diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 000000000..7073c9b93 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,82 @@ +# RocketPy Workspace Instructions + +## Code Style +- Use snake_case for variables, functions, methods, and modules. Use descriptive names. +- Use PascalCase for classes and UPPER_SNAKE_CASE for constants. +- Keep lines at 88 characters and follow PEP 8 unless existing code in the target file differs. +- Run Ruff as the source of truth for formatting/import organization: + - `make format` + - `make lint` +- Use NumPy-style docstrings for public classes, methods, and functions, including units. +- In case of tooling drift between docs and config, prefer current repository tooling in `Makefile` + and `pyproject.toml`. + +## Architecture +- RocketPy is a modular Python library; keep feature logic in the correct package boundary: + - `rocketpy/simulation`: flight simulation and Monte Carlo orchestration. + - `rocketpy/rocket`, `rocketpy/motors`, `rocketpy/environment`: domain models. + - `rocketpy/mathutils`: numerical primitives and interpolation utilities. + - `rocketpy/plots`, `rocketpy/prints`: output and visualization layers. +- Prefer extending existing classes/patterns over introducing new top-level abstractions. +- Preserve public API stability in `rocketpy/__init__.py` exports. + +## Build and Test +- Use Makefile targets for OS-agnostic workflows: + - `make install` + - `make pytest` + - `make pytest-slow` + - `make coverage` + - `make coverage-report` + - `make build-docs` +- Before finishing code changes, run focused tests first, then broader relevant suites. +- When running Python directly in this workspace, prefer the project virtual + environment interpreter: `.venv/bin/python` on Unix/macOS or + `.venv/Scripts/python.exe` on Windows. +- Slow tests are explicitly marked with `@pytest.mark.slow` and are run with `make pytest-slow`. +- For docs changes, check `make build-docs` output and resolve warnings/errors when practical. + +## Development Workflow +- Target pull requests to `develop` by default; `master` is the stable branch. +- Use branch names in `type/description` format, such as: + - `bug/` + - `doc/` + - `enh/` + - `mnt/` + - `tst/` +- Prefer rebasing feature branches on top of `develop` to keep history linear. +- Keep commit and PR titles explicit and prefixed with project acronyms when possible: + - `BUG`, `DOC`, `ENH`, `MNT`, `TST`, `BLD`, `REL`, `REV`, `STY`, `DEV`. + +## Conventions +- SI units are the default. Document units and coordinate-system references explicitly. +- Position/reference-frame arguments are critical in this codebase. Be explicit about orientation + (for example, `tail_to_nose`, `nozzle_to_combustion_chamber`). +- Include unit tests for new behavior. Follow AAA structure and clear test names. +- Use fixtures from `tests/fixtures`; if adding a new fixture module, update `tests/conftest.py`. +- Use `pytest.approx` for floating-point checks where appropriate. +- Use `@cached_property` for expensive computations when helpful, and be careful with stale-cache + behavior when underlying mutable state changes. +- Keep behavior backward compatible across the public API exported via `rocketpy/__init__.py`. +- Prefer extending existing module patterns over creating new top-level package structure. + +## Testing Taxonomy +- Unit tests are mandatory for new behavior. +- Unit tests in RocketPy can be sociable (real collaborators allowed) but should still be fast and + method-focused. +- Treat tests as integration tests when they are strongly I/O-oriented or broad across many methods, + including `all_info()` convention cases. +- Acceptance tests represent realistic user/flight scenarios and may compare simulation thresholds to + known flight data. + +## Documentation Links +- Contributor workflow and setup: `docs/development/setting_up.rst` +- Style and naming details: `docs/development/style_guide.rst` +- Testing philosophy and structure: `docs/development/testing.rst` +- API reference conventions: `docs/reference/index.rst` +- Domain/physics background: `docs/technical/index.rst` + +## Scoped Customizations +- Simulation-specific rules: `.agents/skills/simulation-safety/SKILL.md` +- Test-authoring rules: `.agents/skills/tests-python/SKILL.md` +- RST/Sphinx documentation rules: `.agents/skills/sphinx-docs/SKILL.md` +- Specialized review persona: `.agents/skills/rocketpy-reviewer/SKILL.md` diff --git a/.agents/skills/rocketpy-reviewer/SKILL.md b/.agents/skills/rocketpy-reviewer/SKILL.md new file mode 100644 index 000000000..30d3f7598 --- /dev/null +++ b/.agents/skills/rocketpy-reviewer/SKILL.md @@ -0,0 +1,62 @@ +--- +description: "Physics-safe RocketPy code review agent. Use for pull request review, unit consistency checks, coordinate-frame validation, cached-property risk detection, and regression-focused test-gap analysis." +name: "RocketPy Reviewer" +tools: [read, search] +argument-hint: "Review these changes for physics correctness and regression risk: " +user-invocable: true +--- +You are a RocketPy-focused reviewer for physics safety and regression risk. + +## Goals + +- Detect behavioral regressions and numerical/physics risks before merge. +- Validate unit consistency and coordinate/reference-frame correctness. +- Identify stale-cache risks when `@cached_property` interacts with mutable state. +- Check test coverage quality for changed behavior. +- Verify alignment with RocketPy workflow and contributor conventions. + +## Review Priorities + +1. Correctness and safety issues (highest severity). +2. Behavioral regressions and API compatibility. +3. Numerical stability and tolerance correctness. +4. Missing tests or weak assertions. +5. Documentation mismatches affecting users. +6. Workflow violations (test placement, branch/PR conventions, or missing validation evidence). + +## RocketPy-Specific Checks + +- SI units are explicit and consistent. +- Orientation conventions are unambiguous (`tail_to_nose`, `nozzle_to_combustion_chamber`, etc.). +- New/changed simulation logic does not silently invalidate cached values. +- Floating-point assertions use `pytest.approx` where needed. +- New fixtures are wired through `tests/conftest.py` when applicable. +- Test type is appropriate for scope (`unit`, `integration`, `acceptance`) and `all_info()`-style tests + are not misclassified. +- New behavior includes at least one regression-oriented test and relevant edge-case checks. +- For docs-affecting changes, references and paths remain valid and build warnings are addressed. +- Tooling recommendations match current repository setup (prefer Makefile plus `pyproject.toml` + settings when docs are outdated). + +## Validation Expectations + +- Prefer focused test runs first, then broader relevant suites. +- Recommend `make format` and `make lint` when style/lint risks are present. +- Recommend `make build-docs` when `.rst` files or API docs are changed. + +## Output Format + +Provide findings first, ordered by severity. +For each finding include: +- Severity: Critical, High, Medium, or Low +- Location: file path and line +- Why it matters: behavioral or physics risk +- Suggested fix: concrete, minimal change + +After findings, include: +- Open questions or assumptions +- Residual risks or testing gaps +- Brief change summary +- Suggested validation commands (only when useful) + +If no findings are identified, state that explicitly and still report residual risks/testing gaps. diff --git a/.agents/skills/simulation-safety/SKILL.md b/.agents/skills/simulation-safety/SKILL.md new file mode 100644 index 000000000..cc2af5d27 --- /dev/null +++ b/.agents/skills/simulation-safety/SKILL.md @@ -0,0 +1,41 @@ +--- +description: "Use when editing rocketpy/simulation code, including Flight state updates, Monte Carlo orchestration, post-processing, or cached computations. Covers simulation state safety, unit/reference-frame clarity, and regression checks." +name: "Simulation Safety" +applyTo: "rocketpy/simulation/**/*.py" +--- +# Simulation Safety Guidelines + +- Keep simulation logic inside `rocketpy/simulation` and avoid leaking domain behavior that belongs in + `rocketpy/rocket`, `rocketpy/motors`, or `rocketpy/environment`. +- Preserve public API behavior and exported names used by `rocketpy/__init__.py`. +- Prefer extending existing simulation components before creating new abstractions: + - `flight.py`: simulation state, integration flow, and post-processing. + - `monte_carlo.py`: orchestration and statistical execution workflows. + - `flight_data_exporter.py` and `flight_data_importer.py`: persistence and interchange. + - `flight_comparator.py`: comparative analysis outputs. +- Be explicit with physical units and reference frames in new parameters, attributes, and docstrings. +- For position/orientation-sensitive behavior, use explicit conventions (for example + `tail_to_nose`, `nozzle_to_combustion_chamber`) and avoid implicit assumptions. +- Treat state mutation carefully when cached values exist. +- If changes can invalidate `@cached_property` values, either avoid post-computation mutation or + explicitly invalidate affected caches in a controlled, documented way. +- Keep numerical behavior deterministic unless stochastic behavior is intentional and documented. +- For Monte Carlo and stochastic code paths, make randomness controllable and reproducible when tests + rely on it. +- Prefer vectorized NumPy operations for hot paths and avoid introducing Python loops in + performance-critical sections without justification. +- Guard against numerical edge cases (zero/near-zero denominators, interpolation limits, and boundary + conditions). +- Do not change default numerical tolerances or integration behavior without documenting motivation and + validating regression impact. +- Add focused regression tests for changed behavior, including edge cases and orientation-dependent + behavior. +- For floating-point expectations, use `pytest.approx` with meaningful tolerances. +- Run focused tests first, then broader relevant tests (`make pytest` and `make pytest-slow` when + applicable). + +See: +- `docs/development/testing.rst` +- `docs/development/style_guide.rst` +- `docs/development/setting_up.rst` +- `docs/technical/index.rst` diff --git a/.agents/skills/sphinx-docs/SKILL.md b/.agents/skills/sphinx-docs/SKILL.md new file mode 100644 index 000000000..8c24cac53 --- /dev/null +++ b/.agents/skills/sphinx-docs/SKILL.md @@ -0,0 +1,32 @@ +--- +description: "Use when writing or editing docs/**/*.rst. Covers Sphinx/reStructuredText conventions, cross-references, toctree hygiene, and RocketPy unit/reference-frame documentation requirements." +name: "Sphinx RST Conventions" +applyTo: "docs/**/*.rst" +--- +# Sphinx and RST Guidelines + +- Follow existing heading hierarchy and style in the target document. +- Prefer linking to existing documentation pages instead of duplicating content. +- Use Sphinx cross-references where appropriate (`:class:`, `:func:`, `:mod:`, `:doc:`, `:ref:`). +- Keep API names and module paths consistent with current code exports. +- Document physical units and coordinate/reference-frame conventions explicitly. +- Include concise, practical examples when introducing new user-facing behavior. +- Keep prose clear and technical; avoid marketing language in development/reference docs. +- When adding a new page, update the relevant `toctree` so it appears in navigation. +- Use RocketPy docs build workflow: + - `make build-docs` from repository root for normal validation. + - If stale artifacts appear, clean docs build outputs via `cd docs && make clean`, then rebuild. +- Treat new Sphinx warnings/errors as issues to fix or explicitly call out in review notes. +- Keep `docs/index.rst` section structure coherent with user, development, reference, technical, and + examples navigation. +- Do not edit Sphinx-generated scaffolding files unless explicitly requested: + - `docs/Makefile` + - `docs/make.bat` +- For API docs, ensure references remain aligned with exported/public objects and current module paths. + +See: +- `docs/index.rst` +- `docs/development/build_docs.rst` +- `docs/development/style_guide.rst` +- `docs/reference/index.rst` +- `docs/technical/index.rst` diff --git a/.agents/skills/tests-python/SKILL.md b/.agents/skills/tests-python/SKILL.md new file mode 100644 index 000000000..1e9626142 --- /dev/null +++ b/.agents/skills/tests-python/SKILL.md @@ -0,0 +1,36 @@ +--- +description: "Use when creating or editing pytest files in tests/. Enforces AAA structure, naming conventions, fixture usage, parameterization, slow-test marking, and numerical assertion practices for RocketPy." +name: "RocketPy Pytest Standards" +applyTo: "tests/**/*.py" +--- +# RocketPy Test Authoring Guidelines + +- Unit tests are mandatory for new behavior. +- Follow AAA structure in each test: Arrange, Act, Assert. +- Use descriptive test names matching project convention: + - `test_methodname` + - `test_methodname_stateundertest` + - `test_methodname_expectedbehaviour` +- Include docstrings that clearly state expected behavior and context. +- Prefer parameterization for scenario matrices instead of duplicated tests. +- Classify tests correctly: + - `tests/unit`: fast, method-focused tests (sociable unit tests are acceptable in RocketPy). + - `tests/integration`: broad multi-method/component interactions and strongly I/O-oriented cases. + - `tests/acceptance`: realistic end-user/flight scenarios with threshold-based expectations. +- By RocketPy convention, tests centered on `all_info()` behavior are integration tests. +- Reuse fixtures from `tests/fixtures` whenever possible. +- Keep fixture organization aligned with existing categories under `tests/fixtures` + (environment, flight, motor, rockets, surfaces, units, etc.). +- If you add a new fixture module, update `tests/conftest.py` so fixtures are discoverable. +- Keep tests deterministic: set seeds when randomness is involved and avoid unstable external + dependencies unless integration behavior explicitly requires them. +- Use `pytest.approx` for floating-point comparisons with realistic tolerances. +- Mark expensive tests with `@pytest.mark.slow` and ensure they can run under the project slow-test + workflow. +- Include at least one negative or edge-case assertion for new behaviors. +- When adding a bug fix, include a regression test that fails before the fix and passes after it. + +See: +- `docs/development/testing.rst` +- `docs/development/style_guide.rst` +- `docs/development/setting_up.rst` diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md new file mode 100644 index 000000000..9ddfe1a89 --- /dev/null +++ b/.claude/hooks/README.md @@ -0,0 +1,40 @@ +# Claude Code hooks + +Project-level [Claude Code hooks](https://docs.claude.com/en/docs/claude-code/hooks) +that keep Claude's edits lint-clean and stop lint failures from reaching CI. They +are wired up in [`.claude/settings.json`](../settings.json) and run automatically +for anyone using Claude Code in this repo. + +Both hooks only run **ruff** (the fast linter/formatter, ~0.15s for the whole +repo). Pylint is intentionally left to CI: on this codebase its per-file startup +cost is seconds, too slow for an interactive guard. ruff is also what has actually +been breaking the `Linters` job. + +## Hooks + +| Hook | Event | What it does | +| --- | --- | --- | +| [`ruff_on_edit.py`](ruff_on_edit.py) | `PostToolUse` on `Edit`/`Write`/`MultiEdit` | After Claude edits a `.py` file, runs `ruff format` then `ruff check --fix` on that one file. Surfaces any unfixable lint issues back to Claude. | +| [`ruff_guard_push.py`](ruff_guard_push.py) | `PreToolUse` on `Bash` | Before a `git push`, runs `ruff check` + `ruff format --check` over the repo and **blocks the push** if either would fail (i.e. before it turns CI red). | + +## Design guarantees + +- **Cross-platform** (Windows / macOS / Linux): pure Python, no shell built-ins, + no OS-specific paths. ruff is invoked as ` -m ruff` so it + resolves to the same environment the hook runs in. +- **Never gets in the way**: if `ruff` is not installed, both hooks are a silent + no-op. The push guard only acts on actual `git push` commands. + +## Requirements + +A working `python` on `PATH` (an activated virtualenv is the normal setup) with +`ruff` installed: `pip install ruff` — already included in `pip install .[tests]`. + +## Testing a hook manually + +Pipe a sample event payload to a hook on stdin: + +```bash +printf '{"tool_input":{"file_path":"some_file.py"}}' | python .claude/hooks/ruff_on_edit.py +printf '{"tool_input":{"command":"git push"}}' | python .claude/hooks/ruff_guard_push.py +``` diff --git a/.claude/hooks/ruff_guard_push.py b/.claude/hooks/ruff_guard_push.py new file mode 100644 index 000000000..651e96a43 --- /dev/null +++ b/.claude/hooks/ruff_guard_push.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Claude Code ``PreToolUse`` hook: block ``git push`` if ruff would fail in CI. + +Runs before every ``Bash`` command. If the command is a ``git push``, it runs the +two fast ruff steps from the Linters CI job (``ruff check .`` and +``ruff format --check .``) over the repo. If either would fail, the push is +blocked (exit code 2) and the failure is reported back to Claude so it gets fixed +*before* it turns the CI red. + +Only ruff runs here (both steps together are ~0.15s). Pylint is intentionally left +to CI: on this repo pylint costs seconds-per-file of startup, which is too slow for +an interactive guard. ruff is what has actually been breaking the Linters job. + +Design notes +------------ +* ruff is invoked as `` -m ruff`` (see ``ruff_on_edit.py``). +* Cross-platform: pure Python, no shell built-ins, no OS-specific paths. +* Silent no-op when the command is not a push, or when ruff is not installed. +""" + +import importlib.util +import json +import os +import re +import subprocess +import sys + +# Match ``git push`` as a real command (start of line or after a shell +# separator), tolerating global flags like ``git -C . push``. Avoids most +# false positives from the substring "push" appearing elsewhere. +_GIT_PUSH = re.compile(r"(?:^|[\s;&|(`])git(?:\s+-\S+|\s+--\S+)*\s+push\b") + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + + command = (payload.get("tool_input") or {}).get("command", "") + if not isinstance(command, str) or not _GIT_PUSH.search(command): + return 0 + + if importlib.util.find_spec("ruff") is None: + return 0 # ruff not installed: don't block pushes. + + # Target the project root explicitly. ``CLAUDE_PROJECT_DIR`` is set by Claude + # Code to the native-format project root; passing it as a path argument (not + # as the subprocess ``cwd``) keeps this robust across platforms/shells. Falls + # back to ".", which resolves against the hook's working directory. + target = os.environ.get("CLAUDE_PROJECT_DIR") or "." + ruff = [sys.executable, "-m", "ruff"] + check = subprocess.run(ruff + ["check", target], capture_output=True, text=True) + fmt = subprocess.run( + ruff + ["format", "--check", target], capture_output=True, text=True + ) + if check.returncode == 0 and fmt.returncode == 0: + return 0 + + out = [ + "[ruff] Push blocked — this would fail the Linters CI job. " + "Fix the issues below, then push again.\n" + ] + if check.returncode != 0: + out.append("--- ruff check ---\n" + (check.stdout or "") + (check.stderr or "")) + if fmt.returncode != 0: + out.append( + "--- ruff format --check . ---\n" + + (fmt.stdout or "") + + (fmt.stderr or "") + + "\nFix formatting with: python -m ruff format .\n" + ) + sys.stderr.write("\n".join(out)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/hooks/ruff_on_edit.py b/.claude/hooks/ruff_on_edit.py new file mode 100644 index 000000000..fa9895119 --- /dev/null +++ b/.claude/hooks/ruff_on_edit.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Claude Code ``PostToolUse`` hook: auto-format + lint-fix edited Python files. + +Runs after every ``Edit``/``Write``/``MultiEdit``. Reads the hook payload from +stdin, and if the touched file is a ``.py`` file, runs ``ruff format`` followed by +``ruff check --fix`` on *just that one file*. ruff is ~instant per file, so this +keeps Claude's edits continuously formatted and lint-clean without slowing the +session down. + +Design notes +------------ +* ruff is invoked as `` -m ruff`` so it always resolves to the + same environment the hook runs in (matches the ``python -m ruff`` convention + the repo already uses) instead of relying on a ``ruff`` binary being on PATH. +* Cross-platform: pure Python, no shell built-ins, no OS-specific paths. +* Degrades to a silent no-op when ruff is not installed, so it can never block + editing for a contributor who has not installed the linter yet. +* Exit code 2 feeds any *unfixable* lint issues back to Claude so it fixes them; + the common case (format + autofix succeed) exits 0 silently. +""" + +import importlib.util +import json +import subprocess +import sys + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + + tool_input = payload.get("tool_input") or {} + file_path = tool_input.get("file_path") + if not isinstance(file_path, str) or not file_path.endswith(".py"): + return 0 + + if importlib.util.find_spec("ruff") is None: + # ruff not installed in this environment: never block the workflow. + return 0 + + ruff = [sys.executable, "-m", "ruff"] + subprocess.run(ruff + ["format", file_path], capture_output=True, text=True) + fix = subprocess.run( + ruff + ["check", "--fix", file_path], capture_output=True, text=True + ) + + if fix.returncode != 0: + sys.stderr.write( + f"[ruff] Lint issues ruff could not auto-fix in {file_path}:\n" + f"{fix.stdout}{fix.stderr}" + ) + return 2 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..91e8faa2b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,46 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch *)", + "Bash(git merge *)", + "Bash(git checkout *)", + "Bash(python -m ruff --version)", + "Bash(python -m pylint --version)", + "Bash(python -m ruff check rocketpy/plots/flight_plots.py tests/unit/test_plots.py)", + "Bash(python -m ruff format --check rocketpy/plots/flight_plots.py tests/unit/test_plots.py)", + "Bash(python -m ruff check .)", + "Bash(python -m ruff format --check .)", + "Bash(python -m pylint rocketpy/plots/flight_plots.py tests/unit/test_plots.py)", + "Bash(python -m ruff check rocketpy/plots/flight_plots.py)", + "Bash(python -m ruff format --check rocketpy/plots/flight_plots.py)", + "Bash(python -m pytest tests/unit/test_plots.py -k \"animate_trajectory or animate_rotate\" -p no:cacheprovider -q)", + "Bash(python -m pytest tests/unit/test_plots.py -p no:cacheprovider -q)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/ruff_on_edit.py", + "statusMessage": "ruff: formatting edited file" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/ruff_guard_push.py", + "statusMessage": "ruff: pre-push lint guard" + } + ] + } + ] + } +} diff --git a/.claude/skills/rocketpy-release/SKILL.md b/.claude/skills/rocketpy-release/SKILL.md new file mode 100644 index 000000000..ebf8acd57 --- /dev/null +++ b/.claude/skills/rocketpy-release/SKILL.md @@ -0,0 +1,142 @@ +--- +name: rocketpy-release +description: >- + Cut a new RocketPy release: prepare the version-bump branch, update the + changelog and all version references, and guide the PR-to-master + PyPI flow. + Use when the user wants to release a new version (e.g. "release v1.13", + "fazer um release", "bump version and cut a release", "REL PR"). +--- + +# RocketPy Release + +Prepare and guide a RocketPy version release following the team's established +`REL:` pattern. This skill prepares the branch and the version-bump commit; the +human opens the PR and publishes the GitHub Release themselves. + +## Branching model + +RocketPy uses **`develop`** (integration) and **`master`** (released). All +feature/fix work lands on `develop`. A release promotes `develop` → `master`. + +Since **v1.12.0** the team uses a **single streamlined PR** (see PR #935): +one `rel/vX.Y.Z` branch — created off `develop`, with the version already +bumped — opened **straight to `master`**. (Older releases used three PRs; do +NOT revive that.) + +PyPI is published automatically by `.github/workflows/publish-to-pypi.yml` when +a **GitHub Release is published** (`on: release: [published]`). Creating the +release/tag in the GitHub UI is the final manual trigger. + +## Before you start — gather state + +Run these and confirm with the user: + +```bash +git fetch origin +grep -m1 '^version' pyproject.toml # current version +git show origin/master:pyproject.toml | grep -m1 '^version' # released version +git rev-list --left-right --count origin/master...origin/develop # ahead/behind +git tag --sort=-creatordate | head -5 # last tags +``` + +Confirm the **target version** (semver: MAJOR.MINOR.PATCH) with the user before +touching files. Never guess the number. + +## Step 1 — Create the release branch + +Branch off the latest `develop`: + +```bash +git checkout develop && git pull origin develop +git checkout -b rel/vX.Y.Z +``` + +## Step 2 — The version-bump commit + +Update **every** version reference. These have been missed before (e.g. +`installation.rst` sat stale at 1.11.0 through 1.12.x) — check all of them: + +| File | What to change | +|------|----------------| +| `pyproject.toml` | `version = "X.Y.Z"` | +| `docs/conf.py` | `release = "X.Y.Z"` and `copyright = ", RocketPy Team"` | +| `docs/user/installation.rst` | `pip install rocketpy==X.Y.Z` | +| `CHANGELOG.md` | see below | + +Sanity-sweep for any other stragglers (ignore `.venv`): + +```bash +grep -rn "" --include=*.py --include=*.toml --include=*.rst . \ + | grep -v '.venv' +``` + +### CHANGELOG.md + +The changelog `[Unreleased]` section is auto-populated on every merge to +`develop` by `.github/workflows/changelog.yml`, so entries should already be +present. To release: + +1. Rename the current `## [Unreleased] - yyyy-mm-dd` header to + `## [vX.Y.Z] - YYYY-MM-DD` (today's date). +2. Insert a **fresh empty** `[Unreleased]` block above it, preserving the + template comment and empty subsections: + + ```markdown + ## [Unreleased] - yyyy-mm-dd + + + + ### Added + + ### Changed + + ### Fixed + ``` +3. Add a `Changed` entry to the released section for the bump itself: + `- REL: bumps up rocketpy version to X.Y.Z [#PR](https://github.com/RocketPy-Team/RocketPy/pull/PR)` + (fill the PR number after the PR is opened, or leave a placeholder to edit). +4. Prune obvious noise if the maintainer wants (tests, CI, merge commits are + not meant to be in the changelog per the file's own header comment). + +### Commit + +Match the historical message style (`REL: bump version to X.Y.Z`): + +```bash +git add pyproject.toml docs/conf.py docs/user/installation.rst CHANGELOG.md +git commit -m "REL: bumps up rocketpy version to X.Y.Z" +``` + +Keep it to **one** bump commit. Do not include unrelated changes. + +## Step 3 — Push and hand off the PR + +```bash +git push -u origin rel/vX.Y.Z +``` + +**Stop here and let the human open the PR** (team preference — they open it +themselves). Give them the ready-to-use details: + +- **Base:** `master` ← **Compare:** `rel/vX.Y.Z` +- **Title:** `REL: vX.Y.Z` (or `Rel/vX.Y.Z`) +- **Body:** summarize the highlights; note the single-PR-to-master approach. + +## Step 4 — After the PR merges (guide the human) + +1. **Sync `master` back into `develop`** so develop carries the new version and + the merge history. Historically a PR titled like + `MNT: update develop to release vX.Y.Z`, base `develop` ← compare `master`. +2. **Create the GitHub Release** in the UI: new tag `vX.Y.Z` targeting + `master`, title `vX.Y.Z`, paste the changelog section as notes, Publish. + → This triggers `publish-to-pypi.yml` and ships to PyPI. +3. Verify the PyPI publish workflow succeeded and the new version is live. + +## Guardrails + +- Only prepare the branch + bump commit. **Do not open the PR or publish the + release** unless the user explicitly asks — they do those steps themselves. +- Confirm the target version with the user before editing. +- Base the PR on **`master`**, not `develop`. +- One clean `REL:` commit; no unrelated edits. diff --git a/.github/agents/rocketpy-reviewer.agent.md b/.github/agents/rocketpy-reviewer.agent.md new file mode 100644 index 000000000..be1b64b13 --- /dev/null +++ b/.github/agents/rocketpy-reviewer.agent.md @@ -0,0 +1,62 @@ +--- +description: "Physics-safe RocketPy code review agent. Use for pull request review, unit consistency checks, coordinate-frame validation, cached-property risk detection, and regression-focused test-gap analysis." +name: "RocketPy Reviewer" +tools: [read, search, execute] +argument-hint: "Review these changes for physics correctness and regression risk: " +user-invocable: true +--- +You are a RocketPy-focused reviewer for physics safety and regression risk. + +## Goals + +- Detect behavioral regressions and numerical/physics risks before merge. +- Validate unit consistency and coordinate/reference-frame correctness. +- Identify stale-cache risks when `@cached_property` interacts with mutable state. +- Check test coverage quality for changed behavior. +- Verify alignment with RocketPy workflow and contributor conventions. + +## Review Priorities + +1. Correctness and safety issues (highest severity). +2. Behavioral regressions and API compatibility. +3. Numerical stability and tolerance correctness. +4. Missing tests or weak assertions. +5. Documentation mismatches affecting users. +6. Workflow violations (test placement, branch/PR conventions, or missing validation evidence). + +## RocketPy-Specific Checks + +- SI units are explicit and consistent. +- Orientation conventions are unambiguous (`tail_to_nose`, `nozzle_to_combustion_chamber`, etc.). +- New/changed simulation logic does not silently invalidate cached values. +- Floating-point assertions use `pytest.approx` where needed. +- New fixtures are wired through `tests/conftest.py` when applicable. +- Test type is appropriate for scope (`unit`, `integration`, `acceptance`) and `all_info()`-style tests + are not misclassified. +- New behavior includes at least one regression-oriented test and relevant edge-case checks. +- For docs-affecting changes, references and paths remain valid and build warnings are addressed. +- Tooling recommendations match current repository setup (prefer Makefile plus `pyproject.toml` + settings when docs are outdated). + +## Validation Expectations + +- Prefer focused test runs first, then broader relevant suites. +- Recommend `make format` and `make lint` when style/lint risks are present. +- Recommend `make build-docs` when `.rst` files or API docs are changed. + +## Output Format + +Provide findings first, ordered by severity. +For each finding include: +- Severity: Critical, High, Medium, or Low +- Location: file path and line +- Why it matters: behavioral or physics risk +- Suggested fix: concrete, minimal change + +After findings, include: +- Open questions or assumptions +- Residual risks or testing gaps +- Brief change summary +- Suggested validation commands (only when useful) + +If no findings are identified, state that explicitly and still report residual risks/testing gaps. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f5366cb3b..382aa15e0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,221 +1,80 @@ -# GitHub Copilot Instructions for RocketPy - -This file provides instructions for GitHub Copilot when working on the RocketPy codebase. -These guidelines help ensure consistency with the project's coding standards and development practices. - -## Project Overview - -RocketPy is a Python library for 6-DOF rocket trajectory simulation. -It's designed for high-power rocketry applications with focus on accuracy, performance, and ease of use. - -## Coding Standards - -### Naming Conventions -- **Use `snake_case` for all new code** - variables, functions, methods, and modules -- **Use descriptive names** - prefer `angle_of_attack` over `a` or `alpha` -- **Class names use PascalCase** - e.g., `SolidMotor`, `Environment`, `Flight` -- **Constants use UPPER_SNAKE_CASE** - e.g., `DEFAULT_GRAVITY`, `EARTH_RADIUS` - -### Code Style -- Follow **PEP 8** guidelines -- Line length: **88 characters** (Black's default) -- Organize imports with **isort** -- Our official formatter is the **ruff frmat** - -### Documentation -- **All public classes, methods, and functions must have docstrings** -- Use **NumPy style docstrings** -- Include **Parameters**, **Returns**, and **Examples** sections -- Document **units** for physical quantities (e.g., "in meters", "in radians") - -### Testing -- Write **unit tests** for all new features using pytest -- Follow **AAA pattern** (Arrange, Act, Assert) -- Use descriptive test names following: `test_methodname_expectedbehaviour` -- Include test docstrings explaining expected behavior -- Use **parameterization** for testing multiple scenarios -- Create pytest fixtures to avoid code repetition - -## Domain-Specific Guidelines - -### Physical Units and Conventions -- **SI units by default** - meters, kilograms, seconds, radians -- **Document coordinate systems** clearly (e.g., "tail_to_nose", "nozzle_to_combustion_chamber") -- **Position parameters** are critical - always document reference points -- Use **descriptive variable names** for physical quantities - -### Rocket Components -- **Motors**: SolidMotor, HybridMotor and LiquidMotor classes are children classes of the Motor class -- **Aerodynamic Surfaces**: They have Drag curves and lift coefficients -- **Parachutes**: Trigger functions, deployment conditions -- **Environment**: Atmospheric models, weather data, wind profiles - -### Mathematical Operations -- Use **numpy arrays** for vectorized operations (this improves performance) -- Prefer **scipy functions** for numerical integration and optimization -- **Handle edge cases** in calculations (division by zero, sqrt of negative numbers) -- **Validate input ranges** for physical parameters -- Monte Carlo simulations: sample from `numpy.random` for random number generation and creates several iterations to assess uncertainty in simulations. - -## File Structure and Organization - -### Source Code Organization - -Reminds that `rocketpy` is a Python package served as a library, and its source code is organized into several modules to facilitate maintainability and clarity. The following structure is recommended: - -``` -rocketpy/ -├── core/ # Core simulation classes -├── motors/ # Motor implementations -├── environment/ # Atmospheric and environmental models -├── plots/ # Plotting and visualization -├── tools/ # Utility functions -└── mathutils/ # Mathematical utilities -``` - -Please refer to popular Python packages like `scipy`, `numpy`, and `matplotlib` for inspiration on module organization. - -### Test Organization -``` -tests/ -├── unit/ # Unit tests -├── integration/ # Integration tests -├── acceptance/ # Acceptance tests -└── fixtures/ # Test fixtures organized by component -``` - -### Documentation Structure -``` -docs/ -├── user/ # User guides and tutorials -├── development/ # Development documentation -├── reference/ # API reference -├── examples/ # Flight examples and notebooks -└── technical/ # Technical documentation -``` - -## Common Patterns and Practices - -### Error Handling -- Use **descriptive error messages** with context -- **Validate inputs** at class initialization and method entry -- Raise **appropriate exception types** (ValueError, TypeError, etc.) -- Include **suggestions for fixes** in error messages - -### Performance Considerations -- Use **vectorized operations** where possible -- **Cache expensive computations** when appropriate (we frequently use `cached_property`) -- Keep in mind that RocketPy must be fast! - -### Backward Compatibility -- **Avoid breaking changes** in public APIs -- Use **deprecation warnings** before removing features -- **Document code changes** in docstrings and CHANGELOG - -## AI Assistant Guidelines - -### Code Generation -- **Always include docstrings** for new functions and classes -- **Follow existing patterns** in the codebase -- **Consider edge cases** and error conditions - -### Code Review and Suggestions -- **Check for consistency** with existing code style -- **Verify physical units** and coordinate systems -- **Ensure proper error handling** and input validation -- **Suggest performance improvements** when applicable -- **Recommend additional tests** for new functionality - -### Documentation Assistance -- **Use NumPy docstring format** consistently -- **Include practical examples** in docstrings -- **Document physical meanings** of parameters -- **Cross-reference related functions** and classes - -## Testing Guidelines - -### Unit Tests -- **Test individual methods** in isolation -- **Use fixtures** from the appropriate test fixture modules -- **Mock external dependencies** when necessary -- **Test both happy path and error conditions** - -### Integration Tests -- **Test interactions** between components -- **Verify end-to-end workflows** (Environment → Motor → Rocket → Flight) - -### Test Data -- **Use realistic parameters** for rocket simulations -- **Include edge cases** (very small/large rockets, extreme conditions) -- **Test with different coordinate systems** and orientations - -## Project-Specific Considerations - -### User Experience -- **Provide helpful error messages** with context and suggestions -- **Include examples** in docstrings and documentation -- **Support common use cases** with reasonable defaults - -## Examples of Good Practices - -### Function Definition -```python -def calculate_drag_force( - velocity, - air_density, - drag_coefficient, - reference_area -): - """Calculate drag force using the standard drag equation. - - Parameters - ---------- - velocity : float - Velocity magnitude in m/s. - air_density : float - Air density in kg/m³. - drag_coefficient : float - Dimensionless drag coefficient. - reference_area : float - Reference area in m². - - Returns - ------- - float - Drag force in N. - - Examples - -------- - >>> drag_force = calculate_drag_force(100, 1.225, 0.5, 0.01) - >>> print(f"Drag force: {drag_force:.2f} N") - """ - if velocity < 0: - raise ValueError("Velocity must be non-negative") - if air_density <= 0: - raise ValueError("Air density must be positive") - if reference_area <= 0: - raise ValueError("Reference area must be positive") - - return 0.5 * air_density * velocity**2 * drag_coefficient * reference_area -``` - -### Test Example -```python -def test_calculate_drag_force_returns_correct_value(): - """Test drag force calculation with known inputs.""" - # Arrange - velocity = 100.0 # m/s - air_density = 1.225 # kg/m³ - drag_coefficient = 0.5 - reference_area = 0.01 # m² - expected_force = 30.625 # N - - # Act - result = calculate_drag_force(velocity, air_density, drag_coefficient, reference_area) - - # Assert - assert abs(result - expected_force) < 1e-6 -``` - - -Remember: RocketPy prioritizes accuracy, performance, and usability. Always consider the physical meaning of calculations and provide clear, well-documented interfaces for users. +# RocketPy Workspace Instructions + +## Code Style +- Use snake_case for variables, functions, methods, and modules. Use descriptive names. +- Use PascalCase for classes and UPPER_SNAKE_CASE for constants. +- Keep lines at 88 characters and follow PEP 8 unless existing code in the target file differs. +- Run Ruff as the source of truth for formatting/import organization: + - `make format` + - `make lint` +- Use NumPy-style docstrings for public classes, methods, and functions, including units. +- In case of tooling drift between docs and config, prefer current repository tooling in `Makefile` + and `pyproject.toml`. + +## Architecture +- RocketPy is a modular Python library; keep feature logic in the correct package boundary: + - `rocketpy/simulation`: flight simulation and Monte Carlo orchestration. + - `rocketpy/rocket`, `rocketpy/motors`, `rocketpy/environment`: domain models. + - `rocketpy/mathutils`: numerical primitives and interpolation utilities. + - `rocketpy/plots`, `rocketpy/prints`: output and visualization layers. +- Prefer extending existing classes/patterns over introducing new top-level abstractions. +- Preserve public API stability in `rocketpy/__init__.py` exports. + +## Build and Test +- Use Makefile targets for OS-agnostic workflows: + - `make install` + - `make pytest` + - `make pytest-slow` + - `make coverage` + - `make coverage-report` + - `make build-docs` +- Before finishing code changes, run focused tests first, then broader relevant suites. +- When running Python directly in this workspace, prefer `.venv/Scripts/python.exe`. +- Slow tests are explicitly marked with `@pytest.mark.slow` and are run with `make pytest-slow`. +- For docs changes, check `make build-docs` output and resolve warnings/errors when practical. + +## Development Workflow +- Target pull requests to `develop` by default; `master` is the stable branch. +- Use branch names in `type/description` format, such as: + - `bug/` + - `doc/` + - `enh/` + - `mnt/` + - `tst/` +- Prefer rebasing feature branches on top of `develop` to keep history linear. +- Keep commit and PR titles explicit and prefixed with project acronyms when possible: + - `BUG`, `DOC`, `ENH`, `MNT`, `TST`, `BLD`, `REL`, `REV`, `STY`, `DEV`. + +## Conventions +- SI units are the default. Document units and coordinate-system references explicitly. +- Position/reference-frame arguments are critical in this codebase. Be explicit about orientation + (for example, `tail_to_nose`, `nozzle_to_combustion_chamber`). +- Include unit tests for new behavior. Follow AAA structure and clear test names. +- Use fixtures from `tests/fixtures`; if adding a new fixture module, update `tests/conftest.py`. +- Use `pytest.approx` for floating-point checks where appropriate. +- Use `@cached_property` for expensive computations when helpful, and be careful with stale-cache + behavior when underlying mutable state changes. +- Keep behavior backward compatible across the public API exported via `rocketpy/__init__.py`. +- Prefer extending existing module patterns over creating new top-level package structure. + +## Testing Taxonomy +- Unit tests are mandatory for new behavior. +- Unit tests in RocketPy can be sociable (real collaborators allowed) but should still be fast and + method-focused. +- Treat tests as integration tests when they are strongly I/O-oriented or broad across many methods, + including `all_info()` convention cases. +- Acceptance tests represent realistic user/flight scenarios and may compare simulation thresholds to + known flight data. + +## Documentation Links +- Contributor workflow and setup: `docs/development/setting_up.rst` +- Style and naming details: `docs/development/style_guide.rst` +- Testing philosophy and structure: `docs/development/testing.rst` +- API reference conventions: `docs/reference/index.rst` +- Domain/physics background: `docs/technical/index.rst` + +## Scoped Customizations +- Simulation-specific rules: `.github/instructions/simulation-safety.instructions.md` +- Test-authoring rules: `.github/instructions/tests-python.instructions.md` +- RST/Sphinx documentation rules: `.github/instructions/sphinx-docs.instructions.md` +- Specialized review persona: `.github/agents/rocketpy-reviewer.agent.md` diff --git a/.github/instructions/simulation-safety.instructions.md b/.github/instructions/simulation-safety.instructions.md new file mode 100644 index 000000000..cc2af5d27 --- /dev/null +++ b/.github/instructions/simulation-safety.instructions.md @@ -0,0 +1,41 @@ +--- +description: "Use when editing rocketpy/simulation code, including Flight state updates, Monte Carlo orchestration, post-processing, or cached computations. Covers simulation state safety, unit/reference-frame clarity, and regression checks." +name: "Simulation Safety" +applyTo: "rocketpy/simulation/**/*.py" +--- +# Simulation Safety Guidelines + +- Keep simulation logic inside `rocketpy/simulation` and avoid leaking domain behavior that belongs in + `rocketpy/rocket`, `rocketpy/motors`, or `rocketpy/environment`. +- Preserve public API behavior and exported names used by `rocketpy/__init__.py`. +- Prefer extending existing simulation components before creating new abstractions: + - `flight.py`: simulation state, integration flow, and post-processing. + - `monte_carlo.py`: orchestration and statistical execution workflows. + - `flight_data_exporter.py` and `flight_data_importer.py`: persistence and interchange. + - `flight_comparator.py`: comparative analysis outputs. +- Be explicit with physical units and reference frames in new parameters, attributes, and docstrings. +- For position/orientation-sensitive behavior, use explicit conventions (for example + `tail_to_nose`, `nozzle_to_combustion_chamber`) and avoid implicit assumptions. +- Treat state mutation carefully when cached values exist. +- If changes can invalidate `@cached_property` values, either avoid post-computation mutation or + explicitly invalidate affected caches in a controlled, documented way. +- Keep numerical behavior deterministic unless stochastic behavior is intentional and documented. +- For Monte Carlo and stochastic code paths, make randomness controllable and reproducible when tests + rely on it. +- Prefer vectorized NumPy operations for hot paths and avoid introducing Python loops in + performance-critical sections without justification. +- Guard against numerical edge cases (zero/near-zero denominators, interpolation limits, and boundary + conditions). +- Do not change default numerical tolerances or integration behavior without documenting motivation and + validating regression impact. +- Add focused regression tests for changed behavior, including edge cases and orientation-dependent + behavior. +- For floating-point expectations, use `pytest.approx` with meaningful tolerances. +- Run focused tests first, then broader relevant tests (`make pytest` and `make pytest-slow` when + applicable). + +See: +- `docs/development/testing.rst` +- `docs/development/style_guide.rst` +- `docs/development/setting_up.rst` +- `docs/technical/index.rst` diff --git a/.github/instructions/sphinx-docs.instructions.md b/.github/instructions/sphinx-docs.instructions.md new file mode 100644 index 000000000..8c24cac53 --- /dev/null +++ b/.github/instructions/sphinx-docs.instructions.md @@ -0,0 +1,32 @@ +--- +description: "Use when writing or editing docs/**/*.rst. Covers Sphinx/reStructuredText conventions, cross-references, toctree hygiene, and RocketPy unit/reference-frame documentation requirements." +name: "Sphinx RST Conventions" +applyTo: "docs/**/*.rst" +--- +# Sphinx and RST Guidelines + +- Follow existing heading hierarchy and style in the target document. +- Prefer linking to existing documentation pages instead of duplicating content. +- Use Sphinx cross-references where appropriate (`:class:`, `:func:`, `:mod:`, `:doc:`, `:ref:`). +- Keep API names and module paths consistent with current code exports. +- Document physical units and coordinate/reference-frame conventions explicitly. +- Include concise, practical examples when introducing new user-facing behavior. +- Keep prose clear and technical; avoid marketing language in development/reference docs. +- When adding a new page, update the relevant `toctree` so it appears in navigation. +- Use RocketPy docs build workflow: + - `make build-docs` from repository root for normal validation. + - If stale artifacts appear, clean docs build outputs via `cd docs && make clean`, then rebuild. +- Treat new Sphinx warnings/errors as issues to fix or explicitly call out in review notes. +- Keep `docs/index.rst` section structure coherent with user, development, reference, technical, and + examples navigation. +- Do not edit Sphinx-generated scaffolding files unless explicitly requested: + - `docs/Makefile` + - `docs/make.bat` +- For API docs, ensure references remain aligned with exported/public objects and current module paths. + +See: +- `docs/index.rst` +- `docs/development/build_docs.rst` +- `docs/development/style_guide.rst` +- `docs/reference/index.rst` +- `docs/technical/index.rst` diff --git a/.github/instructions/tests-python.instructions.md b/.github/instructions/tests-python.instructions.md new file mode 100644 index 000000000..1e9626142 --- /dev/null +++ b/.github/instructions/tests-python.instructions.md @@ -0,0 +1,36 @@ +--- +description: "Use when creating or editing pytest files in tests/. Enforces AAA structure, naming conventions, fixture usage, parameterization, slow-test marking, and numerical assertion practices for RocketPy." +name: "RocketPy Pytest Standards" +applyTo: "tests/**/*.py" +--- +# RocketPy Test Authoring Guidelines + +- Unit tests are mandatory for new behavior. +- Follow AAA structure in each test: Arrange, Act, Assert. +- Use descriptive test names matching project convention: + - `test_methodname` + - `test_methodname_stateundertest` + - `test_methodname_expectedbehaviour` +- Include docstrings that clearly state expected behavior and context. +- Prefer parameterization for scenario matrices instead of duplicated tests. +- Classify tests correctly: + - `tests/unit`: fast, method-focused tests (sociable unit tests are acceptable in RocketPy). + - `tests/integration`: broad multi-method/component interactions and strongly I/O-oriented cases. + - `tests/acceptance`: realistic end-user/flight scenarios with threshold-based expectations. +- By RocketPy convention, tests centered on `all_info()` behavior are integration tests. +- Reuse fixtures from `tests/fixtures` whenever possible. +- Keep fixture organization aligned with existing categories under `tests/fixtures` + (environment, flight, motor, rockets, surfaces, units, etc.). +- If you add a new fixture module, update `tests/conftest.py` so fixtures are discoverable. +- Keep tests deterministic: set seeds when randomness is involved and avoid unstable external + dependencies unless integration behavior explicitly requires them. +- Use `pytest.approx` for floating-point comparisons with realistic tolerances. +- Mark expensive tests with `@pytest.mark.slow` and ensure they can run under the project slow-test + workflow. +- Include at least one negative or edge-case assertion for new behaviors. +- When adding a bug fix, include a regression test that fails before the fix and passes after it. + +See: +- `docs/development/testing.rst` +- `docs/development/style_guide.rst` +- `docs/development/setting_up.rst` diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..1d2808213 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "baseBranches": [ + "develop" + ], + "labels": [ + "dependencies" + ], + "dependencyDashboard": true, + "automerge": false, + "pip-compile": { + "managerFilePatterns": [ + "/^docs/requirements\\.txt$/" + ] + }, + "packageRules": [ + { + "matchPackagePatterns": [ + "*" + ], + "matchUpdateTypes": [ + "minor", + "patch" + ], + "groupName": "all non-major dependencies", + "groupSlug": "all-non-major" + }, + { + "description": "docs/requirements.txt is autogenerated by pip-compile from docs/requirements.in. Let the pip-compile manager regenerate the lock file, and stop the pip_requirements manager from editing the autogenerated file line by line.", + "matchManagers": [ + "pip_requirements" + ], + "matchFileNames": [ + "docs/requirements.txt" + ], + "enabled": false + } + ] +} diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 000000000..7bf27daf7 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,83 @@ +name: Populate Changelog +on: + pull_request: + types: [closed] + branches: + - develop + +permissions: + contents: write + +jobs: + Changelog: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Clone RocketPy + uses: actions/checkout@v4 + with: + repository: RocketPy-Team/RocketPy + ref: develop + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Update Changelog + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: | + # The PR title is untrusted input, so it is read from the environment + # inside Python rather than interpolated into a shell/sed program. + # This avoids both shell injection and sed breaking on special + # characters (\, /, &, newlines) in the title. + python - <<'PY' + import os + + labels = os.environ.get("PR_LABELS", "") + title = os.environ["PR_TITLE"] + number = os.environ["PR_NUMBER"] + + section, prefix = "### Added", "ENH" + if "Bug" in labels: + section, prefix = "### Fixed", "BUG" + elif "Refactor" in labels: + section, prefix = "### Changed", "MNT" + elif "Docs" in labels and "Git housekeeping" in labels: + section, prefix = "### Changed", "DOC" + elif "Tests" in labels: + section, prefix = "### Changed", "TST" + elif "Docs" in labels: + section, prefix = "### Added", "DOC" + + entry = ( + f"- {prefix}: {title} " + f"[#{number}](https://github.com/RocketPy-Team/RocketPy/pull/{number})\n" + ) + + with open("CHANGELOG.md", encoding="utf-8") as handle: + lines = handle.readlines() + + for index, line in enumerate(lines): + if line.rstrip("\n") == section: + insert_at = index + 1 + # Place the entry at the top of the list, after the single + # blank line that follows the section header. + if insert_at < len(lines) and lines[insert_at].strip() == "": + insert_at += 1 + lines.insert(insert_at, entry) + break + else: + raise SystemExit(f"Section {section!r} not found in CHANGELOG.md") + + with open("CHANGELOG.md", "w", encoding="utf-8") as handle: + handle.writelines(lines) + PY + + - name: Push Changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "DOC: Update Changelog for PR #${{ github.event.pull_request.number }}" + git push diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..806bef0dc --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,107 @@ +name: Documentation + +on: + # Only PRs targeting master (base branch = master) and pushes to master. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [master] + paths: + - "docs/**" + - "rocketpy/**" # docstrings feed the autodoc API reference + - "pyproject.toml" + - "requirements*" + - ".readthedocs.yaml" + - ".github/workflows/docs.yml" + push: + branches: [master] + paths: + - "docs/**" + - "rocketpy/**" + - "pyproject.toml" + - "requirements*" + - ".readthedocs.yaml" + - ".github/workflows/docs.yml" + +# Least privilege: this workflow only needs to read the repository. +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + build-docs: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] # match the Read the Docs build environment + env: + MPLBACKEND: Agg + # Render jupyter-execute cells as static code blocks instead of running + # them. This keeps the check fast and deterministic: no simulations and, + # crucially, no live network calls to external weather/data servers (which + # make the full build slow and flaky). Read the Docs still executes the + # cells to render live outputs; this job only validates the docs structure + # (reStructuredText, cross-references, toctrees, autodoc API reference). + DOCS_SKIP_EXECUTE: "1" + steps: + - uses: actions/checkout@v4 + + - name: Install pandoc (required by nbsphinx) + run: sudo apt-get update && sudo apt-get install -y pandoc + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: | + docs/requirements.txt + requirements.txt + + - name: Install dependencies (mirrors .readthedocs.yaml) + run: | + python -m pip install --upgrade pip + pip install -r docs/requirements.txt + pip install -r requirements.txt + pip install .[all] + + - name: Cache Sphinx doctrees + uses: actions/cache@v4 + with: + path: docs/_build/doctrees + key: sphinx-doctrees-${{ github.ref }}-${{ hashFiles('docs/**', 'rocketpy/**') }} + restore-keys: | + sphinx-doctrees-${{ github.ref }}- + sphinx-doctrees- + + - name: Build docs (warnings-as-errors) + working-directory: docs + run: | + sphinx-build -b html -W --keep-going \ + -d _build/doctrees \ + -w _build/sphinx-warnings.txt \ + . _build/html + + - name: Surface Sphinx warnings as annotations + if: always() + run: | + if [ -s docs/_build/sphinx-warnings.txt ]; then + echo "::group::Sphinx warnings" + cat docs/_build/sphinx-warnings.txt + echo "::endgroup::" + while IFS= read -r line; do + echo "::warning::${line}" + done < docs/_build/sphinx-warnings.txt + else + echo "No Sphinx warnings 🎉" + fi + + - name: Upload built HTML + if: always() + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/_build/html + if-no-files-found: error diff --git a/.github/workflows/test-pytest-slow.yaml b/.github/workflows/test-pytest-slow.yaml index fb58f62de..fd66edc79 100644 --- a/.github/workflows/test-pytest-slow.yaml +++ b/.github/workflows/test-pytest-slow.yaml @@ -27,6 +27,8 @@ jobs: MPLBACKEND: Agg steps: - uses: actions/checkout@main + - name: Set up headless display + uses: pyvista/setup-headless-display-action@v4 - name: Set up Python uses: actions/setup-python@main with: diff --git a/.github/workflows/test_pytest.yaml b/.github/workflows/test_pytest.yaml index f2a13fb13..51c6febcf 100644 --- a/.github/workflows/test_pytest.yaml +++ b/.github/workflows/test_pytest.yaml @@ -26,6 +26,8 @@ jobs: MPLBACKEND: Agg steps: - uses: actions/checkout@main + - name: Set up headless display + uses: pyvista/setup-headless-display-action@v4 - name: Set up Python uses: actions/setup-python@main with: diff --git a/.pylintrc b/.pylintrc index b0aaf655c..02565f092 100644 --- a/.pylintrc +++ b/.pylintrc @@ -225,6 +225,11 @@ good-names=FlightPhases, center_of_mass_without_motor_to_CDM, motor_center_of_dry_mass_to_CDM, generic_motor_cesaroni_M1520, + R_phi, + R_delta, + R_pi, + R_uncanted, + R_body_to_fin, Re, # Reynolds number # Good variable names regexes, separated by a comma. If names match any regex, @@ -385,7 +390,7 @@ indent-string=' ' max-line-length=88 # Maximum number of lines in a module. -max-module-lines=3000 +max-module-lines=3050 # Allow the body of a class to be on the same line as the declaration if body # contains single statement. @@ -471,7 +476,6 @@ disable=raw-checker-failed, locally-disabled, file-ignored, suppressed-message, - useless-suppression, deprecated-pragma, # because we have some pending deprecations in the code. use-symbolic-message-instead, use-implicit-booleaness-not-comparison-to-string, @@ -505,6 +509,7 @@ disable=raw-checker-failed, # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable= + useless-suppression [METHOD_ARGS] diff --git a/CHANGELOG.md b/CHANGELOG.md index 4faa0db04..7ae48db43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,20 +32,88 @@ Attention: The newest changes should be on top --> ### Added -- +- ENH: ENH: Interactive 3D Flight Trajectory and Attitude Animation. [#1066](https://github.com/RocketPy-Team/RocketPy/pull/1066) +- ENH: ENH: Interactive 3D Flight Trajectory and Attitude Animation [#1066](https://github.com/RocketPy-Team/RocketPy/pull/1066) +- ENH: ENH: `Function` vectorized speed-up and refactor [#1049](https://github.com/RocketPy-Team/RocketPy/pull/1049) +- ENH: REV: Revert PR #958 [#1063](https://github.com/RocketPy-Team/RocketPy/pull/1063) +- ENH: MNT: remove duplicate and merge-sync entries from Unreleased changelog [#1062](https://github.com/RocketPy-Team/RocketPy/pull/1062) +### Changed + +- MNT: MNT: Remove Unused pylint Disable Statements. [#1067](https://github.com/RocketPy-Team/RocketPy/pull/1067) +- MNT: MNT: Remove Unused pylint Disable Statements. [#1067](https://github.com/RocketPy-Team/RocketPy/pull/1067) + +### Fixed + +## [v1.13.0] - 2026-07-04 + +### Added + +- ENH: ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055) +- ENH: TST: Add unit tests for evaluate_reduced_mass and remove TODO [#1051](https://github.com/RocketPy-Team/RocketPy/pull/1051) +- ENH: seed sensor measurement noise per instance [#1052](https://github.com/RocketPy-Team/RocketPy/pull/1052) +- ENH: FIX: pre release hardening [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047) +- ENH: DEV: Claude Code ruff hooks (auto-format on edit + pre-push lint guard) [#1046](https://github.com/RocketPy-Team/RocketPy/pull/1046) +- ENH: CI: create a CI for testing docs updates + solve different docs issues [#1045](https://github.com/RocketPy-Team/RocketPy/pull/1045) +- ENH: MNT: final fixes before next release [#1044](https://github.com/RocketPy-Team/RocketPy/pull/1044) +- ENH: Individual Fins: add `Fin`, `TrapezoidalFin`, `EllipticalFin`, and `FreeFormFin` classes for modeling asymmetric or individually-positioned fins [#818](https://github.com/RocketPy-Team/RocketPy/pull/818) +- ENH: Add AIGFS and HRRR forecast models to the Environment class [#951](https://github.com/RocketPy-Team/RocketPy/pull/951) +- ENH: Add RingClusterMotor for annular clustered motor modeling [#924](https://github.com/RocketPy-Team/RocketPy/pull/924) +- ENH: Discrete and Continuous Controllers [#946](https://github.com/RocketPy-Team/RocketPy/pull/946) +- ENH: Add custom exceptions and unstable rocket warning [#970](https://github.com/RocketPy-Team/RocketPy/pull/970) +- ENH: Adopt built-in Python logging instead of print() calls (closes [#450](https://github.com/RocketPy-Team/RocketPy/issues/450)) [#973](https://github.com/RocketPy-Team/RocketPy/pull/973) +- ENH: Pass acceleration (`u_dot`) data to parachute trigger functions [#911](https://github.com/RocketPy-Team/RocketPy/pull/911) +- ENH: Add 3D flight trajectory and attitude animations in Flight plots layer [#909](https://github.com/RocketPy-Team/RocketPy/pull/909) +- ENH: Monte Carlo Formatting Options [#947](https://github.com/RocketPy-Team/RocketPy/pull/947) +- ENH: Adaptive Monte Carlo via Convergence Criteria [#922](https://github.com/RocketPy-Team/RocketPy/pull/922) +- ENH: Auto-Detection of Pressure Conversion Factor [#966](https://github.com/RocketPy-Team/RocketPy/pull/966) +- ENH: Introduce pressure unit conversion when using forecast/reanalysis/ensemble data [#955](https://github.com/RocketPy-Team/RocketPy/pull/955) +- MNT: Refactor parachute implementation with abstract base and hemispherical model split [#958](https://github.com/RocketPy-Team/RocketPy/pull/958) +- DOC: Add aerodynamic surfaces user guide [#1043](https://github.com/RocketPy-Team/RocketPy/pull/1043) +- DOC: Add Valkyrie flight example (Bisky Team) [#967](https://github.com/RocketPy-Team/RocketPy/pull/967) +- DOC: Configure AI instructions and update developer docs [#975](https://github.com/RocketPy-Team/RocketPy/pull/975) +- ENH: Auto Populate Changelog [#919](https://github.com/RocketPy-Team/RocketPy/pull/919) +- MNT: Sync develop's Renovate config with master (#1039) [#1040](https://github.com/RocketPy-Team/RocketPy/pull/1040) +- MNT: Configure Renovate Bot targeting develop branch [#972](https://github.com/RocketPy-Team/RocketPy/pull/972) ### Changed -- +- REL: bumps up rocketpy version to 1.13.0 [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048) +- MNT: **Breaking**: `Parachute` is now an abstract base class and can no longer be instantiated directly; use `HemisphericalParachute` (or `Rocket.add_parachute`, which builds it for you). Loading old serialized files still works [#958](https://github.com/RocketPy-Team/RocketPy/pull/958) +- MNT: Discrete controllers are now called exactly once per time node (previously twice); results may change for stateful controllers and `observed_variables` no longer contains duplicated entries [#949](https://github.com/RocketPy-Team/RocketPy/pull/949) +- MNT: Multi-dimensional linear `Function` objects now apply their extrapolation rule to points outside the data's convex hull (previously returned NaN) [#969](https://github.com/RocketPy-Team/RocketPy/pull/969) +- MNT: Informational messages previously shown with `print()` now use Python logging and are silent by default; call `rocketpy.utils.enable_logging()` to see them [#973](https://github.com/RocketPy-Team/RocketPy/pull/973) +- ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055) + +### Deprecated + +- MNT: Rename `radius` to `radius_function` in `CylindricalTank` and `SphericalTank`; old `radius=` keyword argument now raises `DeprecationWarning` [#957](https://github.com/RocketPy-Team/RocketPy/pull/957) + +### Removed + +- MNT: Remove redundant, unused per-level `wind_heading`/`wind_direction` functions from `EnvironmentAnalysis` pressure-level data (derivable from `wind_velocity_x`/`wind_velocity_y`) [#1041](https://github.com/RocketPy-Team/RocketPy/pull/1041) + +### Fixed + +- BUG: Environment not Encoding Necessary Parameters for Decode [#1059](https://github.com/RocketPy-Team/RocketPy/pull/1059) +- BUG: fix individual fin (`TrapezoidalFin`, `EllipticalFin`, `FreeFormFin`) serialization crash when saving rockets/flights [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048) +- BUG: support the new Wyoming sounding WSGI page format (legacy cgi-bin endpoint was discontinued by UWyo) [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048) +- FIX: pre-release hardening — bug fixes across the unreleased features [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047) +- BUG: Remove duplicate controller process; controllers were being invoked twice per time node [#949](https://github.com/RocketPy-Team/RocketPy/pull/949) +- BUG: fix wind heading and direction wraparound interpolation [#974](https://github.com/RocketPy-Team/RocketPy/pull/974) +- BUG: fix NaN in ND linear interpolation outside convex hull (closes [#926](https://github.com/RocketPy-Team/RocketPy/issues/926)) [#969](https://github.com/RocketPy-Team/RocketPy/pull/969) +- BUG: Add wraparound logic for wind direction in environment plots [#939](https://github.com/RocketPy-Team/RocketPy/pull/939) + +## [v1.12.1] - 2026-04-03 ### Fixed -- +- HOTFIX: Migrate Forecasts to UCAR THREDDS [#943](https://github.com/RocketPy-Team/RocketPy/pull/943) ## [v1.12.0] - 2026-03-08 ### Added +- ENH: Air brakes controller functions now support 8-parameter signature [#854](https://github.com/RocketPy-Team/RocketPy/pull/854) - TST: Add acceptance tests for 3DOF flight simulation based on Bella Lui rocket [#914] (https://github.com/RocketPy-Team/RocketPy/pull/914_ - ENH: Add background map auto download functionality to Monte Carlo plots [#896](https://github.com/RocketPy-Team/RocketPy/pull/896) - MNT: net thrust addition to 3 dof in flight class [#907] (https://github.com/RocketPy-Team/RocketPy/pull/907) @@ -71,17 +139,16 @@ Attention: The newest changes should be on top --> ### Fixed -- BUG: Restore `Rocket.power_off_drag` and `Rocket.power_on_drag` as `Function` objects while preserving raw inputs in `power_off_drag_input` and `power_on_drag_input` [#941](https://github.com/RocketPy-Team/RocketPy/pull/941) -- BUG: Add explicit timeouts to ThrustCurve API requests [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) - BUG: Fix hard-coded radius value for parachute added mass calculation [#889](https://github.com/RocketPy-Team/RocketPy/pull/889) +- BUG: Fix incorrect Jacobian in `only_radial_burn` branch of `SolidMotor.evaluate_geometry` [#944](https://github.com/RocketPy-Team/RocketPy/pull/944) +- ENH: Restore `power_off_drag`/`power_on_drag` as `Function` objects and preserve raw user input in `_input` attributes [#941](https://github.com/RocketPy-Team/RocketPy/pull/941) +- ENH: Add explicit timeouts to ThrustCurve API requests [#940](https://github.com/RocketPy-Team/RocketPy/pull/940) - DOC: Fix documentation build [#908](https://github.com/RocketPy-Team/RocketPy/pull/908) - BUG: energy_data plot not working for 3 dof sims [[#906](https://github.com/RocketPy-Team/RocketPy/issues/906)] - BUG: Fix CSV column header spacing in FlightDataExporter [#864](https://github.com/RocketPy-Team/RocketPy/issues/864) - BUG: Fix parallel Monte Carlo simulation showing incorrect iteration count [#806](https://github.com/RocketPy-Team/RocketPy/pull/806) - BUG: Fix missing titles in roll parameter plots for fin sets [#934](https://github.com/RocketPy-Team/RocketPy/pull/934) - BUG: Duplicate _controllers in Flight.TimeNodes.merge() [#931](https://github.com/RocketPy-Team/RocketPy/pull/931) -- BUG: Fix incorrect Jacobian in `only_radial_burn` branch of `SolidMotor.evaluate_geometry` [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) -- BUG: Add explicit timeouts to ThrustCurve API requests [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) ## [v1.11.0] - 2025-11-01 diff --git a/data/motors/cesaroni/Cesaroni_1997K650-21A.eng b/data/motors/cesaroni/Cesaroni_1997K650-21A.eng new file mode 100644 index 000000000..e95c47090 --- /dev/null +++ b/data/motors/cesaroni/Cesaroni_1997K650-21A.eng @@ -0,0 +1,12 @@ +;Pink 54mm 5G +;1997-K650-PK-21 +1997-K650-PK-21 54 488 21-19-17-15-13-11 1.0651 1.71 CTI +0.011 1353.5 +0.028 793.832 +0.186 815.421 +2.578 601.186 +2.689 479.953 +2.855 189.324 +3.086 43.179 +3.451 0 +; \ No newline at end of file diff --git a/data/rockets/valkyrie/Dima.json b/data/rockets/valkyrie/Dima.json new file mode 100644 index 000000000..048ba3c74 --- /dev/null +++ b/data/rockets/valkyrie/Dima.json @@ -0,0 +1,38 @@ +{ + "launch": { + "rail_length": { + "value": 10, + "dispersion": 0.001 + }, + "ensemble_member": null + }, + "Environment": { + "latitude": { + "value": 43.077771 + }, + "longitude": { + "value": -2.684446 + }, + "elevation": { + "value": 592 + }, + "max_expected_height": { + "value": 3000 + }, + "timezone": "Europe/Madrid" + }, + "Montecarlo": { + "map_filename": "Dima.PNG", + "map_scale": 1, + "append": false, + "map_limits": [ + -20000, + 20000, + -20000, + 20000 + ] + }, + "Flight": { + "max_time": 3000 + } +} diff --git a/data/rockets/valkyrie/VLK.json b/data/rockets/valkyrie/VLK.json new file mode 100644 index 000000000..92318f469 --- /dev/null +++ b/data/rockets/valkyrie/VLK.json @@ -0,0 +1,76 @@ +{ + "VLK": { + "rocket": { + "rocket_mass": {"value": 5.65, "dispersion": 0.2}, + "rocket_center_of_mass_without_motor": {"value": 0.844, "dispersion": 0.01}, + "rocket_coordinate_system_orientation": "tail_to_nose", + "rocket_inertia_11": {"value": 1.806, "dispersion": 0.1}, + "rocket_inertia_33": {"value": 0.0183, "dispersion": 0.001}, + "rocket_radius": {"value": 0.055, "dispersion": 0.0001}, + "motor_position": {"value": 0, "dispersion": 0} + }, + "motor": { + "motor_coordinate_system_orientation": "nozzle_to_combustion_chamber", + "motor_thrust_source": "./data/motors/cesaroni/Cesaroni_1997K650-21A.eng", + "motor_dry_mass": {"value": 0.6449, "dispersion": 0.001}, + "motor_inertia_11": {"value": 0.0343, "dispersion": 0.0001}, + "motor_inertia_33": {"value": 0.0008, "dispersion": 0.0001}, + "motor_dry_mass_position": {"value": 0.246, "dispersion": 0.001}, + "motor_impulse": {"value": 2300, "dispersion": 20}, + "motor_burn_time": {"value": 3.5, "dispersion": 0.1}, + "nozzle_radius": {"value": 0.045, "dispersion": 0.0005}, + "nozzle_position": {"value": 0}, + "throat_radius": {"value": 0.023, "dispersion": 0.0005}, + "grain_number": {"value": 5}, + "grain_separation": {"value": 0.001, "dispersion": 0.0001}, + "grain_density": {"value": 1170, "dispersion": 10}, + "grain_outer_radius": {"value": 0.025, "dispersion": 0.001}, + "grain_initial_inner_radius": {"value": 0.01, "dispersion": 0.000375}, + "grain_initial_height": {"value": 0.0966, "dispersion": 0.001}, + "grains_center_of_mass_position": {"value": 0.246, "dispersion": 0.003}, + "power_off_drag": {"value": 0.35, "dispersion": 0.05}, + "power_on_drag": {"value": 0.35, "dispersion": 0.05} + }, + "nose": { + "nose_length": {"value": 0.20, "dispersion": 0.01}, + "nose_kind": "elliptical", + "nose_power": {"value": null, "dispersion": null}, + "nose_position": {"value": 1.84, "dispersion": 0} + }, + "fins": { + "fin_span": {"value": 0.0768, "dispersion": 0.001}, + "fin_root_chord": {"value": 0.17, "dispersion": 0.005}, + "fin_tip_chord": {"value": 0.14, "dispersion": 0.005}, + "fin_sweep_length": {"value": 0.10, "dispersion": 0.003}, + "fin_airfoil": "None", + "fin_position": {"value": 0.148, "dispersion": 0.001}, + "fin_cant_angle": {"value": 0, "dispersion": 0}, + "fin_n": {"value": 4, "dispersion": 0} + }, + "tail": { + "top_radius": {"value": 0.055, "dispersion": 0.001}, + "bottom_radius": {"value": 0.03, "dispersion": 0.005}, + "length": {"value": 0.15, "dispersion": 0.005}, + "position": {"value": 0, "dispersion": 0} + }, + "main_chute": { + "cd_s_main": {"value": 5.786483801, "dispersion": 0.07}, + "main_lag": {"value": 1.73, "dispersion": 0.4}, + "main_sampling_rate": {"value": 105}, + "Funcion de Deploy": {"value": 0}, + "main_noise": [0, 8.3, 0.5] + }, + "drogue_chute": { + "cd_s_drogue": {"value": 0.258940616, "dispersion": 0.07}, + "drogue_lag": {"value": 1.73, "dispersion": 0.4}, + "drogue_sampling_rate": {"value": 105}, + "Funcion de Deploy": {"value": 0}, + "drogue_noise": [0, 8.3, 0.5] + }, + "rail_buttons": { + "upper_button_position": {"value": 1.13, "dispersion": 0}, + "lower_button_position": {"value": 0.383, "dispersion": 0}, + "rail_angular_position": {"value": 0, "dispersion": 0} + } + } +} \ No newline at end of file diff --git a/data/rockets/valkyrie/flightInfo_merged.csv b/data/rockets/valkyrie/flightInfo_merged.csv new file mode 100644 index 000000000..521103f73 --- /dev/null +++ b/data/rockets/valkyrie/flightInfo_merged.csv @@ -0,0 +1,12579 @@ +time,altitude +0,0.34424737095832825 +0.01,0.4096522331237793 +0.02,0.4844495952129364 +0.03,0.5686082243919373 +0.04,0.6620555520057678 +0.05,0.7647705674171448 +0.06,0.8767398595809937 +0.07,0.9979233145713806 +0.08,1.1282371282577515 +0.09,1.2677185535430908 +0.1,1.4164103269577026 +0.11,1.5742592811584473 +0.12,1.741287112236023 +0.13,1.9174764156341553 +0.14,2.1028378009796143 +0.15,2.297421455383301 +0.16,2.501166582107544 +0.17,2.714061737060547 +0.18,2.9361159801483154 +0.19,3.167316436767578 +0.2,3.4076409339904785 +0.21,3.6570794582366943 +0.22,3.9157540798187256 +0.23,4.183582305908203 +0.24,4.460573673248291 +0.25,4.746795177459717 +0.26,5.042205810546875 +0.27,5.346819877624512 +0.28,5.660666465759277 +0.29,5.983789920806885 +0.3,6.316145896911621 +0.31,6.657732963562012 +0.32,7.008546352386475 +0.33,7.368549346923828 +0.34,7.737792015075684 +0.35,8.116194725036621 +0.36,8.503837585449219 +0.37,8.900636672973633 +0.38,9.306602478027344 +0.39,9.7217378616333 +0.4,10.146217346191406 +0.41,10.57990837097168 +0.42,11.022735595703125 +0.43,11.474719047546387 +0.44,11.93583869934082 +0.45,12.406149864196777 +0.46,12.885601043701172 +0.47,13.374221801757812 +0.48,13.872072219848633 +0.49,14.379134178161621 +0.5,14.895292282104492 +0.51,15.420513153076172 +0.52,15.954848289489746 +0.53,16.49825096130371 +0.54,17.050731658935547 +0.55,17.61224365234375 +0.56,18.182798385620117 +0.57,18.762418746948242 +0.58,19.35106658935547 +0.59,19.948747634887695 +0.6,20.5555419921875 +0.61,21.17142677307129 +0.62,21.79645538330078 +0.63,22.430660247802734 +0.64,23.073835372924805 +0.65,23.72611427307129 +0.66,24.38735008239746 +0.67,25.057621002197266 +0.68,25.73690414428711 +0.69,26.425580978393555 +0.7,27.123353958129883 +0.71,27.830101013183594 +0.72,28.545774459838867 +0.73,29.27034568786621 +0.74,30.003990173339844 +0.75,30.74651336669922 +0.76,31.497953414916992 +0.77,32.25880432128906 +0.78,33.028717041015625 +0.79,33.80760955810547 +0.8,34.595558166503906 +0.81,35.392478942871094 +0.82,36.1983757019043 +0.83,37.01343536376953 +0.84,37.837337493896484 +0.85,38.670448303222656 +0.86,39.512413024902344 +0.87,40.36339569091797 +0.88,41.22346496582031 +0.89,42.09280014038086 +0.9,42.9708366394043 +0.91,43.857662200927734 +0.92,44.75386047363281 +0.93,45.65866470336914 +0.94,46.57217025756836 +0.95,47.495059967041016 +0.96,48.426639556884766 +0.97,49.36689376831055 +0.98,50.31669235229492 +0.99,51.27504348754883 +1,52.24203109741211 +1.01,53.218299865722656 +1.02,54.20310974121094 +1.03,55.197166442871094 +1.04,56.199951171875 +1.05,57.211910247802734 +1.06,58.23289489746094 +1.07,59.262271881103516 +1.08,60.300052642822266 +1.09,61.3477668762207 +1.1,62.40391159057617 +1.11,63.469051361083984 +1.12,64.54252624511719 +1.13,65.6246109008789 +1.14,66.71533966064453 +1.15,67.81461334228516 +1.16,68.92334747314453 +1.17,70.04035949707031 +1.18,71.1666259765625 +1.19,72.30133056640625 +1.2,73.44469451904297 +1.21,74.59632110595703 +1.22,75.75676727294922 +1.23,76.92515563964844 +1.24,78.1018295288086 +1.25,79.28691101074219 +1.26,80.48171997070312 +1.27,81.68443298339844 +1.28,82.89510345458984 +1.29,84.1144027709961 +1.3,85.34256744384766 +1.31,86.57843780517578 +1.32,87.82215881347656 +1.33,89.0754165649414 +1.34,90.33643341064453 +1.35,91.60639953613281 +1.36,92.8850326538086 +1.37,94.17122650146484 +1.38,95.46638488769531 +1.39,96.76910400390625 +1.4,98.0815658569336 +1.41,99.401611328125 +1.42,100.72897338867188 +1.43,102.06503295898438 +1.44,103.41033172607422 +1.45,104.76349639892578 +1.46,106.12544250488281 +1.47,107.49502563476562 +1.48,108.8729248046875 +1.49,110.25946044921875 +1.5,111.65361022949219 +1.51,113.05644989013672 +1.52,114.46788024902344 +1.53,115.8870849609375 +1.54,117.31358337402344 +1.55,118.74869537353516 +1.56,120.19194793701172 +1.57,121.6441650390625 +1.58,123.10456848144531 +1.59,124.57186889648438 +1.6,126.04759216308594 +1.61,127.5300064086914 +1.62,129.02284240722656 +1.63,130.52230834960938 +1.64,132.02969360351562 +1.65,133.54713439941406 +1.66,135.07106018066406 +1.67,136.6019287109375 +1.68,138.13922119140625 +1.69,139.68788146972656 +1.7,141.24359130859375 +1.71,142.80760192871094 +1.72,144.37892150878906 +1.73,145.95672607421875 +1.74,147.5425262451172 +1.75,149.13851928710938 +1.76,150.74009704589844 +1.77,152.3497314453125 +1.78,153.9647674560547 +1.79,155.58999633789062 +1.8,157.22280883789062 +1.81,158.86410522460938 +1.82,160.510986328125 +1.83,162.16403198242188 +1.84,163.8265380859375 +1.85,165.49752807617188 +1.86,167.1735076904297 +1.87,168.85845947265625 +1.88,170.55165100097656 +1.89,172.2493133544922 +1.9,173.9539337158203 +1.91,175.66673278808594 +1.92,177.38502502441406 +1.93,179.10891723632812 +1.94,180.84117126464844 +1.95,182.5819549560547 +1.96,184.32925415039062 +1.97,186.08436584472656 +1.98,187.85101318359375 +1.99,189.6222381591797 +2,191.400390625 +2.01,193.1836700439453 +2.02,194.978271484375 +2.03,196.77809143066406 +2.04,198.58248901367188 +2.05,200.3924560546875 +2.06,202.21206665039062 +2.07,204.03465270996094 +2.08,205.8641357421875 +2.09,207.70533752441406 +2.1,209.54965209960938 +2.11,211.39996337890625 +2.12,213.25881958007812 +2.13,215.12112426757812 +2.14,216.9900360107422 +2.15,218.86557006835938 +2.16,220.74851989746094 +2.17,222.6356658935547 +2.18,224.5264892578125 +2.19,226.42919921875 +2.2,228.3399200439453 +2.21,230.25482177734375 +2.22,232.17971801757812 +2.23,234.1068572998047 +2.24,236.04208374023438 +2.25,237.9814453125 +2.26,239.9283905029297 +2.27,241.88409423828125 +2.28,243.84158325195312 +2.29,245.8058319091797 +2.3,247.78099060058594 +2.31,249.7571258544922 +2.32,251.7446746826172 +2.33,253.7340087890625 +2.34,255.7299041748047 +2.35,257.7336120605469 +2.36,259.7427062988281 +2.37,261.75714111328125 +2.38,263.77862548828125 +2.39,265.8052978515625 +2.4,267.8353271484375 +2.41,269.8711242675781 +2.42,271.91412353515625 +2.43,273.9585266113281 +2.44,276.0101623535156 +2.45,278.0620422363281 +2.46,280.11932373046875 +2.47,282.1824951171875 +2.48,284.24554443359375 +2.49,286.3124084472656 +2.5,288.38299560546875 +2.51,290.4571228027344 +2.52,292.5429382324219 +2.53,294.6259460449219 +2.54,296.7168273925781 +2.55,298.8036193847656 +2.56,300.8930969238281 +2.57,302.9897155761719 +2.58,305.08245849609375 +2.59,307.1776428222656 +2.6,309.2787780761719 +2.61,311.3831787109375 +2.62,313.4951171875 +2.63,315.60382080078125 +2.64,317.7184753417969 +2.65,319.83489990234375 +2.66,321.9522399902344 +2.67,324.06781005859375 +2.68,326.185302734375 +2.69,328.305908203125 +2.7,330.427734375 +2.71,332.5437316894531 +2.72,334.6688232421875 +2.73,336.79718017578125 +2.74,338.9176025390625 +2.75,341.04644775390625 +2.76,343.1728515625 +2.77,345.29583740234375 +2.78,347.4095153808594 +2.79,349.5279235839844 +2.8,351.6434326171875 +2.81,353.7611999511719 +2.82,355.8721923828125 +2.83,357.9757995605469 +2.84,360.076904296875 +2.85,362.1820983886719 +2.86,364.2793884277344 +2.87,366.37017822265625 +2.88,368.4497985839844 +2.89,370.5351867675781 +2.9,372.6181640625 +2.91,374.6919860839844 +2.92,376.7639465332031 +2.93,378.82489013671875 +2.94,380.8809814453125 +2.95,382.93902587890625 +2.96,384.9892883300781 +2.97,387.0325622558594 +2.98,389.0752868652344 +2.99,391.1109924316406 +3,393.14361572265625 +3.01,395.1832580566406 +3.02,397.2127990722656 +3.03,399.25128173828125 +3.04,401.27740478515625 +3.05,403.30682373046875 +3.06,405.3287658691406 +3.07,407.352294921875 +3.08,409.37884521484375 +3.09,411.40667724609375 +3.1,413.4261169433594 +3.11,415.44366455078125 +3.12,417.45318603515625 +3.13,419.4560852050781 +3.14,421.46942138671875 +3.15,423.4714660644531 +3.16,425.483642578125 +3.17,427.4830017089844 +3.18,429.4854736328125 +3.19,431.4920654296875 +3.2,433.4911193847656 +3.21,435.4836120605469 +3.22,437.4779052734375 +3.23,439.4691162109375 +3.24,441.4677734375 +3.25,443.45587158203125 +3.26,445.4500427246094 +3.27,447.4422302246094 +3.28,449.42791748046875 +3.29,451.4157409667969 +3.3,453.39398193359375 +3.31,455.3801574707031 +3.32,457.35357666015625 +3.33,459.33294677734375 +3.34,461.3126525878906 +3.35,463.29217529296875 +3.36,465.2694396972656 +3.37,467.2418212890625 +3.38,469.2055969238281 +3.39,471.1628723144531 +3.4,473.13995361328125 +3.41,475.110595703125 +3.42,477.0699157714844 +3.43,479.0368957519531 +3.44,481.00341796875 +3.45,482.9580383300781 +3.46,484.9210510253906 +3.47,486.86993408203125 +3.48,488.827880859375 +3.49,490.7804870605469 +3.5,492.72442626953125 +3.51,494.6754150390625 +3.52,496.61883544921875 +3.53,498.5569763183594 +3.54,500.4932556152344 +3.55,502.436767578125 +3.56,504.3796081542969 +3.57,506.32232666015625 +3.58,508.254150390625 +3.59,510.1968994140625 +3.6,512.1262817382812 +3.61,514.0516967773438 +3.62,515.9713134765625 +3.63,517.8892211914062 +3.64,519.819580078125 +3.65,521.7365112304688 +3.66,523.6572265625 +3.67,525.5762939453125 +3.68,527.4837646484375 +3.69,529.4093017578125 +3.7,531.3226318359375 +3.71,533.2386474609375 +3.72,535.1658325195312 +3.73,537.081298828125 +3.74,539.0025634765625 +3.75,540.9182739257812 +3.76,542.8209838867188 +3.77,544.7211303710938 +3.78,546.6217041015625 +3.79,548.514892578125 +3.8,550.4153442382812 +3.81,552.3145751953125 +3.82,554.2217407226562 +3.83,556.1307983398438 +3.84,558.0324096679688 +3.85,559.9353637695312 +3.86,561.833984375 +3.87,563.7233276367188 +3.88,565.6027221679688 +3.89,567.4999389648438 +3.9,569.3853149414062 +3.91,571.2841796875 +3.92,573.168701171875 +3.93,575.0521850585938 +3.94,576.9283447265625 +3.95,578.8124389648438 +3.96,580.6815185546875 +3.97,582.5548095703125 +3.98,584.4413452148438 +3.99,586.3154296875 +4,588.1873168945312 +4.01,590.0615844726562 +4.02,591.9259643554688 +4.03,593.8135375976562 +4.04,595.69384765625 +4.05,597.5635986328125 +4.06,599.4391479492188 +4.07,601.303466796875 +4.08,603.1814575195312 +4.09,605.047607421875 +4.1,606.9124145507812 +4.11,608.77001953125 +4.12,610.626953125 +4.13,612.4813232421875 +4.14,614.3324584960938 +4.15,616.195556640625 +4.16,618.0567626953125 +4.17,619.9188232421875 +4.18,621.7676391601562 +4.19,623.634033203125 +4.2,625.5045776367188 +4.21,627.3645629882812 +4.22,629.2254638671875 +4.23,631.0724487304688 +4.24,632.92578125 +4.25,634.7720336914062 +4.26,636.6226196289062 +4.27,638.4695434570312 +4.28,640.3147583007812 +4.29,642.15087890625 +4.3,644.0023193359375 +4.31,645.84326171875 +4.32,647.6770629882812 +4.33,649.5215454101562 +4.34,651.361083984375 +4.35,653.1873779296875 +4.36,655.0271606445312 +4.37,656.8729858398438 +4.38,658.7062377929688 +4.39,660.547607421875 +4.4,662.3847045898438 +4.41,664.2189331054688 +4.42,666.0565185546875 +4.43,667.88916015625 +4.44,669.7335205078125 +4.45,671.5653076171875 +4.46,673.4005126953125 +4.47,675.2315673828125 +4.48,677.0606079101562 +4.49,678.8972778320312 +4.5,680.726318359375 +4.51,682.5421752929688 +4.52,684.348388671875 +4.53,686.1693725585938 +4.54,687.9925537109375 +4.55,689.8040161132812 +4.56,691.6107177734375 +4.57,693.4351196289062 +4.58,695.262451171875 +4.59,697.0745849609375 +4.6,698.8945922851562 +4.61,700.7049560546875 +4.62,702.5114135742188 +4.63,704.3089599609375 +4.64,706.1166381835938 +4.65,707.9317016601562 +4.66,709.7512817382812 +4.67,711.5647583007812 +4.68,713.3665161132812 +4.69,715.16650390625 +4.7,716.9789428710938 +4.71,718.7868041992188 +4.72,720.5816040039062 +4.73,722.3845825195312 +4.74,724.1859130859375 +4.75,725.990478515625 +4.76,727.78271484375 +4.77,729.58251953125 +4.78,731.374267578125 +4.79,733.1536865234375 +4.8,734.9293823242188 +4.81,736.7349243164062 +4.82,738.5296020507812 +4.83,740.3112182617188 +4.84,742.0934448242188 +4.85,743.8727416992188 +4.86,745.6483154296875 +4.87,747.4266967773438 +4.88,749.2078857421875 +4.89,750.9840087890625 +4.9,752.76513671875 +4.91,754.5376586914062 +4.92,756.30224609375 +4.93,758.0697631835938 +4.94,759.847412109375 +4.95,761.6157836914062 +4.96,763.3741455078125 +4.97,765.1376342773438 +4.98,766.9041137695312 +4.99,768.6707153320312 +5,770.4237060546875 +5.01,772.171875 +5.02,773.923095703125 +5.03,775.7012329101562 +5.04,777.4722290039062 +5.05,779.2384033203125 +5.06,781.013427734375 +5.07,782.7770385742188 +5.08,784.531494140625 +5.09,786.29345703125 +5.1,788.053466796875 +5.11,789.81298828125 +5.12,791.5584106445312 +5.13,793.3106079101562 +5.14,795.0660400390625 +5.15,796.8102416992188 +5.16,798.56494140625 +5.17,800.30908203125 +5.18,802.0543823242188 +5.19,803.7920532226562 +5.2,805.5366821289062 +5.21,807.2686157226562 +5.22,808.9996337890625 +5.23,810.7304077148438 +5.24,812.4667358398438 +5.25,814.1890258789062 +5.26,815.9235229492188 +5.27,817.6533813476562 +5.28,819.3838500976562 +5.29,821.1097412109375 +5.3,822.8311157226562 +5.31,824.5640258789062 +5.32,826.3026123046875 +5.33,828.0381469726562 +5.34,829.7648315429688 +5.35,831.4899291992188 +5.36,833.2156982421875 +5.37,834.9500732421875 +5.38,836.6836547851562 +5.39,838.4149780273438 +5.4,840.13818359375 +5.41,841.8540649414062 +5.42,843.5612182617188 +5.43,845.2866821289062 +5.44,847.0128784179688 +5.45,848.7295532226562 +5.46,850.44189453125 +5.47,852.14697265625 +5.48,853.8660888671875 +5.49,855.5786743164062 +5.5,857.2883911132812 +5.51,858.9916381835938 +5.52,860.681884765625 +5.53,862.3767700195312 +5.54,864.0769653320312 +5.55,865.7692260742188 +5.56,867.473388671875 +5.57,869.1734008789062 +5.58,870.8699340820312 +5.59,872.5593872070312 +5.6,874.2586059570312 +5.61,875.9463500976562 +5.62,877.6263427734375 +5.63,879.3021850585938 +5.64,880.9893798828125 +5.65,882.6666870117188 +5.66,884.3413696289062 +5.67,886.0193481445312 +5.68,887.6896362304688 +5.69,889.375732421875 +5.7,891.0665893554688 +5.71,892.7511596679688 +5.72,894.4295654296875 +5.73,896.1039428710938 +5.74,897.7831420898438 +5.75,899.4591064453125 +5.76,901.129638671875 +5.77,902.8064575195312 +5.78,904.4823608398438 +5.79,906.1476440429688 +5.8,907.8046264648438 +5.81,909.46142578125 +5.82,911.1342163085938 +5.83,912.8067626953125 +5.84,914.4703369140625 +5.85,916.1359252929688 +5.86,917.791015625 +5.87,919.4467163085938 +5.88,921.0919799804688 +5.89,922.7555541992188 +5.9,924.4124145507812 +5.91,926.0603637695312 +5.92,927.711181640625 +5.93,929.3707885742188 +5.94,931.0272827148438 +5.95,932.6719970703125 +5.96,934.3189086914062 +5.97,935.9591674804688 +5.98,937.5950317382812 +5.99,939.2478637695312 +6,940.887451171875 +6.01,942.5204467773438 +6.02,944.166748046875 +6.03,945.8027954101562 +6.04,947.4308471679688 +6.05,949.067138671875 +6.06,950.6939086914062 +6.07,952.3274536132812 +6.08,953.9515380859375 +6.09,955.5839233398438 +6.1,957.2105102539062 +6.11,958.8276977539062 +6.12,960.4583740234375 +6.13,962.0781860351562 +6.14,963.69677734375 +6.15,965.3148803710938 +6.16,966.9501342773438 +6.17,968.5767211914062 +6.18,970.1983642578125 +6.19,971.8077392578125 +6.2,973.4078979492188 +6.21,975.0296630859375 +6.22,976.6421508789062 +6.23,978.2542724609375 +6.24,979.8792114257812 +6.25,981.497802734375 +6.26,983.109375 +6.27,984.7168579101562 +6.28,986.3203735351562 +6.29,987.9213256835938 +6.3,989.5189819335938 +6.31,991.1141967773438 +6.32,992.7076416015625 +6.33,994.3088989257812 +6.34,995.9069213867188 +6.35,997.497314453125 +6.36,999.0867309570312 +6.37,1000.6825561523438 +6.38,1002.2693481445312 +6.39,1003.8610229492188 +6.4,1005.4429931640625 +6.41,1007.0174560546875 +6.42,1008.580810546875 +6.43,1010.1654052734375 +6.44,1011.7373657226562 +6.45,1013.3055419921875 +6.46,1014.8817138671875 +6.47,1016.4541625976562 +6.48,1018.0316772460938 +6.49,1019.607666015625 +6.5,1021.1806030273438 +6.51,1022.7645263671875 +6.52,1024.3409423828125 +6.53,1025.9180908203125 +6.54,1027.491455078125 +6.55,1029.071533203125 +6.56,1030.6396484375 +6.57,1032.2064208984375 +6.58,1033.7679443359375 +6.59,1035.3319091796875 +6.6,1036.8929443359375 +6.61,1038.449462890625 +6.62,1040.023193359375 +6.63,1041.58740234375 +6.64,1043.15087890625 +6.65,1044.7093505859375 +6.66,1046.2613525390625 +6.67,1047.81494140625 +6.68,1049.3583984375 +6.69,1050.9013671875 +6.7,1052.4437255859375 +6.71,1053.98486328125 +6.72,1055.5159912109375 +6.73,1057.0723876953125 +6.74,1058.620849609375 +6.75,1060.159912109375 +6.76,1061.6949462890625 +6.77,1063.2265625 +6.78,1064.7613525390625 +6.79,1066.2833251953125 +6.8,1067.8056640625 +6.81,1069.335693359375 +6.82,1070.863037109375 +6.83,1072.39306640625 +6.84,1073.9189453125 +6.85,1075.440185546875 +6.86,1076.9566650390625 +6.87,1078.4808349609375 +6.88,1080.0135498046875 +6.89,1081.54150390625 +6.9,1083.060302734375 +6.91,1084.580322265625 +6.92,1086.0904541015625 +6.93,1087.6121826171875 +6.94,1089.127685546875 +6.95,1090.6319580078125 +6.96,1092.1331787109375 +6.97,1093.6356201171875 +6.98,1095.143798828125 +6.99,1096.6444091796875 +7,1098.137451171875 +7.01,1099.635009765625 +7.02,1101.13671875 +7.03,1102.6419677734375 +7.04,1104.1368408203125 +7.05,1105.627197265625 +7.06,1107.1256103515625 +7.07,1108.6158447265625 +7.08,1110.1060791015625 +7.09,1111.592529296875 +7.1,1113.0694580078125 +7.11,1114.54052734375 +7.12,1116.0234375 +7.13,1117.520263671875 +7.14,1119.010498046875 +7.15,1120.4912109375 +7.16,1121.9661865234375 +7.17,1123.4449462890625 +7.18,1124.9237060546875 +7.19,1126.39892578125 +7.2,1127.874267578125 +7.21,1129.3416748046875 +7.22,1130.8046875 +7.23,1132.26416015625 +7.24,1133.7392578125 +7.25,1135.2034912109375 +7.26,1136.67236328125 +7.27,1138.131103515625 +7.28,1139.59814453125 +7.29,1141.0595703125 +7.3,1142.5225830078125 +7.31,1143.9984130859375 +7.32,1145.468505859375 +7.33,1146.93359375 +7.34,1148.388671875 +7.35,1149.84765625 +7.36,1151.3114013671875 +7.37,1152.76806640625 +7.38,1154.2176513671875 +7.39,1155.66455078125 +7.4,1157.1112060546875 +7.41,1158.554443359375 +7.42,1160.0032958984375 +7.43,1161.443603515625 +7.44,1162.877685546875 +7.45,1164.323974609375 +7.46,1165.7684326171875 +7.47,1167.2015380859375 +7.48,1168.6240234375 +7.49,1170.05810546875 +7.5,1171.4912109375 +7.51,1172.92041015625 +7.52,1174.344970703125 +7.53,1175.7774658203125 +7.54,1177.2000732421875 +7.55,1178.614501953125 +7.56,1180.041259765625 +7.57,1181.458984375 +7.58,1182.8824462890625 +7.59,1184.297607421875 +7.6,1185.7060546875 +7.61,1187.1209716796875 +7.62,1188.5291748046875 +7.63,1189.94091796875 +7.64,1191.3460693359375 +7.65,1192.770263671875 +7.66,1194.1861572265625 +7.67,1195.59912109375 +7.68,1197.0076904296875 +7.69,1198.4168701171875 +7.7,1199.8253173828125 +7.71,1201.22412109375 +7.72,1202.640625 +7.73,1204.0460205078125 +7.74,1205.442626953125 +7.75,1206.8538818359375 +7.76,1208.2548828125 +7.77,1209.6441650390625 +7.78,1211.0247802734375 +7.79,1212.4091796875 +7.8,1213.8046875 +7.81,1215.1915283203125 +7.82,1216.588134765625 +7.83,1217.985595703125 +7.84,1219.380859375 +7.85,1220.7689208984375 +7.86,1222.1541748046875 +7.87,1223.537353515625 +7.88,1224.9141845703125 +7.89,1226.2867431640625 +7.9,1227.6558837890625 +7.91,1229.0274658203125 +7.92,1230.395751953125 +7.93,1231.75537109375 +7.94,1233.1204833984375 +7.95,1234.4881591796875 +7.96,1235.8568115234375 +7.97,1237.2191162109375 +7.98,1238.57373046875 +7.99,1239.9176025390625 +8,1241.272216796875 +8.01,1242.6324462890625 +8.02,1243.99365234375 +8.03,1245.3568115234375 +8.04,1246.7276611328125 +8.05,1248.087158203125 +8.06,1249.43603515625 +8.07,1250.7874755859375 +8.08,1252.14306640625 +8.09,1253.4998779296875 +8.1,1254.8564453125 +8.11,1256.206787109375 +8.12,1257.5618896484375 +8.13,1258.90576171875 +8.14,1260.2523193359375 +8.15,1261.595703125 +8.16,1262.94482421875 +8.17,1264.2952880859375 +8.18,1265.64990234375 +8.19,1266.993896484375 +8.2,1268.3289794921875 +8.21,1269.6563720703125 +8.22,1270.9932861328125 +8.23,1272.3353271484375 +8.24,1273.6668701171875 +8.25,1274.995361328125 +8.26,1276.325927734375 +8.27,1277.66455078125 +8.28,1278.997802734375 +8.29,1280.33544921875 +8.3,1281.6656494140625 +8.31,1282.9862060546875 +8.32,1284.2994384765625 +8.33,1285.6031494140625 +8.34,1286.9178466796875 +8.35,1288.2333984375 +8.36,1289.5400390625 +8.37,1290.8482666015625 +8.38,1292.14990234375 +8.39,1293.453125 +8.4,1294.755615234375 +8.41,1296.0538330078125 +8.42,1297.3492431640625 +8.43,1298.6573486328125 +8.44,1299.9560546875 +8.45,1301.2510986328125 +8.46,1302.550048828125 +8.47,1303.8536376953125 +8.48,1305.156494140625 +8.49,1306.4559326171875 +8.5,1307.7518310546875 +8.51,1309.05908203125 +8.52,1310.3582763671875 +8.53,1311.6517333984375 +8.54,1312.9462890625 +8.55,1314.2432861328125 +8.56,1315.5316162109375 +8.57,1316.8135986328125 +8.58,1318.0972900390625 +8.59,1319.3812255859375 +8.6,1320.6640625 +8.61,1321.952392578125 +8.62,1323.23583984375 +8.63,1324.5277099609375 +8.64,1325.8170166015625 +8.65,1327.09619140625 +8.66,1328.37646484375 +8.67,1329.6600341796875 +8.68,1330.9373779296875 +8.69,1332.2039794921875 +8.7,1333.4666748046875 +8.71,1334.72021484375 +8.72,1335.9771728515625 +8.73,1337.2294921875 +8.74,1338.502197265625 +8.75,1339.7686767578125 +8.76,1341.036376953125 +8.77,1342.3045654296875 +8.78,1343.5657958984375 +8.79,1344.8201904296875 +8.8,1346.0751953125 +8.81,1347.3447265625 +8.82,1348.6065673828125 +8.83,1349.8668212890625 +8.84,1351.116455078125 +8.85,1352.3592529296875 +8.86,1353.5946044921875 +8.87,1354.8402099609375 +8.88,1356.08349609375 +8.89,1357.32080078125 +8.9,1358.555908203125 +8.91,1359.7952880859375 +8.92,1361.0323486328125 +8.93,1362.2723388671875 +8.94,1363.5159912109375 +8.95,1364.7611083984375 +8.96,1366.00390625 +8.97,1367.244384765625 +8.98,1368.4759521484375 +8.99,1369.7164306640625 +9,1370.9598388671875 +9.01,1372.203369140625 +9.02,1373.44091796875 +9.03,1374.6688232421875 +9.04,1375.9041748046875 +9.05,1377.129150390625 +9.06,1378.357177734375 +9.07,1379.5830078125 +9.08,1380.80078125 +9.09,1382.0164794921875 +9.1,1383.2249755859375 +9.11,1384.42919921875 +9.12,1385.6328125 +9.13,1386.836669921875 +9.14,1388.0450439453125 +9.15,1389.249267578125 +9.16,1390.449951171875 +9.17,1391.6561279296875 +9.18,1392.8603515625 +9.19,1394.056640625 +9.2,1395.259033203125 +9.21,1396.4698486328125 +9.22,1397.6756591796875 +9.23,1398.87353515625 +9.24,1400.0672607421875 +9.25,1401.2769775390625 +9.26,1402.4818115234375 +9.27,1403.679443359375 +9.28,1404.8743896484375 +9.29,1406.0631103515625 +9.3,1407.2432861328125 +9.31,1408.4171142578125 +9.32,1409.6114501953125 +9.33,1410.8016357421875 +9.34,1411.9847412109375 +9.35,1413.177978515625 +9.36,1414.370849609375 +9.37,1415.5543212890625 +9.38,1416.7322998046875 +9.39,1417.91748046875 +9.4,1419.0941162109375 +9.41,1420.2705078125 +9.42,1421.43994140625 +9.43,1422.6070556640625 +9.44,1423.7850341796875 +9.45,1424.958251953125 +9.46,1426.13427734375 +9.47,1427.310791015625 +9.48,1428.4923095703125 +9.49,1429.6654052734375 +9.5,1430.8367919921875 +9.51,1431.9990234375 +9.52,1433.1529541015625 +9.53,1434.3179931640625 +9.54,1435.4769287109375 +9.55,1436.650634765625 +9.56,1437.8145751953125 +9.57,1438.97607421875 +9.58,1440.1292724609375 +9.59,1441.289794921875 +9.6,1442.449462890625 +9.61,1443.6082763671875 +9.62,1444.763427734375 +9.63,1445.91552734375 +9.64,1447.0772705078125 +9.65,1448.2337646484375 +9.66,1449.3834228515625 +9.67,1450.5234375 +9.68,1451.670166015625 +9.69,1452.8131103515625 +9.7,1453.968017578125 +9.71,1455.114013671875 +9.72,1456.2503662109375 +9.73,1457.3897705078125 +9.74,1458.5255126953125 +9.75,1459.659912109375 +9.76,1460.793701171875 +9.77,1461.9246826171875 +9.78,1463.0579833984375 +9.79,1464.1839599609375 +9.8,1465.3115234375 +9.81,1466.43994140625 +9.82,1467.570068359375 +9.83,1468.697998046875 +9.84,1469.81640625 +9.85,1470.932861328125 +9.86,1472.0443115234375 +9.87,1473.1590576171875 +9.88,1474.270263671875 +9.89,1475.391357421875 +9.9,1476.5037841796875 +9.91,1477.61572265625 +9.92,1478.72119140625 +9.93,1479.8306884765625 +9.94,1480.946533203125 +9.95,1482.0582275390625 +9.96,1483.1732177734375 +9.97,1484.2802734375 +9.98,1485.378662109375 +9.99,1486.4781494140625 +10,1487.5772705078125 +10.01,1488.673095703125 +10.02,1489.7596435546875 +10.03,1490.870361328125 +10.04,1491.9822998046875 +10.05,1493.0855712890625 +10.06,1494.18408203125 +10.07,1495.282958984375 +10.08,1496.3807373046875 +10.09,1497.4774169921875 +10.1,1498.57763671875 +10.11,1499.6751708984375 +10.12,1500.76953125 +10.13,1501.8583984375 +10.14,1502.9454345703125 +10.15,1504.0360107421875 +10.16,1505.118896484375 +10.17,1506.2149658203125 +10.18,1507.3033447265625 +10.19,1508.38623046875 +10.2,1509.460693359375 +10.21,1510.541748046875 +10.22,1511.61376953125 +10.23,1512.6907958984375 +10.24,1513.76025390625 +10.25,1514.83544921875 +10.26,1515.90380859375 +10.27,1516.9810791015625 +10.28,1518.05224609375 +10.29,1519.1187744140625 +10.3,1520.192626953125 +10.31,1521.2657470703125 +10.32,1522.3326416015625 +10.33,1523.3973388671875 +10.34,1524.4537353515625 +10.35,1525.5115966796875 +10.36,1526.5709228515625 +10.37,1527.6309814453125 +10.38,1528.6859130859375 +10.39,1529.73779296875 +10.4,1530.7896728515625 +10.41,1531.8349609375 +10.42,1532.89892578125 +10.43,1533.962890625 +10.44,1535.0245361328125 +10.45,1536.0772705078125 +10.46,1537.1248779296875 +10.47,1538.172607421875 +10.48,1539.22412109375 +10.49,1540.2711181640625 +10.5,1541.319091796875 +10.51,1542.3641357421875 +10.52,1543.4034423828125 +10.53,1544.447998046875 +10.54,1545.496337890625 +10.55,1546.5374755859375 +10.56,1547.5772705078125 +10.57,1548.61572265625 +10.58,1549.6483154296875 +10.59,1550.67822265625 +10.6,1551.7000732421875 +10.61,1552.7259521484375 +10.62,1553.7528076171875 +10.63,1554.780029296875 +10.64,1555.8111572265625 +10.65,1556.843994140625 +10.66,1557.8770751953125 +10.67,1558.9022216796875 +10.68,1559.93212890625 +10.69,1560.964599609375 +10.7,1561.9912109375 +10.71,1563.0159912109375 +10.72,1564.037353515625 +10.73,1565.065673828125 +10.74,1566.08837890625 +10.75,1567.1060791015625 +10.76,1568.1190185546875 +10.77,1569.1300048828125 +10.78,1570.134765625 +10.79,1571.1444091796875 +10.8,1572.153564453125 +10.81,1573.155029296875 +10.82,1574.1591796875 +10.83,1575.1666259765625 +10.84,1576.1663818359375 +10.85,1577.162109375 +10.86,1578.1568603515625 +10.87,1579.1512451171875 +10.88,1580.1380615234375 +10.89,1581.1224365234375 +10.9,1582.11474609375 +10.91,1583.099365234375 +10.92,1584.0904541015625 +10.93,1585.0911865234375 +10.94,1586.0855712890625 +10.95,1587.08056640625 +10.96,1588.0723876953125 +10.97,1589.05712890625 +10.98,1590.04931640625 +10.99,1591.032958984375 +11,1592.0089111328125 +11.01,1592.9853515625 +11.02,1593.9732666015625 +11.03,1594.958740234375 +11.04,1595.935546875 +11.05,1596.9217529296875 +11.06,1597.9053955078125 +11.07,1598.881103515625 +11.08,1599.8631591796875 +11.09,1600.83642578125 +11.1,1601.804931640625 +11.11,1602.7862548828125 +11.12,1603.7628173828125 +11.13,1604.74267578125 +11.14,1605.716796875 +11.15,1606.692626953125 +11.16,1607.6619873046875 +11.17,1608.628173828125 +11.18,1609.587890625 +11.19,1610.5411376953125 +11.2,1611.5185546875 +11.21,1612.4886474609375 +11.22,1613.4512939453125 +11.23,1614.4124755859375 +11.24,1615.3656005859375 +11.25,1616.3104248046875 +11.26,1617.258056640625 +11.27,1618.2127685546875 +11.28,1619.1634521484375 +11.29,1620.1177978515625 +11.3,1621.0672607421875 +11.31,1622.023193359375 +11.32,1622.9725341796875 +11.33,1623.91259765625 +11.34,1624.863525390625 +11.35,1625.8138427734375 +11.36,1626.76025390625 +11.37,1627.7149658203125 +11.38,1628.664794921875 +11.39,1629.6053466796875 +11.4,1630.5516357421875 +11.41,1631.489501953125 +11.42,1632.443115234375 +11.43,1633.38916015625 +11.44,1634.330322265625 +11.45,1635.2646484375 +11.46,1636.2059326171875 +11.47,1637.1414794921875 +11.48,1638.0694580078125 +11.49,1638.9879150390625 +11.5,1639.901611328125 +11.51,1640.8197021484375 +11.52,1641.73193359375 +11.53,1642.6605224609375 +11.54,1643.593505859375 +11.55,1644.5169677734375 +11.56,1645.4346923828125 +11.57,1646.3438720703125 +11.58,1647.2445068359375 +11.59,1648.151611328125 +11.6,1649.0576171875 +11.61,1649.9664306640625 +11.62,1650.8675537109375 +11.63,1651.7703857421875 +11.64,1652.6922607421875 +11.65,1653.612060546875 +11.66,1654.5223388671875 +11.67,1655.4334716796875 +11.68,1656.343505859375 +11.69,1657.250732421875 +11.7,1658.156982421875 +11.71,1659.0535888671875 +11.72,1659.9609375 +11.73,1660.8614501953125 +11.74,1661.7630615234375 +11.75,1662.6627197265625 +11.76,1663.5537109375 +11.77,1664.4368896484375 +11.78,1665.3115234375 +11.79,1666.185302734375 +11.8,1667.059326171875 +11.81,1667.9573974609375 +11.82,1668.8536376953125 +11.83,1669.7470703125 +11.84,1670.631591796875 +11.85,1671.512451171875 +11.86,1672.3924560546875 +11.87,1673.269775390625 +11.88,1674.140380859375 +11.89,1675.021484375 +11.9,1675.9061279296875 +11.91,1676.785888671875 +11.92,1677.6578369140625 +11.93,1678.53125 +11.94,1679.3958740234375 +11.95,1680.27734375 +11.96,1681.1500244140625 +11.97,1682.0240478515625 +11.98,1682.893310546875 +11.99,1683.76318359375 +12,1684.633544921875 +12.01,1685.4951171875 +12.02,1686.357177734375 +12.03,1687.231201171875 +12.04,1688.1068115234375 +12.05,1688.9765625 +12.06,1689.8375244140625 +12.07,1690.6949462890625 +12.08,1691.5487060546875 +12.09,1692.4010009765625 +12.1,1693.2728271484375 +12.11,1694.1368408203125 +12.12,1694.991943359375 +12.13,1695.837158203125 +12.14,1696.6842041015625 +12.15,1697.53515625 +12.16,1698.387939453125 +12.17,1699.235107421875 +12.18,1700.080810546875 +12.19,1700.9219970703125 +12.2,1701.7747802734375 +12.21,1702.620849609375 +12.22,1703.461181640625 +12.23,1704.299072265625 +12.24,1705.1466064453125 +12.25,1705.9906005859375 +12.26,1706.8333740234375 +12.27,1707.668212890625 +12.28,1708.495361328125 +12.29,1709.323486328125 +12.3,1710.1580810546875 +12.31,1710.9837646484375 +12.32,1711.821533203125 +12.33,1712.670166015625 +12.34,1713.5108642578125 +12.35,1714.34375 +12.36,1715.1700439453125 +12.37,1715.9908447265625 +12.38,1716.8072509765625 +12.39,1717.6158447265625 +12.4,1718.46337890625 +12.41,1719.30419921875 +12.42,1720.135986328125 +12.43,1720.9700927734375 +12.44,1721.79638671875 +12.45,1722.6148681640625 +12.46,1723.4425048828125 +12.47,1724.2623291015625 +12.48,1725.08447265625 +12.49,1725.9033203125 +12.5,1726.715576171875 +12.51,1727.5201416015625 +12.52,1728.3226318359375 +12.53,1729.1175537109375 +12.54,1729.9239501953125 +12.55,1730.7227783203125 +12.56,1731.5150146484375 +12.57,1732.3155517578125 +12.58,1733.1278076171875 +12.59,1733.9312744140625 +12.6,1734.73046875 +12.61,1735.527587890625 +12.62,1736.3182373046875 +12.63,1737.1138916015625 +12.64,1737.9052734375 +12.65,1738.686767578125 +12.66,1739.486083984375 +12.67,1740.285888671875 +12.68,1741.0767822265625 +12.69,1741.8740234375 +12.7,1742.662353515625 +12.71,1743.4488525390625 +12.72,1744.2322998046875 +12.73,1745.0140380859375 +12.74,1745.8126220703125 +12.75,1746.6058349609375 +12.76,1747.3936767578125 +12.77,1748.1787109375 +12.78,1748.9549560546875 +12.79,1749.7305908203125 +12.8,1750.50341796875 +12.81,1751.29931640625 +12.82,1752.09326171875 +12.83,1752.8785400390625 +12.84,1753.660888671875 +12.85,1754.4415283203125 +12.86,1755.2169189453125 +12.87,1755.98583984375 +12.88,1756.7520751953125 +12.89,1757.511962890625 +12.9,1758.261962890625 +12.91,1759.05810546875 +12.92,1759.8585205078125 +12.93,1760.6500244140625 +12.94,1761.4349365234375 +12.95,1762.2110595703125 +12.96,1762.983154296875 +12.97,1763.759765625 +12.98,1764.53466796875 +12.99,1765.3067626953125 +13,1766.079833984375 +13.01,1766.848876953125 +13.02,1767.6163330078125 +13.03,1768.3919677734375 +13.04,1769.1611328125 +13.05,1769.9228515625 +13.06,1770.685546875 +13.07,1771.439453125 +13.08,1772.1884765625 +13.09,1772.9287109375 +13.1,1773.66650390625 +13.11,1774.40185546875 +13.12,1775.128662109375 +13.13,1775.868896484375 +13.14,1776.609130859375 +13.15,1777.35302734375 +13.16,1778.095703125 +13.17,1778.845703125 +13.18,1779.5992431640625 +13.19,1780.3441162109375 +13.2,1781.0804443359375 +13.21,1781.8228759765625 +13.22,1782.556640625 +13.23,1783.2806396484375 +13.24,1784.008544921875 +13.25,1784.734130859375 +13.26,1785.4537353515625 +13.27,1786.1959228515625 +13.28,1786.9281005859375 +13.29,1787.655517578125 +13.3,1788.3818359375 +13.31,1789.1207275390625 +13.32,1789.8560791015625 +13.33,1790.59033203125 +13.34,1791.321044921875 +13.35,1792.043212890625 +13.36,1792.7630615234375 +13.37,1793.4781494140625 +13.38,1794.19482421875 +13.39,1794.9017333984375 +13.4,1795.6103515625 +13.41,1796.310546875 +13.42,1797.014892578125 +13.43,1797.7362060546875 +13.44,1798.4490966796875 +13.45,1799.1522216796875 +13.46,1799.8646240234375 +13.47,1800.568603515625 +13.48,1801.2679443359375 +13.49,1801.9805908203125 +13.5,1802.6898193359375 +13.51,1803.3995361328125 +13.52,1804.107177734375 +13.53,1804.8089599609375 +13.54,1805.5035400390625 +13.55,1806.203857421875 +13.56,1806.8970947265625 +13.57,1807.5858154296875 +13.58,1808.2816162109375 +13.59,1808.9832763671875 +13.6,1809.677734375 +13.61,1810.3638916015625 +13.62,1811.048095703125 +13.63,1811.7279052734375 +13.64,1812.4254150390625 +13.65,1813.1158447265625 +13.66,1813.804443359375 +13.67,1814.4937744140625 +13.68,1815.191650390625 +13.69,1815.8902587890625 +13.7,1816.5804443359375 +13.71,1817.275390625 +13.72,1817.9605712890625 +13.73,1818.6492919921875 +13.74,1819.3282470703125 +13.75,1820.0054931640625 +13.76,1820.695556640625 +13.77,1821.3785400390625 +13.78,1822.05712890625 +13.79,1822.7313232421875 +13.8,1823.402587890625 +13.81,1824.0841064453125 +13.82,1824.760009765625 +13.83,1825.42626953125 +13.84,1826.09228515625 +13.85,1826.7540283203125 +13.86,1827.418212890625 +13.87,1828.0914306640625 +13.88,1828.7564697265625 +13.89,1829.42529296875 +13.9,1830.1058349609375 +13.91,1830.794189453125 +13.92,1831.47412109375 +13.93,1832.15234375 +13.94,1832.822265625 +13.95,1833.4840087890625 +13.96,1834.149658203125 +13.97,1834.8057861328125 +13.98,1835.4537353515625 +13.99,1836.09619140625 +14,1836.7523193359375 +14.01,1837.406982421875 +14.02,1838.05615234375 +14.03,1838.703857421875 +14.04,1839.364013671875 +14.05,1840.015869140625 +14.06,1840.6650390625 +14.07,1841.318359375 +14.08,1841.9744873046875 +14.09,1842.649658203125 +14.1,1843.315185546875 +14.11,1843.9737548828125 +14.12,1844.6337890625 +14.13,1845.2938232421875 +14.14,1845.9442138671875 +14.15,1846.58642578125 +14.16,1847.221923828125 +14.17,1847.8563232421875 +14.18,1848.4923095703125 +14.19,1849.1409912109375 +14.2,1849.782958984375 +14.21,1850.4154052734375 +14.22,1851.0535888671875 +14.23,1851.692138671875 +14.24,1852.342041015625 +14.25,1852.986572265625 +14.26,1853.6243896484375 +14.27,1854.2568359375 +14.28,1854.88134765625 +14.29,1855.507568359375 +14.3,1856.1356201171875 +14.31,1856.757080078125 +14.32,1857.3720703125 +14.33,1857.9832763671875 +14.34,1858.6217041015625 +14.35,1859.2548828125 +14.36,1859.88427734375 +14.37,1860.5140380859375 +14.38,1861.142822265625 +14.39,1861.7635498046875 +14.4,1862.380615234375 +14.41,1862.996826171875 +14.42,1863.617919921875 +14.43,1864.236572265625 +14.44,1864.8515625 +14.45,1865.457275390625 +14.46,1866.0594482421875 +14.47,1866.6566162109375 +14.48,1867.2630615234375 +14.49,1867.8760986328125 +14.5,1868.4810791015625 +14.51,1869.08251953125 +14.52,1869.683349609375 +14.53,1870.2747802734375 +14.54,1870.8773193359375 +14.55,1871.477783203125 +14.56,1872.07470703125 +14.57,1872.662353515625 +14.58,1873.2596435546875 +14.59,1873.8564453125 +14.6,1874.44970703125 +14.61,1875.03662109375 +14.62,1875.63916015625 +14.63,1876.2352294921875 +14.64,1876.823486328125 +14.65,1877.412841796875 +14.66,1877.992919921875 +14.67,1878.579833984375 +14.68,1879.1722412109375 +14.69,1879.75830078125 +14.7,1880.338134765625 +14.71,1880.932373046875 +14.72,1881.5172119140625 +14.73,1882.103271484375 +14.74,1882.6873779296875 +14.75,1883.2652587890625 +14.76,1883.8369140625 +14.77,1884.4053955078125 +14.78,1884.984130859375 +14.79,1885.5745849609375 +14.8,1886.1646728515625 +14.81,1886.7484130859375 +14.82,1887.3333740234375 +14.83,1887.927001953125 +14.84,1888.5126953125 +14.85,1889.1026611328125 +14.86,1889.6864013671875 +14.87,1890.2623291015625 +14.88,1890.8365478515625 +14.89,1891.41064453125 +14.9,1891.9755859375 +14.91,1892.5404052734375 +14.92,1893.111328125 +14.93,1893.68359375 +14.94,1894.2557373046875 +14.95,1894.8232421875 +14.96,1895.3907470703125 +14.97,1895.948974609375 +14.98,1896.5028076171875 +14.99,1897.0596923828125 +15,1897.6197509765625 +15.01,1898.1844482421875 +15.02,1898.7431640625 +15.03,1899.303466796875 +15.04,1899.8546142578125 +15.05,1900.4012451171875 +15.06,1900.9405517578125 +15.07,1901.473876953125 +15.08,1902.007568359375 +15.09,1902.5570068359375 +15.1,1903.1082763671875 +15.11,1903.655029296875 +15.12,1904.20361328125 +15.13,1904.7540283203125 +15.14,1905.2969970703125 +15.15,1905.844970703125 +15.16,1906.3931884765625 +15.17,1906.935546875 +15.18,1907.489013671875 +15.19,1908.0538330078125 +15.2,1908.61572265625 +15.21,1909.1793212890625 +15.22,1909.733642578125 +15.23,1910.2835693359375 +15.24,1910.8369140625 +15.25,1911.3875732421875 +15.26,1911.9306640625 +15.27,1912.4725341796875 +15.28,1913.0069580078125 +15.29,1913.546630859375 +15.3,1914.0869140625 +15.31,1914.622802734375 +15.32,1915.1512451171875 +15.33,1915.68994140625 +15.34,1916.219482421875 +15.35,1916.74169921875 +15.36,1917.267822265625 +15.37,1917.7882080078125 +15.38,1918.3204345703125 +15.39,1918.851806640625 +15.4,1919.3741455078125 +15.41,1919.89404296875 +15.42,1920.4097900390625 +15.43,1920.919921875 +15.44,1921.4212646484375 +15.45,1921.9283447265625 +15.46,1922.439453125 +15.47,1922.9515380859375 +15.48,1923.4693603515625 +15.49,1923.97998046875 +15.5,1924.4898681640625 +15.51,1924.994140625 +15.52,1925.492919921875 +15.53,1926.0107421875 +15.54,1926.531005859375 +15.55,1927.0587158203125 +15.56,1927.577392578125 +15.57,1928.092041015625 +15.58,1928.5977783203125 +15.59,1929.0947265625 +15.6,1929.589599609375 +15.61,1930.0821533203125 +15.62,1930.5809326171875 +15.63,1931.075927734375 +15.64,1931.5655517578125 +15.65,1932.0662841796875 +15.66,1932.5765380859375 +15.67,1933.0811767578125 +15.68,1933.576904296875 +15.69,1934.072265625 +15.7,1934.560546875 +15.71,1935.041748046875 +15.72,1935.5159912109375 +15.73,1936.005126953125 +15.74,1936.4990234375 +15.75,1936.9942626953125 +15.76,1937.4991455078125 +15.77,1937.9951171875 +15.78,1938.4891357421875 +15.79,1938.9793701171875 +15.8,1939.4608154296875 +15.81,1939.9471435546875 +15.82,1940.445068359375 +15.83,1940.9359130859375 +15.84,1941.426513671875 +15.85,1941.9117431640625 +15.86,1942.39501953125 +15.87,1942.87646484375 +15.88,1943.3663330078125 +15.89,1943.843994140625 +15.9,1944.324951171875 +15.91,1944.7989501953125 +15.92,1945.2659912109375 +15.93,1945.7296142578125 +15.94,1946.19677734375 +15.95,1946.6571044921875 +15.96,1947.129638671875 +15.97,1947.5986328125 +15.98,1948.072998046875 +15.99,1948.5404052734375 +16,1949.0078125 +16.01,1949.4840087890625 +16.02,1949.960205078125 +16.03,1950.4278564453125 +16.04,1950.8868408203125 +16.05,1951.3460693359375 +16.06,1951.7967529296875 +16.07,1952.2388916015625 +16.08,1952.6744384765625 +16.09,1953.1209716796875 +16.1,1953.5712890625 +16.11,1954.01318359375 +16.12,1954.45556640625 +16.13,1954.8983154296875 +16.14,1955.3397216796875 +16.15,1955.7799072265625 +16.16,1956.21875 +16.17,1956.65283203125 +16.18,1957.0784912109375 +16.19,1957.506591796875 +16.2,1957.9532470703125 +16.21,1958.40771484375 +16.22,1958.8553466796875 +16.23,1959.2962646484375 +16.24,1959.7305908203125 +16.25,1960.1563720703125 +16.26,1960.584716796875 +16.27,1961.011962890625 +16.28,1961.438232421875 +16.29,1961.865234375 +16.3,1962.28564453125 +16.31,1962.69775390625 +16.32,1963.12548828125 +16.33,1963.563232421875 +16.34,1963.992431640625 +16.35,1964.4151611328125 +16.36,1964.8350830078125 +16.37,1965.2520751953125 +16.38,1965.6627197265625 +16.39,1966.081787109375 +16.4,1966.4962158203125 +16.41,1966.91357421875 +16.42,1967.33203125 +16.43,1967.753173828125 +16.44,1968.1661376953125 +16.45,1968.574462890625 +16.46,1968.9764404296875 +16.47,1969.3720703125 +16.48,1969.774658203125 +16.49,1970.1766357421875 +16.5,1970.594970703125 +16.51,1971.0068359375 +16.52,1971.4273681640625 +16.53,1971.8509521484375 +16.54,1972.26611328125 +16.55,1972.6748046875 +16.56,1973.0791015625 +16.57,1973.4808349609375 +16.58,1973.8839111328125 +16.59,1974.2825927734375 +16.6,1974.675048828125 +16.61,1975.0765380859375 +16.62,1975.4698486328125 +16.63,1975.86083984375 +16.64,1976.2437744140625 +16.65,1976.6263427734375 +16.66,1977.0047607421875 +16.67,1977.3809814453125 +16.68,1977.7724609375 +16.69,1978.1636962890625 +16.7,1978.5487060546875 +16.71,1978.9256591796875 +16.72,1979.3043212890625 +16.73,1979.692626953125 +16.74,1980.0787353515625 +16.75,1980.4605712890625 +16.76,1980.8560791015625 +16.77,1981.251220703125 +16.78,1981.65185546875 +16.79,1982.05419921875 +16.8,1982.4541015625 +16.81,1982.851806640625 +16.82,1983.2471923828125 +16.83,1983.63427734375 +16.84,1984.015380859375 +16.85,1984.3983154296875 +16.86,1984.7850341796875 +16.87,1985.167724609375 +16.88,1985.5462646484375 +16.89,1985.9228515625 +16.9,1986.2913818359375 +16.91,1986.6600341796875 +16.92,1987.0267333984375 +16.93,1987.4014892578125 +16.94,1987.7703857421875 +16.95,1988.1453857421875 +16.96,1988.5123291015625 +16.97,1988.871337890625 +16.98,1989.2347412109375 +16.99,1989.6104736328125 +17,1989.978271484375 +17.01,1990.3380126953125 +17.02,1990.7021484375 +17.03,1991.058349609375 +17.04,1991.412841796875 +17.05,1991.7718505859375 +17.06,1992.1414794921875 +17.07,1992.503173828125 +17.08,1992.85693359375 +17.09,1993.2193603515625 +17.1,1993.59033203125 +17.11,1993.955322265625 +17.12,1994.314453125 +17.13,1994.665771484375 +17.14,1995.021728515625 +17.15,1995.3739013671875 +17.16,1995.71826171875 +17.17,1996.054931640625 +17.18,1996.4049072265625 +17.19,1996.7470703125 +17.2,1997.0982666015625 +17.21,1997.447998046875 +17.22,1997.7962646484375 +17.23,1998.1534423828125 +17.24,1998.5048828125 +17.25,1998.8548583984375 +17.26,1999.2012939453125 +17.27,1999.5399169921875 +17.28,1999.8795166015625 +17.29,2000.215576171875 +17.3,2000.55029296875 +17.31,2000.8817138671875 +17.32,2001.209716796875 +17.33,2001.5302734375 +17.34,2001.84326171875 +17.35,2002.1488037109375 +17.36,2002.4642333984375 +17.37,2002.78515625 +17.38,2003.1201171875 +17.39,2003.4560546875 +17.4,2003.79736328125 +17.41,2004.148193359375 +17.42,2004.4912109375 +17.43,2004.8284912109375 +17.44,2005.1580810546875 +17.45,2005.480224609375 +17.46,2005.8056640625 +17.47,2006.1300048828125 +17.48,2006.4512939453125 +17.49,2006.7650146484375 +17.5,2007.080078125 +17.51,2007.396484375 +17.52,2007.7076416015625 +17.53,2008.02685546875 +17.54,2008.33203125 +17.55,2008.6297607421875 +17.56,2008.9202880859375 +17.57,2009.2103271484375 +17.58,2009.5087890625 +17.59,2009.8154296875 +17.6,2010.11474609375 +17.61,2010.40673828125 +17.62,2010.6937255859375 +17.63,2011.005126953125 +17.64,2011.3135986328125 +17.65,2011.6259765625 +17.66,2011.9332275390625 +17.67,2012.2398681640625 +17.68,2012.5504150390625 +17.69,2012.853515625 +17.7,2013.1514892578125 +17.71,2013.4444580078125 +17.72,2013.7484130859375 +17.73,2014.0496826171875 +17.74,2014.3436279296875 +17.75,2014.64404296875 +17.76,2014.9600830078125 +17.77,2015.2685546875 +17.78,2015.578857421875 +17.79,2015.8817138671875 +17.8,2016.1817626953125 +17.81,2016.4837646484375 +17.82,2016.7806396484375 +17.83,2017.0703125 +17.84,2017.3505859375 +17.85,2017.623779296875 +17.86,2017.9130859375 +17.87,2018.1953125 +17.88,2018.4703369140625 +17.89,2018.7384033203125 +17.9,2018.99951171875 +17.91,2019.2584228515625 +17.92,2019.5152587890625 +17.93,2019.7841796875 +17.94,2020.046142578125 +17.95,2020.3011474609375 +17.96,2020.561279296875 +17.97,2020.8192138671875 +17.98,2021.0704345703125 +17.99,2021.3172607421875 +18,2021.5572509765625 +18.01,2021.7906494140625 +18.02,2022.017333984375 +18.03,2022.2764892578125 +18.04,2022.4993896484375 +18.05,2022.7452392578125 +18.06,2022.994140625 +18.07,2023.2412109375 +18.08,2023.498779296875 +18.09,2023.74951171875 +18.1,2023.9932861328125 +18.11,2024.2379150390625 +18.12,2024.483154296875 +18.13,2024.7342529296875 +18.14,2024.978515625 +18.15,2025.2159423828125 +18.16,2025.4490966796875 +18.17,2025.688232421875 +18.18,2025.9207763671875 +18.19,2026.1619873046875 +18.2,2026.396484375 +18.21,2026.6573486328125 +18.22,2026.9112548828125 +18.23,2027.165771484375 +18.24,2027.4158935546875 +18.25,2027.684814453125 +18.26,2027.9490966796875 +18.27,2028.2064208984375 +18.28,2028.4593505859375 +18.29,2028.7156982421875 +18.3,2028.9779052734375 +18.31,2029.2381591796875 +18.32,2029.49658203125 +18.33,2029.748046875 +18.34,2029.995361328125 +18.35,2030.2410888671875 +18.36,2030.4801025390625 +18.37,2030.71240234375 +18.38,2030.9486083984375 +18.39,2031.1961669921875 +18.4,2031.439697265625 +18.41,2031.673828125 +18.42,2031.9041748046875 +18.43,2032.1331787109375 +18.44,2032.3555908203125 +18.45,2032.58203125 +18.46,2032.8204345703125 +18.47,2033.0338134765625 +18.48,2033.2568359375 +18.49,2033.473388671875 +18.5,2033.686279296875 +18.51,2033.8980712890625 +18.52,2034.1063232421875 +18.53,2034.308349609375 +18.54,2034.5042724609375 +18.55,2034.694091796875 +18.56,2034.888671875 +18.57,2035.0853271484375 +18.58,2035.3109130859375 +18.59,2035.530029296875 +18.6,2035.7454833984375 +18.61,2035.9791259765625 +18.62,2036.203369140625 +18.63,2036.3992919921875 +18.64,2036.611083984375 +18.65,2036.8165283203125 +18.66,2037.0157470703125 +18.67,2037.208984375 +18.68,2037.4044189453125 +18.69,2037.5965576171875 +18.7,2037.7965087890625 +18.71,2037.995849609375 +18.72,2038.2081298828125 +18.73,2038.4141845703125 +18.74,2038.6142578125 +18.75,2038.808349609375 +18.76,2038.9967041015625 +18.77,2039.1795654296875 +18.78,2039.3570556640625 +18.79,2039.542236328125 +18.8,2039.732177734375 +18.81,2039.916748046875 +18.82,2040.0958251953125 +18.83,2040.2646484375 +18.84,2040.4334716796875 +18.85,2040.60986328125 +18.86,2040.7811279296875 +18.87,2040.9674072265625 +18.88,2041.158447265625 +18.89,2041.3416748046875 +18.9,2041.522216796875 +18.91,2041.695068359375 +18.92,2041.8656005859375 +18.93,2042.03125 +18.94,2042.1968994140625 +18.95,2042.3626708984375 +18.96,2042.535888671875 +18.97,2042.72119140625 +18.98,2042.9085693359375 +18.99,2043.083740234375 +19,2043.2540283203125 +19.01,2043.4195556640625 +19.02,2043.587646484375 +19.03,2043.760498046875 +19.04,2043.9429931640625 +19.05,2044.1278076171875 +19.06,2044.3101806640625 +19.07,2044.4876708984375 +19.08,2044.66748046875 +19.09,2044.842529296875 +19.1,2045.0177001953125 +19.11,2045.1881103515625 +19.12,2045.3492431640625 +19.13,2045.501220703125 +19.14,2045.646484375 +19.15,2045.78759765625 +19.16,2045.9268798828125 +19.17,2046.0621337890625 +19.18,2046.1932373046875 +19.19,2046.3204345703125 +19.2,2046.4482421875 +19.21,2046.5814208984375 +19.22,2046.7176513671875 +19.23,2046.849853515625 +19.24,2046.994384765625 +19.25,2047.13720703125 +19.26,2047.27587890625 +19.27,2047.4267578125 +19.28,2047.564208984375 +19.29,2047.70703125 +19.3,2047.85498046875 +19.31,2047.9989013671875 +19.32,2048.13427734375 +19.33,2048.265625 +19.34,2048.393310546875 +19.35,2048.52197265625 +19.36,2048.6513671875 +19.37,2048.79296875 +19.38,2048.9306640625 +19.39,2049.071533203125 +19.4,2049.20849609375 +19.41,2049.34619140625 +19.42,2049.480224609375 +19.43,2049.614990234375 +19.44,2049.741455078125 +19.45,2049.86865234375 +19.46,2049.9990234375 +19.47,2050.128173828125 +19.48,2050.253662109375 +19.49,2050.37548828125 +19.5,2050.49609375 +19.51,2050.61328125 +19.52,2050.749755859375 +19.53,2050.8916015625 +19.54,2051.02978515625 +19.55,2051.164306640625 +19.56,2051.295166015625 +19.57,2051.41357421875 +19.58,2051.5283203125 +19.59,2051.64892578125 +19.6,2051.77734375 +19.61,2051.904541015625 +19.62,2052.0283203125 +19.63,2052.1484375 +19.64,2052.25634765625 +19.65,2052.36083984375 +19.66,2052.462158203125 +19.67,2052.560546875 +19.68,2052.655517578125 +19.69,2052.74755859375 +19.7,2052.836669921875 +19.71,2052.9296875 +19.72,2053.02197265625 +19.73,2053.11328125 +19.74,2053.20166015625 +19.75,2053.30322265625 +19.76,2053.40380859375 +19.77,2053.510498046875 +19.78,2053.609619140625 +19.79,2053.705810546875 +19.8,2053.798828125 +19.81,2053.888916015625 +19.82,2053.976318359375 +19.83,2054.060791015625 +19.84,2054.147216796875 +19.85,2054.237548828125 +19.86,2054.338623046875 +19.87,2054.436767578125 +19.88,2054.531982421875 +19.89,2054.631103515625 +19.9,2054.727294921875 +19.91,2054.82080078125 +19.92,2054.911376953125 +19.93,2054.999267578125 +19.94,2055.084228515625 +19.95,2055.166748046875 +19.96,2055.241943359375 +19.97,2055.32373046875 +19.98,2055.393798828125 +19.99,2055.46142578125 +20,2055.53564453125 +20.01,2055.61865234375 +20.02,2055.69677734375 +20.03,2055.772216796875 +20.04,2055.845458984375 +20.05,2055.91845703125 +20.06,2055.997802734375 +20.07,2056.07470703125 +20.08,2056.14697265625 +20.09,2056.216796875 +20.1,2056.2841796875 +20.11,2056.3515625 +20.12,2056.42333984375 +20.13,2056.49267578125 +20.14,2056.559814453125 +20.15,2056.62451171875 +20.16,2056.68701171875 +20.17,2056.75390625 +20.18,2056.81884765625 +20.19,2056.876953125 +20.2,2056.937255859375 +20.21,2056.990966796875 +20.22,2057.047119140625 +20.23,2057.101318359375 +20.24,2057.1533203125 +20.25,2057.203369140625 +20.26,2057.2490234375 +20.27,2057.295166015625 +20.28,2057.357177734375 +20.29,2057.4169921875 +20.3,2057.4794921875 +20.31,2057.53955078125 +20.32,2057.61572265625 +20.33,2057.6943359375 +20.34,2057.7705078125 +20.35,2057.844482421875 +20.36,2057.916259765625 +20.37,2057.98583984375 +20.38,2058.048828125 +20.39,2058.10302734375 +20.4,2058.15087890625 +20.41,2058.197021484375 +20.42,2058.2412109375 +20.43,2058.283447265625 +20.44,2058.308349609375 +20.45,2058.33154296875 +20.46,2058.353271484375 +20.47,2058.3935546875 +20.48,2058.432373046875 +20.49,2058.4716796875 +20.5,2058.5068359375 +20.51,2058.5361328125 +20.52,2058.55029296875 +20.53,2058.57666015625 +20.54,2058.601318359375 +20.55,2058.62451171875 +20.56,2058.646240234375 +20.57,2058.6533203125 +20.58,2058.6591796875 +20.59,2058.67724609375 +20.6,2058.69384765625 +20.61,2058.7158203125 +20.62,2058.736572265625 +20.63,2058.76025390625 +20.64,2058.782470703125 +20.65,2058.799072265625 +20.66,2058.81884765625 +20.67,2058.837158203125 +20.68,2058.854248046875 +20.69,2058.870361328125 +20.7,2058.896240234375 +20.71,2058.8984375 +20.72,2058.8994140625 +20.73,2058.8994140625 +20.74,2058.9140625 +20.75,2058.927490234375 +20.76,2058.93994140625 +20.77,2058.951171875 +20.78,2058.963623046875 +20.79,2058.97265625 +20.8,2058.98291015625 +20.81,2058.9921875 +20.82,2058.998046875 +20.83,2059.01171875 +20.84,2059.0244140625 +20.85,2059.01611328125 +20.86,2059.007080078125 +20.87,2058.99462890625 +20.88,2058.954833984375 +20.89,2058.769775390625 +20.9,2058.137939453125 +20.91,2057.27001953125 +20.92,2056.1728515625 +20.93,2055.071044921875 +20.94,2053.763427734375 +20.95,2052.260986328125 +20.96,2050.477783203125 +20.97,2048.713623046875 +20.98,2046.96826171875 +20.99,2045.24169921875 +21,2043.533935546875 +21.01,2042.13671875 +21.02,2042.214111328125 +21.03,2042.3524169921875 +21.04,2042.49560546875 +21.05,2042.6390380859375 +21.06,2042.7828369140625 +21.07,2042.9290771484375 +21.08,2043.0753173828125 +21.09,2043.21923828125 +21.1,2043.3631591796875 +21.11,2043.50927734375 +21.12,2043.6553955078125 +21.13,2043.8017578125 +21.14,2043.947998046875 +21.15,2044.0941162109375 +21.16,2044.240234375 +21.17,2044.3751220703125 +21.18,2044.5211181640625 +21.19,2044.6624755859375 +21.2,2044.8037109375 +21.21,2044.9447021484375 +21.22,2045.078857421875 +21.23,2045.219482421875 +21.24,2045.373291015625 +21.25,2045.5133056640625 +21.26,2045.6663818359375 +21.27,2045.8189697265625 +21.28,2045.9710693359375 +21.29,2046.1181640625 +21.3,2046.2647705078125 +21.31,2046.410888671875 +21.32,2046.5408935546875 +21.33,2046.66162109375 +21.34,2046.781982421875 +21.35,2046.8955078125 +21.36,2047.0086669921875 +21.37,2047.1148681640625 +21.38,2047.2208251953125 +21.39,2047.3331298828125 +21.4,2047.4295654296875 +21.41,2047.5302734375 +21.42,2047.641845703125 +21.43,2047.7574462890625 +21.44,2047.87255859375 +21.45,2047.9849853515625 +21.46,2048.096923828125 +21.47,2048.203857421875 +21.48,2048.301513671875 +21.49,2048.398681640625 +21.5,2048.49560546875 +21.51,2048.592041015625 +21.52,2048.67041015625 +21.53,2048.74609375 +21.54,2048.823974609375 +21.55,2048.8994140625 +21.56,2048.9677734375 +21.57,2049.035888671875 +21.58,2049.101806640625 +21.59,2049.16748046875 +21.6,2049.228515625 +21.61,2049.289306640625 +21.62,2049.3544921875 +21.63,2049.414794921875 +21.64,2049.47509765625 +21.65,2049.53515625 +21.66,2049.581787109375 +21.67,2049.6416015625 +21.68,2049.710205078125 +21.69,2049.7783203125 +21.7,2049.843994140625 +21.71,2049.9091796875 +21.72,2049.97412109375 +21.73,2050.041015625 +21.74,2050.112060546875 +21.75,2050.182373046875 +21.76,2050.25244140625 +21.77,2050.3154296875 +21.78,2050.384765625 +21.79,2050.460205078125 +21.8,2050.528564453125 +21.81,2050.596435546875 +21.82,2050.670654296875 +21.83,2050.72216796875 +21.84,2050.79541015625 +21.85,2050.8681640625 +21.86,2050.94921875 +21.87,2051.04541015625 +21.88,2051.14501953125 +21.89,2051.243896484375 +21.9,2051.341552734375 +21.91,2051.4384765625 +21.92,2051.53466796875 +21.93,2051.6298828125 +21.94,2051.72412109375 +21.95,2051.817626953125 +21.96,2051.91015625 +21.97,2052.001953125 +21.98,2052.0927734375 +21.99,2052.171630859375 +22,2052.249755859375 +22.01,2052.326904296875 +22.02,2052.41455078125 +22.03,2052.508056640625 +22.04,2052.6005859375 +22.05,2052.692138671875 +22.06,2052.78271484375 +22.07,2052.872314453125 +22.08,2052.967529296875 +22.09,2053.061767578125 +22.1,2053.155029296875 +22.11,2053.24951171875 +22.12,2053.349609375 +22.13,2053.455322265625 +22.14,2053.552978515625 +22.15,2053.65625 +22.16,2053.75830078125 +22.17,2053.865966796875 +22.18,2053.972412109375 +22.19,2054.07080078125 +22.2,2054.16162109375 +22.21,2054.246826171875 +22.22,2054.330810546875 +22.23,2054.41162109375 +22.24,2054.4736328125 +22.25,2054.552490234375 +22.26,2054.63037109375 +22.27,2054.70947265625 +22.28,2054.80078125 +22.29,2054.890869140625 +22.3,2054.97998046875 +22.31,2055.03466796875 +22.32,2055.121826171875 +22.33,2055.207763671875 +22.34,2055.28369140625 +22.35,2055.3095703125 +22.36,2055.326171875 +22.37,2055.342529296875 +22.38,2055.3583984375 +22.39,2055.382568359375 +22.4,2055.40869140625 +22.41,2055.434326171875 +22.42,2055.4482421875 +22.43,2055.470703125 +22.44,2055.49267578125 +22.45,2055.516357421875 +22.46,2055.53955078125 +22.47,2055.564453125 +22.48,2055.5888671875 +22.49,2055.61279296875 +22.5,2055.6318359375 +22.51,2055.65478515625 +22.52,2055.7060546875 +22.53,2055.765380859375 +22.54,2055.835205078125 +22.55,2055.921875 +22.56,2056.038330078125 +22.57,2056.1953125 +22.58,2056.3505859375 +22.59,2056.50390625 +22.6,2056.655517578125 +22.61,2056.747314453125 +22.62,2056.837646484375 +22.63,2056.984619140625 +22.64,2057.072021484375 +22.65,2057.153564453125 +22.66,2057.2294921875 +22.67,2057.304443359375 +22.68,2057.362548828125 +22.69,2057.412841796875 +22.7,2057.42919921875 +22.71,2057.442626953125 +22.72,2057.440185546875 +22.73,2057.40869140625 +22.74,2057.376953125 +22.75,2057.33203125 +22.76,2057.271484375 +22.77,2057.202392578125 +22.78,2057.12255859375 +22.79,2057.030029296875 +22.8,2056.9091796875 +22.81,2056.78955078125 +22.82,2056.66845703125 +22.83,2056.54833984375 +22.84,2056.425048828125 +22.85,2056.300537109375 +22.86,2056.150390625 +22.87,2055.96826171875 +22.88,2055.787841796875 +22.89,2055.58935546875 +22.9,2055.37939453125 +22.91,2055.151611328125 +22.92,2054.91943359375 +22.93,2054.689697265625 +22.94,2054.45361328125 +22.95,2054.213134765625 +22.96,2053.98193359375 +22.97,2053.753173828125 +22.98,2053.529052734375 +22.99,2053.31396484375 +23,2053.101318359375 +23.01,2052.897705078125 +23.02,2052.6962890625 +23.03,2052.497314453125 +23.04,2052.300537109375 +23.05,2052.099365234375 +23.06,2051.90283203125 +23.07,2051.706298828125 +23.08,2051.510009765625 +23.09,2051.318115234375 +23.1,2051.128662109375 +23.11,2050.94140625 +23.12,2050.75634765625 +23.13,2050.575927734375 +23.14,2050.442138671875 +23.15,2050.329833984375 +23.16,2050.232421875 +23.17,2050.143310546875 +23.18,2050.055419921875 +23.19,2049.97119140625 +23.2,2049.892578125 +23.21,2049.815185546875 +23.22,2049.7392578125 +23.23,2049.664306640625 +23.24,2049.60400390625 +23.25,2049.55126953125 +23.26,2049.499755859375 +23.27,2049.448974609375 +23.28,2049.39697265625 +23.29,2049.34619140625 +23.3,2049.291748046875 +23.31,2049.242919921875 +23.32,2049.195068359375 +23.33,2049.14794921875 +23.34,2049.101806640625 +23.35,2049.06103515625 +23.36,2049.03662109375 +23.37,2049.032958984375 +23.38,2049.060791015625 +23.39,2049.1044921875 +23.4,2049.14794921875 +23.41,2049.19140625 +23.42,2049.219482421875 +23.43,2049.245361328125 +23.44,2049.255615234375 +23.45,2049.263916015625 +23.46,2049.272705078125 +23.47,2049.2529296875 +23.48,2049.2314453125 +23.49,2049.2041015625 +23.5,2049.17724609375 +23.51,2049.100341796875 +23.52,2048.997802734375 +23.53,2048.883544921875 +23.54,2048.770751953125 +23.55,2048.62646484375 +23.56,2048.4794921875 +23.57,2048.3056640625 +23.58,2048.13427734375 +23.59,2047.9493408203125 +23.6,2047.766845703125 +23.61,2047.5867919921875 +23.62,2047.4090576171875 +23.63,2047.22265625 +23.64,2047.0496826171875 +23.65,2046.8680419921875 +23.66,2046.6844482421875 +23.67,2046.4967041015625 +23.68,2046.3092041015625 +23.69,2046.124267578125 +23.7,2045.9375 +23.71,2045.7532958984375 +23.72,2045.5760498046875 +23.73,2045.3968505859375 +23.74,2045.22021484375 +23.75,2045.0504150390625 +23.76,2044.878662109375 +23.77,2044.716064453125 +23.78,2044.502685546875 +23.79,2044.292236328125 +23.8,2044.0625 +23.81,2043.8271484375 +23.82,2043.58837890625 +23.83,2043.3240966796875 +23.84,2043.041259765625 +23.85,2042.7554931640625 +23.86,2042.447021484375 +23.87,2042.140380859375 +23.88,2041.8377685546875 +23.89,2041.539306640625 +23.9,2041.23828125 +23.91,2040.9368896484375 +23.92,2040.6396484375 +23.93,2040.346435546875 +23.94,2040.0108642578125 +23.95,2039.6798095703125 +23.96,2039.3533935546875 +23.97,2039.0269775390625 +23.98,2038.70947265625 +23.99,2038.3919677734375 +24,2038.0614013671875 +24.01,2037.719970703125 +24.02,2037.381103515625 +24.03,2037.0447998046875 +24.04,2036.7132568359375 +24.05,2036.382080078125 +24.06,2036.033447265625 +24.07,2035.6898193359375 +24.08,2035.3466796875 +24.09,2035.008544921875 +24.1,2034.6685791015625 +24.11,2034.3314208984375 +24.12,2033.9947509765625 +24.13,2033.6607666015625 +24.14,2033.331787109375 +24.15,2033.0078125 +24.16,2032.6578369140625 +24.17,2032.3106689453125 +24.18,2031.957763671875 +24.19,2031.6099853515625 +24.2,2031.2630615234375 +24.21,2030.9215087890625 +24.22,2030.573974609375 +24.23,2030.2230224609375 +24.24,2029.88623046875 +24.25,2029.5457763671875 +24.26,2029.20166015625 +24.27,2028.8629150390625 +24.28,2028.5074462890625 +24.29,2028.1529541015625 +24.3,2027.7996826171875 +24.31,2027.4473876953125 +24.32,2027.1005859375 +24.33,2026.75927734375 +24.34,2026.4122314453125 +24.35,2026.0531005859375 +24.36,2025.6842041015625 +24.37,2025.3189697265625 +24.38,2024.9549560546875 +24.39,2024.5968017578125 +24.4,2024.23974609375 +24.41,2023.8818359375 +24.42,2023.5296630859375 +24.43,2023.1788330078125 +24.44,2022.8138427734375 +24.45,2022.4547119140625 +24.46,2022.1014404296875 +24.47,2021.75390625 +24.48,2021.4034423828125 +24.49,2021.041015625 +24.5,2020.6844482421875 +24.51,2020.3359375 +24.52,2020.0087890625 +24.53,2019.709228515625 +24.54,2019.4173583984375 +24.55,2019.130615234375 +24.56,2018.84912109375 +24.57,2018.574951171875 +24.58,2018.310302734375 +24.59,2018.0550537109375 +24.6,2017.8201904296875 +24.61,2017.590087890625 +24.62,2017.3646240234375 +24.63,2017.1502685546875 +24.64,2016.9669189453125 +24.65,2016.8077392578125 +24.66,2016.654541015625 +24.67,2016.505126953125 +24.68,2016.368408203125 +24.69,2016.2265625 +24.7,2016.08837890625 +24.71,2015.947265625 +24.72,2015.8096923828125 +24.73,2015.6824951171875 +24.74,2015.5521240234375 +24.75,2015.42529296875 +24.76,2015.3106689453125 +24.77,2015.199462890625 +24.78,2015.0982666015625 +24.79,2015.009033203125 +24.8,2014.929443359375 +24.81,2014.8572998046875 +24.82,2014.7945556640625 +24.83,2014.74560546875 +24.84,2014.69921875 +24.85,2014.6553955078125 +24.86,2014.614013671875 +24.87,2014.5750732421875 +24.88,2014.5650634765625 +24.89,2014.55712890625 +24.9,2014.5511474609375 +24.91,2014.5472412109375 +24.92,2014.5299072265625 +24.93,2014.5145263671875 +24.94,2014.4990234375 +24.95,2014.485595703125 +24.96,2014.47412109375 +24.97,2014.4468994140625 +24.98,2014.421875 +24.99,2014.3944091796875 +25,2014.3690185546875 +25.01,2014.36767578125 +25.02,2014.3836669921875 +25.03,2014.4188232421875 +25.04,2014.459716796875 +25.05,2014.501953125 +25.06,2014.5673828125 +25.07,2014.61181640625 +25.08,2014.6573486328125 +25.09,2014.6995849609375 +25.1,2014.7098388671875 +25.11,2014.712646484375 +25.12,2014.7081298828125 +25.13,2014.68310546875 +25.14,2014.6533203125 +25.15,2014.603271484375 +25.16,2014.5462646484375 +25.17,2014.500244140625 +25.18,2014.4473876953125 +25.19,2014.394287109375 +25.2,2014.3387451171875 +25.21,2014.2896728515625 +25.22,2014.211669921875 +25.23,2014.1358642578125 +25.24,2014.06005859375 +25.25,2013.986328125 +25.26,2013.9149169921875 +25.27,2013.8455810546875 +25.28,2013.7783203125 +25.29,2013.7132568359375 +25.3,2013.6434326171875 +25.31,2013.5758056640625 +25.32,2013.5101318359375 +25.33,2013.446533203125 +25.34,2013.385009765625 +25.35,2013.325439453125 +25.36,2013.2877197265625 +25.37,2013.2318115234375 +25.38,2013.188720703125 +25.39,2013.1474609375 +25.4,2013.096923828125 +25.41,2013.0458984375 +25.42,2012.9990234375 +25.43,2012.953857421875 +25.44,2012.910400390625 +25.45,2012.866455078125 +25.46,2012.8240966796875 +25.47,2012.78125 +25.48,2012.7401123046875 +25.49,2012.6939697265625 +25.5,2012.61865234375 +25.51,2012.4769287109375 +25.52,2012.2872314453125 +25.53,2012.0675048828125 +25.54,2011.846923828125 +25.55,2011.618896484375 +25.56,2011.387939453125 +25.57,2011.142822265625 +25.58,2010.901611328125 +25.59,2010.6378173828125 +25.6,2010.3780517578125 +25.61,2010.1156005859375 +25.62,2009.84619140625 +25.63,2009.5809326171875 +25.64,2009.3197021484375 +25.65,2009.033935546875 +25.66,2008.7503662109375 +25.67,2008.4556884765625 +25.68,2008.1611328125 +25.69,2007.8687744140625 +25.7,2007.5787353515625 +25.71,2007.271240234375 +25.72,2006.9683837890625 +25.73,2006.670166015625 +25.74,2006.4007568359375 +25.75,2006.1114501953125 +25.76,2005.806884765625 +25.77,2005.507080078125 +25.78,2005.2120361328125 +25.79,2004.897216796875 +25.8,2004.58740234375 +25.81,2004.251708984375 +25.82,2003.921142578125 +25.83,2003.5955810546875 +25.84,2003.292724609375 +25.85,2002.9969482421875 +25.86,2002.7169189453125 +25.87,2002.4415283203125 +25.88,2002.1861572265625 +25.89,2001.937255859375 +25.9,2001.699462890625 +25.91,2001.4967041015625 +25.92,2001.3065185546875 +25.93,2001.1268310546875 +25.94,2000.9815673828125 +25.95,2000.839599609375 +25.96,2000.700927734375 +25.97,2000.5654296875 +25.98,2000.43310546875 +25.99,2000.3038330078125 +26,2000.1776123046875 +26.01,2000.054443359375 +26.02,1999.93212890625 +26.03,1999.799560546875 +26.04,1999.670166015625 +26.05,1999.5416259765625 +26.06,1999.4073486328125 +26.07,1999.232177734375 +26.08,1999.060546875 +26.09,1998.887939453125 +26.1,1998.7188720703125 +26.11,1998.5533447265625 +26.12,1998.3912353515625 +26.13,1998.232421875 +26.14,1998.0770263671875 +26.15,1997.9249267578125 +26.16,1997.776123046875 +26.17,1997.630615234375 +26.18,1997.4884033203125 +26.19,1997.362548828125 +26.2,1997.2264404296875 +26.21,1997.08251953125 +26.22,1996.932861328125 +26.23,1996.7864990234375 +26.24,1996.64111328125 +26.25,1996.492431640625 +26.26,1996.342529296875 +26.27,1996.1849365234375 +26.28,1996.0152587890625 +26.29,1995.8489990234375 +26.3,1995.6839599609375 +26.31,1995.5091552734375 +26.32,1995.3377685546875 +26.33,1995.169921875 +26.34,1995.018798828125 +26.35,1994.870849609375 +26.36,1994.737060546875 +26.37,1994.613037109375 +26.38,1994.496337890625 +26.39,1994.42431640625 +26.4,1994.3746337890625 +26.41,1994.3336181640625 +26.42,1994.3145751953125 +26.43,1994.312744140625 +26.44,1994.3125 +26.45,1994.3138427734375 +26.46,1994.31689453125 +26.47,1994.279541015625 +26.48,1994.244140625 +26.49,1994.193115234375 +26.5,1994.12646484375 +26.51,1994.051025390625 +26.52,1993.9735107421875 +26.53,1993.8873291015625 +26.54,1993.799072265625 +26.55,1993.7132568359375 +26.56,1993.6253662109375 +26.57,1993.5399169921875 +26.58,1993.443603515625 +26.59,1993.3387451171875 +26.6,1993.2364501953125 +26.61,1993.1343994140625 +26.62,1993.01953125 +26.63,1992.88525390625 +26.64,1992.74951171875 +26.65,1992.6165771484375 +26.66,1992.4864501953125 +26.67,1992.3350830078125 +26.68,1992.1866455078125 +26.69,1992.0411376953125 +26.7,1991.9139404296875 +26.71,1991.796142578125 +26.72,1991.6810302734375 +26.73,1991.590576171875 +26.74,1991.50244140625 +26.75,1991.4254150390625 +26.76,1991.3551025390625 +26.77,1991.2869873046875 +26.78,1991.2208251953125 +26.79,1991.1568603515625 +26.8,1991.0927734375 +26.81,1991.028564453125 +26.82,1990.9598388671875 +26.83,1990.851318359375 +26.84,1990.723388671875 +26.85,1990.57177734375 +26.86,1990.3900146484375 +26.87,1990.1654052734375 +26.88,1989.9444580078125 +26.89,1989.7271728515625 +26.9,1989.4915771484375 +26.91,1989.260009765625 +26.92,1989.0540771484375 +26.93,1988.851806640625 +26.94,1988.6529541015625 +26.95,1988.4598388671875 +26.96,1988.30322265625 +26.97,1988.1165771484375 +26.98,1987.93115234375 +26.99,1987.749267578125 +27,1987.5595703125 +27.01,1987.3536376953125 +27.02,1987.14697265625 +27.03,1986.94384765625 +27.04,1986.744384765625 +27.05,1986.5330810546875 +27.06,1986.325439453125 +27.07,1986.136962890625 +27.08,1985.9078369140625 +27.09,1985.7266845703125 +27.1,1985.5751953125 +27.11,1985.4312744140625 +27.12,1985.2860107421875 +27.13,1985.143798828125 +27.14,1985.0089111328125 +27.15,1984.8770751953125 +27.16,1984.74365234375 +27.17,1984.613037109375 +27.18,1984.4437255859375 +27.19,1984.277587890625 +27.2,1984.1146240234375 +27.21,1983.9549560546875 +27.22,1983.7940673828125 +27.23,1983.6298828125 +27.24,1983.4688720703125 +27.25,1983.300048828125 +27.26,1983.1455078125 +27.27,1982.9764404296875 +27.28,1982.80419921875 +27.29,1982.63525390625 +27.3,1982.4761962890625 +27.31,1982.313720703125 +27.32,1982.1610107421875 +27.33,1981.967529296875 +27.34,1981.834716796875 +27.35,1981.6478271484375 +27.36,1981.4599609375 +27.37,1981.275634765625 +27.38,1981.09033203125 +27.39,1980.868896484375 +27.4,1980.6339111328125 +27.41,1980.3984375 +27.42,1980.16259765625 +27.43,1979.9176025390625 +27.44,1979.6219482421875 +27.45,1979.328857421875 +27.46,1979.040283203125 +27.47,1978.7476806640625 +27.48,1978.4444580078125 +27.49,1978.14599609375 +27.5,1977.8369140625 +27.51,1977.5108642578125 +27.52,1977.16357421875 +27.53,1976.8194580078125 +27.54,1976.4281005859375 +27.55,1976.0272216796875 +27.56,1975.6146240234375 +27.57,1975.177490234375 +27.58,1974.720458984375 +27.59,1974.27001953125 +27.6,1973.8525390625 +27.61,1973.4150390625 +27.62,1972.9949951171875 +27.63,1972.581298828125 +27.64,1972.1541748046875 +27.65,1971.7335205078125 +27.66,1971.29296875 +27.67,1970.8568115234375 +27.68,1970.4273681640625 +27.69,1970.0001220703125 +27.7,1969.564208984375 +27.71,1969.1260986328125 +27.72,1968.6904296875 +27.73,1968.261474609375 +27.74,1967.8436279296875 +27.75,1967.427978515625 +27.76,1966.9840087890625 +27.77,1966.546875 +27.78,1966.11669921875 +27.79,1965.693359375 +27.8,1965.2767333984375 +27.81,1964.8668212890625 +27.82,1964.46142578125 +27.83,1964.058349609375 +27.84,1963.666259765625 +27.85,1963.28076171875 +27.86,1962.901611328125 +27.87,1962.524658203125 +27.88,1962.154052734375 +27.89,1961.765869140625 +27.9,1961.36669921875 +27.91,1960.9698486328125 +27.92,1960.5797119140625 +27.93,1960.1917724609375 +27.94,1959.810546875 +27.95,1959.4249267578125 +27.96,1959.056884765625 +27.97,1958.6954345703125 +27.98,1958.329345703125 +27.99,1957.9698486328125 +28,1957.61669921875 +28.01,1957.2633056640625 +28.02,1956.9010009765625 +28.03,1956.536376953125 +28.04,1956.1629638671875 +28.05,1955.782958984375 +28.06,1955.4075927734375 +28.07,1955.0234375 +28.08,1954.6461181640625 +28.09,1954.275390625 +28.1,1953.9071044921875 +28.11,1953.54541015625 +28.12,1953.1793212890625 +28.13,1952.8154296875 +28.14,1952.458251953125 +28.15,1952.098876953125 +28.16,1951.75927734375 +28.17,1951.4127197265625 +28.18,1951.070556640625 +28.19,1950.7347412109375 +28.2,1950.4052734375 +28.21,1950.0799560546875 +28.22,1949.7457275390625 +28.23,1949.417724609375 +28.24,1949.07861328125 +28.25,1948.7327880859375 +28.26,1948.3890380859375 +28.27,1948.0518798828125 +28.28,1947.738525390625 +28.29,1947.4315185546875 +28.3,1947.121826171875 +28.31,1946.8314208984375 +28.32,1946.546875 +28.33,1946.268310546875 +28.34,1945.9954833984375 +28.35,1945.7393798828125 +28.36,1945.4932861328125 +28.37,1945.2548828125 +28.38,1945.0262451171875 +28.39,1944.8160400390625 +28.4,1944.621826171875 +28.41,1944.44140625 +28.42,1944.2808837890625 +28.43,1944.1490478515625 +28.44,1944.0213623046875 +28.45,1943.89794921875 +28.46,1943.778564453125 +28.47,1943.663330078125 +28.48,1943.5455322265625 +28.49,1943.431640625 +28.5,1943.32177734375 +28.51,1943.2158203125 +28.52,1943.1136474609375 +28.53,1943.0152587890625 +28.54,1942.920654296875 +28.55,1942.82958984375 +28.56,1942.7379150390625 +28.57,1942.61474609375 +28.58,1942.4276123046875 +28.59,1942.2451171875 +28.6,1942.067138671875 +28.61,1941.8936767578125 +28.62,1941.722412109375 +28.63,1941.555419921875 +28.64,1941.384033203125 +28.65,1941.2213134765625 +28.66,1941.0628662109375 +28.67,1940.904296875 +28.68,1940.7017822265625 +28.69,1940.4908447265625 +28.7,1940.2735595703125 +28.71,1940.0457763671875 +28.72,1939.8076171875 +28.73,1939.5655517578125 +28.74,1939.32861328125 +28.75,1939.0965576171875 +28.76,1938.86083984375 +28.77,1938.6300048828125 +28.78,1938.3997802734375 +28.79,1938.161376953125 +28.8,1937.9234619140625 +28.81,1937.6949462890625 +28.82,1937.4560546875 +28.83,1937.2069091796875 +28.84,1936.97802734375 +28.85,1936.754150390625 +28.86,1936.5350341796875 +28.87,1936.32080078125 +28.88,1936.0697021484375 +28.89,1935.8236083984375 +28.9,1935.5826416015625 +28.91,1935.3336181640625 +28.92,1935.0853271484375 +28.93,1934.826904296875 +28.94,1934.5736083984375 +28.95,1934.3056640625 +28.96,1934.0430908203125 +28.97,1933.7835693359375 +28.98,1933.529296875 +28.99,1933.2801513671875 +29,1933.0361328125 +29.01,1932.79931640625 +29.02,1932.6002197265625 +29.03,1932.4210205078125 +29.04,1932.252685546875 +29.05,1932.088623046875 +29.06,1931.935302734375 +29.07,1931.7926025390625 +29.08,1931.6583251953125 +29.09,1931.543212890625 +29.1,1931.46435546875 +29.11,1931.4019775390625 +29.12,1931.351318359375 +29.13,1931.3101806640625 +29.14,1931.28271484375 +29.15,1931.2667236328125 +29.16,1931.253173828125 +29.17,1931.2420654296875 +29.18,1931.2354736328125 +29.19,1931.2379150390625 +29.2,1931.255615234375 +29.21,1931.2818603515625 +29.22,1931.314453125 +29.23,1931.3773193359375 +29.24,1931.441650390625 +29.25,1931.50732421875 +29.26,1931.574462890625 +29.27,1931.6429443359375 +29.28,1931.7127685546875 +29.29,1931.7838134765625 +29.3,1931.8560791015625 +29.31,1931.9295654296875 +29.32,1932.004150390625 +29.33,1932.07763671875 +29.34,1932.15234375 +29.35,1932.2366943359375 +29.36,1932.322021484375 +29.37,1932.3929443359375 +29.38,1932.458251953125 +29.39,1932.5201416015625 +29.4,1932.572265625 +29.41,1932.62548828125 +29.42,1932.533447265625 +29.43,1932.44384765625 +29.44,1932.3568115234375 +29.45,1932.2723388671875 +29.46,1932.1903076171875 +29.47,1932.0933837890625 +29.48,1931.9990234375 +29.49,1931.9072265625 +29.5,1931.791748046875 +29.51,1931.6485595703125 +29.52,1931.4210205078125 +29.53,1931.188720703125 +29.54,1930.9385986328125 +29.55,1930.6839599609375 +29.56,1930.4051513671875 +29.57,1930.09375 +29.58,1929.7523193359375 +29.59,1929.4095458984375 +29.6,1929.0435791015625 +29.61,1928.6788330078125 +29.62,1928.3153076171875 +29.63,1927.944091796875 +29.64,1927.5762939453125 +29.65,1927.214111328125 +29.66,1926.8443603515625 +29.67,1926.480224609375 +29.68,1926.1217041015625 +29.69,1925.768798828125 +29.7,1925.4345703125 +29.71,1925.1055908203125 +29.72,1924.786376953125 +29.73,1924.47900390625 +29.74,1924.1832275390625 +29.75,1923.8924560546875 +29.76,1923.606689453125 +29.77,1923.3280029296875 +29.78,1923.05419921875 +29.79,1922.78515625 +29.8,1922.52099609375 +29.81,1922.246337890625 +29.82,1921.9764404296875 +29.83,1921.71142578125 +29.84,1921.477294921875 +29.85,1921.2498779296875 +29.86,1921.0333251953125 +29.87,1920.8428955078125 +29.88,1920.6588134765625 +29.89,1920.4808349609375 +29.9,1920.306640625 +29.91,1920.1341552734375 +29.92,1919.947998046875 +29.93,1919.765869140625 +29.94,1919.5875244140625 +29.95,1919.4132080078125 +29.96,1919.2381591796875 +29.97,1919.05615234375 +29.98,1918.8629150390625 +29.99,1918.673583984375 +30,1918.4794921875 +30.01,1918.2633056640625 +30.02,1917.9947509765625 +30.03,1917.728759765625 +30.04,1917.4654541015625 +30.05,1917.206787109375 +30.06,1916.9462890625 +30.07,1916.6884765625 +30.08,1916.4049072265625 +30.09,1916.1263427734375 +30.1,1915.8287353515625 +30.11,1915.527587890625 +30.12,1915.2099609375 +30.13,1914.89111328125 +30.14,1914.551513671875 +30.15,1914.206787109375 +30.16,1913.86328125 +30.17,1913.5234375 +30.18,1913.16748046875 +30.19,1912.8131103515625 +30.2,1912.460205078125 +30.21,1912.1131591796875 +30.22,1911.7655029296875 +30.23,1911.38671875 +30.24,1910.9879150390625 +30.25,1910.580322265625 +30.26,1910.17919921875 +30.27,1909.7802734375 +30.28,1909.3812255859375 +30.29,1908.9779052734375 +30.3,1908.56591796875 +30.31,1908.158447265625 +30.32,1907.757568359375 +30.33,1907.3480224609375 +30.34,1906.938720703125 +30.35,1906.5360107421875 +30.36,1906.1400146484375 +30.37,1905.7506103515625 +30.38,1905.3677978515625 +30.39,1904.991455078125 +30.4,1904.62158203125 +30.41,1904.2581787109375 +30.42,1903.90966796875 +30.43,1903.569580078125 +30.44,1903.235595703125 +30.45,1902.90771484375 +30.46,1902.5946044921875 +30.47,1902.2982177734375 +30.48,1902.007568359375 +30.49,1901.7333984375 +30.5,1901.473388671875 +30.51,1901.220947265625 +30.52,1900.984619140625 +30.53,1900.779541015625 +30.54,1900.581298828125 +30.55,1900.39208984375 +30.56,1900.211669921875 +30.57,1900.0445556640625 +30.58,1899.9013671875 +30.59,1899.7730712890625 +30.6,1899.6488037109375 +30.61,1899.5174560546875 +30.62,1899.3878173828125 +30.63,1899.2620849609375 +30.64,1899.1226806640625 +30.65,1899.004638671875 +30.66,1898.890380859375 +30.67,1898.77978515625 +30.68,1898.6728515625 +30.69,1898.5694580078125 +30.7,1898.4696044921875 +30.71,1898.3841552734375 +30.72,1898.3193359375 +30.73,1898.2706298828125 +30.74,1898.229248046875 +30.75,1898.1993408203125 +30.76,1898.2286376953125 +30.77,1898.30126953125 +30.78,1898.37548828125 +30.79,1898.4576416015625 +30.8,1898.541259765625 +30.81,1898.6260986328125 +30.82,1898.7056884765625 +30.83,1898.7518310546875 +30.84,1898.7994384765625 +30.85,1898.724609375 +30.86,1898.6112060546875 +30.87,1898.4749755859375 +30.88,1898.226806640625 +30.89,1897.981201171875 +30.9,1897.71630859375 +30.91,1897.4560546875 +30.92,1897.17236328125 +30.93,1896.871826171875 +30.94,1896.5634765625 +30.95,1896.251708984375 +30.96,1895.9083251953125 +30.97,1895.5662841796875 +30.98,1895.2342529296875 +30.99,1894.8643798828125 +31,1894.4830322265625 +31.01,1894.10791015625 +31.02,1893.7171630859375 +31.03,1893.33251953125 +31.04,1892.9541015625 +31.05,1892.6014404296875 +31.06,1892.2547607421875 +31.07,1891.9139404296875 +31.08,1891.5789794921875 +31.09,1891.2496337890625 +31.1,1890.8956298828125 +31.11,1890.547607421875 +31.12,1890.218505859375 +31.13,1889.8822021484375 +31.14,1889.5516357421875 +31.15,1889.2269287109375 +31.16,1888.8775634765625 +31.17,1888.5321044921875 +31.18,1888.1925048828125 +31.19,1887.861083984375 +31.2,1887.548583984375 +31.21,1887.24169921875 +31.22,1886.9425048828125 +31.23,1886.64892578125 +31.24,1886.363037109375 +31.25,1886.123779296875 +31.26,1885.893798828125 +31.27,1885.7208251953125 +31.28,1885.5521240234375 +31.29,1885.3919677734375 +31.3,1885.272705078125 +31.31,1885.18115234375 +31.32,1885.1060791015625 +31.33,1885.0340576171875 +31.34,1884.96728515625 +31.35,1884.903564453125 +31.36,1884.8515625 +31.37,1884.802490234375 +31.38,1884.784423828125 +31.39,1884.7689208984375 +31.4,1884.792724609375 +31.41,1884.8271484375 +31.42,1884.8809814453125 +31.43,1884.936279296875 +31.44,1884.9931640625 +31.45,1885.084228515625 +31.46,1885.1829833984375 +31.47,1885.2872314453125 +31.48,1885.4598388671875 +31.49,1885.6368408203125 +31.5,1885.8140869140625 +31.51,1885.9913330078125 +31.52,1886.164306640625 +31.53,1886.33740234375 +31.54,1886.468994140625 +31.55,1886.592529296875 +31.56,1886.7012939453125 +31.57,1886.734375 +31.58,1886.6754150390625 +31.59,1886.5535888671875 +31.6,1886.4063720703125 +31.61,1886.2276611328125 +31.62,1886.0523681640625 +31.63,1885.865478515625 +31.64,1885.6690673828125 +31.65,1885.4481201171875 +31.66,1885.226806640625 +31.67,1885.009521484375 +31.68,1884.796142578125 +31.69,1884.58447265625 +31.7,1884.376708984375 +31.71,1884.1727294921875 +31.72,1883.9747314453125 +31.73,1883.7762451171875 +31.74,1883.57275390625 +31.75,1883.3731689453125 +31.76,1883.17724609375 +31.77,1882.9852294921875 +31.78,1882.8011474609375 +31.79,1882.6163330078125 +31.8,1882.4351806640625 +31.81,1882.2576904296875 +31.82,1882.0880126953125 +31.83,1881.92626953125 +31.84,1881.767822265625 +31.85,1881.61279296875 +31.86,1881.4610595703125 +31.87,1881.312744140625 +31.88,1881.1676025390625 +31.89,1881.00390625 +31.9,1880.82421875 +31.91,1880.6480712890625 +31.92,1880.4754638671875 +31.93,1880.30419921875 +31.94,1880.136474609375 +31.95,1879.9613037109375 +31.96,1879.7874755859375 +31.97,1879.6171875 +31.98,1879.4482421875 +31.99,1879.2806396484375 +32,1879.1077880859375 +32.01,1878.9102783203125 +32.02,1878.7056884765625 +32.03,1878.5050048828125 +32.04,1878.30810546875 +32.05,1878.1104736328125 +32.06,1877.921142578125 +32.07,1877.7310791015625 +32.08,1877.544677734375 +32.09,1877.3511962890625 +32.1,1877.1549072265625 +32.11,1876.96240234375 +32.12,1876.773681640625 +32.13,1876.584228515625 +32.14,1876.3985595703125 +32.15,1876.2166748046875 +32.16,1876.042724609375 +32.17,1875.87890625 +32.18,1875.7183837890625 +32.19,1875.5765380859375 +32.2,1875.455322265625 +32.21,1875.337158203125 +32.22,1875.2218017578125 +32.23,1875.105224609375 +32.24,1874.991455078125 +32.25,1874.8524169921875 +32.26,1874.7144775390625 +32.27,1874.562255859375 +32.28,1874.39599609375 +32.29,1874.222412109375 +32.3,1873.9849853515625 +32.31,1873.7322998046875 +32.32,1873.4146728515625 +32.33,1873.099853515625 +32.34,1872.761962890625 +32.35,1872.4119873046875 +32.36,1872.0675048828125 +32.37,1871.7109375 +32.38,1871.3577880859375 +32.39,1870.9906005859375 +32.4,1870.6226806640625 +32.41,1870.2366943359375 +32.42,1869.8521728515625 +32.43,1869.4564208984375 +32.44,1869.066650390625 +32.45,1868.678466796875 +32.46,1868.28759765625 +32.47,1867.8834228515625 +32.48,1867.466064453125 +32.49,1867.0528564453125 +32.5,1866.64599609375 +32.51,1866.2216796875 +32.52,1865.79296875 +32.53,1865.3558349609375 +32.54,1864.91455078125 +32.55,1864.4627685546875 +32.56,1864.0157470703125 +32.57,1863.56689453125 +32.58,1863.1055908203125 +32.59,1862.6490478515625 +32.6,1862.19091796875 +32.61,1861.724853515625 +32.62,1861.2789306640625 +32.63,1860.8096923828125 +32.64,1860.328369140625 +32.65,1859.8524169921875 +32.66,1859.377197265625 +32.67,1858.9051513671875 +32.68,1858.4296875 +32.69,1857.9422607421875 +32.7,1857.46240234375 +32.71,1856.9771728515625 +32.72,1856.4822998046875 +32.73,1855.9952392578125 +32.74,1855.515869140625 +32.75,1855.044189453125 +32.76,1854.5802001953125 +32.77,1854.1236572265625 +32.78,1853.6746826171875 +32.79,1853.222412109375 +32.8,1852.755859375 +32.81,1852.288330078125 +32.82,1851.8284912109375 +32.83,1851.36767578125 +32.84,1850.9144287109375 +32.85,1850.4688720703125 +32.86,1850.02001953125 +32.87,1849.59814453125 +32.88,1849.1641845703125 +32.89,1848.7291259765625 +32.9,1848.297119140625 +32.91,1847.8660888671875 +32.92,1847.4383544921875 +32.93,1847.01806640625 +32.94,1846.6051025390625 +32.95,1846.1995849609375 +32.96,1845.8011474609375 +32.97,1845.4100341796875 +32.98,1845.0260009765625 +32.99,1844.644775390625 +33,1844.2705078125 +33.01,1843.9031982421875 +33.02,1843.5472412109375 +33.03,1843.1981201171875 +33.04,1842.851318359375 +33.05,1842.5113525390625 +33.06,1842.177978515625 +33.07,1841.851318359375 +33.08,1841.5289306640625 +33.09,1841.213134765625 +33.1,1840.9058837890625 +33.11,1840.60498046875 +33.12,1840.3101806640625 +33.13,1840.0345458984375 +33.14,1839.7757568359375 +33.15,1839.5682373046875 +33.16,1839.3702392578125 +33.17,1839.1773681640625 +33.18,1838.9984130859375 +33.19,1838.8243408203125 +33.2,1838.663818359375 +33.21,1838.5101318359375 +33.22,1838.361083984375 +33.23,1838.2166748046875 +33.24,1838.0767822265625 +33.25,1837.9412841796875 +33.26,1837.8101806640625 +33.27,1837.687744140625 +33.28,1837.569580078125 +33.29,1837.4556884765625 +33.3,1837.345703125 +33.31,1837.2398681640625 +33.32,1837.1336669921875 +33.33,1837.0010986328125 +33.34,1836.868408203125 +33.35,1836.7401123046875 +33.36,1836.6158447265625 +33.37,1836.4957275390625 +33.38,1836.3558349609375 +33.39,1836.2093505859375 +33.4,1836.0670166015625 +33.41,1835.9268798828125 +33.42,1835.7562255859375 +33.43,1835.5771484375 +33.44,1835.3939208984375 +33.45,1835.0987548828125 +33.46,1834.79638671875 +33.47,1834.4718017578125 +33.48,1834.140380859375 +33.49,1833.810791015625 +33.5,1833.4420166015625 +33.51,1833.0667724609375 +33.52,1832.691650390625 +33.53,1832.3232421875 +33.54,1831.959228515625 +33.55,1831.586669921875 +33.56,1831.20361328125 +33.57,1830.8272705078125 +33.58,1830.45751953125 +33.59,1830.094482421875 +33.6,1829.75537109375 +33.61,1829.4332275390625 +33.62,1829.1173095703125 +33.63,1828.818359375 +33.64,1828.5404052734375 +33.65,1828.2681884765625 +33.66,1828.001708984375 +33.67,1827.7408447265625 +33.68,1827.4854736328125 +33.69,1827.2442626953125 +33.7,1827.010498046875 +33.71,1826.786376953125 +33.72,1826.56298828125 +33.73,1826.3447265625 +33.74,1826.1358642578125 +33.75,1825.95361328125 +33.76,1825.7803955078125 +33.77,1825.63330078125 +33.78,1825.58984375 +33.79,1825.560302734375 +33.8,1825.533935546875 +33.81,1825.5299072265625 +33.82,1825.528564453125 +33.83,1825.6097412109375 +33.84,1825.7078857421875 +33.85,1825.8076171875 +33.86,1825.8936767578125 +33.87,1825.9793701171875 +33.88,1826.0450439453125 +33.89,1826.1124267578125 +33.9,1826.162109375 +33.91,1826.213623046875 +33.92,1826.2215576171875 +33.93,1826.1583251953125 +33.94,1826.080810546875 +33.95,1826.0020751953125 +33.96,1825.926513671875 +33.97,1825.849609375 +33.98,1825.76708984375 +33.99,1825.6876220703125 +34,1825.591796875 +34.01,1825.48828125 +34.02,1825.38818359375 +34.03,1825.2523193359375 +34.04,1825.1180419921875 +34.05,1824.9677734375 +34.06,1824.8211669921875 +34.07,1824.669677734375 +34.08,1824.52197265625 +34.09,1824.371337890625 +34.1,1824.2158203125 +34.11,1824.06396484375 +34.12,1823.91162109375 +34.13,1823.7628173828125 +34.14,1823.61767578125 +34.15,1823.4718017578125 +34.16,1823.32958984375 +34.17,1823.1822509765625 +34.18,1823.03857421875 +34.19,1822.8790283203125 +34.2,1822.7188720703125 +34.21,1822.55810546875 +34.22,1822.3902587890625 +34.23,1822.2283935546875 +34.24,1822.0701904296875 +34.25,1821.915771484375 +34.26,1821.764892578125 +34.27,1821.61767578125 +34.28,1821.4718017578125 +34.29,1821.33154296875 +34.3,1821.197021484375 +34.31,1821.074462890625 +34.32,1820.955322265625 +34.33,1820.8306884765625 +34.34,1820.7093505859375 +34.35,1820.5911865234375 +34.36,1820.4739990234375 +34.37,1820.360107421875 +34.38,1820.238525390625 +34.39,1820.070556640625 +34.4,1819.9063720703125 +34.41,1819.7457275390625 +34.42,1819.586669921875 +34.43,1819.424560546875 +34.44,1819.26611328125 +34.45,1819.10693359375 +34.46,1818.9512939453125 +34.47,1818.792724609375 +34.48,1818.534423828125 +34.49,1818.2548828125 +34.5,1817.9564208984375 +34.51,1817.654296875 +34.52,1817.33154296875 +34.53,1817.0054931640625 +34.54,1816.680419921875 +34.55,1816.3564453125 +34.56,1816.03564453125 +34.57,1815.7008056640625 +34.58,1815.3671875 +34.59,1815.0347900390625 +34.6,1814.712158203125 +34.61,1814.3907470703125 +34.62,1814.0638427734375 +34.63,1813.7425537109375 +34.64,1813.4266357421875 +34.65,1813.1312255859375 +34.66,1812.847412109375 +34.67,1812.5687255859375 +34.68,1812.3079833984375 +34.69,1812.0521240234375 +34.7,1811.80322265625 +34.71,1811.55908203125 +34.72,1811.319580078125 +34.73,1811.10205078125 +34.74,1810.888916015625 +34.75,1810.68017578125 +34.76,1810.475830078125 +34.77,1810.275634765625 +34.78,1810.0926513671875 +34.79,1809.913818359375 +34.8,1809.7584228515625 +34.81,1809.58740234375 +34.82,1809.439697265625 +34.83,1809.2869873046875 +34.84,1809.18115234375 +34.85,1809.0784912109375 +34.86,1809.0091552734375 +34.87,1808.91259765625 +34.88,1808.819091796875 +34.89,1808.7286376953125 +34.9,1808.66064453125 +34.91,1808.595458984375 +34.92,1808.532958984375 +34.93,1808.47314453125 +34.94,1808.416015625 +34.95,1808.3333740234375 +34.96,1808.2816162109375 +34.97,1808.2581787109375 +34.98,1808.211181640625 +34.99,1808.1795654296875 +35,1808.150146484375 +35.01,1808.1229248046875 +35.02,1808.097900390625 +35.03,1808.0103759765625 +35.04,1807.9256591796875 +35.05,1807.837158203125 +35.06,1807.723388671875 +35.07,1807.610595703125 +35.08,1807.4749755859375 +35.09,1807.342529296875 +35.1,1807.1917724609375 +35.11,1807.0379638671875 +35.12,1806.8875732421875 +35.13,1806.7406005859375 +35.14,1806.571044921875 +35.15,1806.40087890625 +35.16,1806.234130859375 +35.17,1806.0753173828125 +35.18,1805.919921875 +35.19,1805.763671875 +35.2,1805.61083984375 +35.21,1805.4420166015625 +35.22,1805.26806640625 +35.23,1805.09765625 +35.24,1804.9222412109375 +35.25,1804.75048828125 +35.26,1804.5758056640625 +35.27,1804.40478515625 +35.28,1804.24365234375 +35.29,1804.0797119140625 +35.3,1803.92138671875 +35.31,1803.7642822265625 +35.32,1803.599853515625 +35.33,1803.4517822265625 +35.34,1803.3070068359375 +35.35,1803.1656494140625 +35.36,1803.0059814453125 +35.37,1802.8497314453125 +35.38,1802.6947021484375 +35.39,1802.5194091796875 +35.4,1802.339111328125 +35.41,1802.1710205078125 +35.42,1801.987060546875 +35.43,1801.79833984375 +35.44,1801.621826171875 +35.45,1801.4295654296875 +35.46,1801.232421875 +35.47,1801.0262451171875 +35.48,1800.8326416015625 +35.49,1800.642822265625 +35.5,1800.4482421875 +35.51,1800.25732421875 +35.52,1800.0701904296875 +35.53,1799.8739013671875 +35.54,1799.67724609375 +35.55,1799.484375 +35.56,1799.2930908203125 +35.57,1799.1055908203125 +35.58,1798.9111328125 +35.59,1798.720458984375 +35.6,1798.5335693359375 +35.61,1798.3375244140625 +35.62,1798.145263671875 +35.63,1797.9547119140625 +35.64,1797.765869140625 +35.65,1797.580810546875 +35.66,1797.3822021484375 +35.67,1797.1680908203125 +35.68,1796.9580078125 +35.69,1796.71533203125 +35.7,1796.470703125 +35.71,1796.2281494140625 +35.72,1795.9857177734375 +35.73,1795.728271484375 +35.74,1795.423828125 +35.75,1795.087890625 +35.76,1794.755126953125 +35.77,1794.4171142578125 +35.78,1794.0716552734375 +35.79,1793.7315673828125 +35.8,1793.3841552734375 +35.81,1793.0401611328125 +35.82,1792.6888427734375 +35.83,1792.3173828125 +35.84,1791.9324951171875 +35.85,1791.5020751953125 +35.86,1791.0653076171875 +35.87,1790.6201171875 +35.88,1790.1173095703125 +35.89,1789.6175537109375 +35.9,1789.099365234375 +35.91,1788.57373046875 +35.92,1788.040771484375 +35.93,1787.5113525390625 +35.94,1786.98974609375 +35.95,1786.4737548828125 +35.96,1785.967529296875 +35.97,1785.4561767578125 +35.98,1784.952392578125 +35.99,1784.456298828125 +36,1783.9678955078125 +36.01,1783.487060546875 +36.02,1783.026611328125 +36.03,1782.5733642578125 +36.04,1782.12744140625 +36.05,1781.688720703125 +36.06,1781.29150390625 +36.07,1780.913818359375 +36.08,1780.547119140625 +36.09,1780.1932373046875 +36.1,1779.8477783203125 +36.11,1779.5086669921875 +36.12,1779.1778564453125 +36.13,1778.8682861328125 +36.14,1778.5753173828125 +36.15,1778.313720703125 +36.16,1778.059814453125 +36.17,1777.823974609375 +36.18,1777.5933837890625 +36.19,1777.3677978515625 +36.2,1777.1343994140625 +36.21,1776.9061279296875 +36.22,1776.67431640625 +36.23,1776.443359375 +36.24,1776.1724853515625 +36.25,1775.9027099609375 +36.26,1775.63623046875 +36.27,1775.336669921875 +36.28,1775.0343017578125 +36.29,1774.677734375 +36.3,1774.31689453125 +36.31,1773.9302978515625 +36.32,1773.4947509765625 +36.33,1773.06640625 +36.34,1772.645263671875 +36.35,1772.1905517578125 +36.36,1771.7413330078125 +36.37,1771.299560546875 +36.38,1770.8350830078125 +36.39,1770.371826171875 +36.4,1769.8349609375 +36.41,1769.293701171875 +36.42,1768.7459716796875 +36.43,1768.1939697265625 +36.44,1767.646484375 +36.45,1767.1075439453125 +36.46,1766.5687255859375 +36.47,1766.0384521484375 +36.48,1765.4912109375 +36.49,1764.95263671875 +36.5,1764.4228515625 +36.51,1763.8974609375 +36.52,1763.380615234375 +36.53,1762.8702392578125 +36.54,1762.368408203125 +36.55,1761.8642578125 +36.56,1761.34716796875 +36.57,1760.83447265625 +36.58,1760.3111572265625 +36.59,1759.8157958984375 +36.6,1759.318115234375 +36.61,1758.8289794921875 +36.62,1758.3482666015625 +36.63,1757.8759765625 +36.64,1757.4268798828125 +36.65,1756.9752197265625 +36.66,1756.548828125 +36.67,1756.1302490234375 +36.68,1755.7022705078125 +36.69,1755.2823486328125 +36.7,1754.865966796875 +36.71,1754.4552001953125 +36.72,1754.050048828125 +36.73,1753.652587890625 +36.74,1753.2626953125 +36.75,1752.8675537109375 +36.76,1752.47998046875 +36.77,1752.0892333984375 +36.78,1751.7060546875 +36.79,1751.341064453125 +36.8,1750.985595703125 +36.81,1750.6373291015625 +36.82,1750.2855224609375 +36.83,1749.94091796875 +36.84,1749.6033935546875 +36.85,1749.266357421875 +36.86,1748.9364013671875 +36.87,1748.619873046875 +36.88,1748.2930908203125 +36.89,1747.970947265625 +36.9,1747.65576171875 +36.91,1747.347412109375 +36.92,1747.0435791015625 +36.93,1746.7335205078125 +36.94,1746.43017578125 +36.95,1746.1292724609375 +36.96,1745.8350830078125 +36.97,1745.54736328125 +36.98,1745.2596435546875 +36.99,1744.98486328125 +37,1744.71630859375 +37.01,1744.4561767578125 +37.02,1744.20654296875 +37.03,1743.9630126953125 +37.04,1743.7275390625 +37.05,1743.4979248046875 +37.06,1743.2677001953125 +37.07,1743.043212890625 +37.08,1742.824462890625 +37.09,1742.6177978515625 +37.1,1742.423095703125 +37.11,1742.2337646484375 +37.12,1742.06689453125 +37.13,1741.9306640625 +37.14,1741.8055419921875 +37.15,1741.7064208984375 +37.16,1741.615966796875 +37.17,1741.5252685546875 +37.18,1741.4432373046875 +37.19,1741.3651123046875 +37.2,1741.3294677734375 +37.21,1741.3038330078125 +37.22,1741.2835693359375 +37.23,1741.2667236328125 +37.24,1741.2745361328125 +37.25,1741.2960205078125 +37.26,1741.3203125 +37.27,1741.34716796875 +37.28,1741.3809814453125 +37.29,1741.42578125 +37.3,1741.4752197265625 +37.31,1741.5740966796875 +37.32,1741.6746826171875 +37.33,1741.779052734375 +37.34,1741.88720703125 +37.35,1741.9970703125 +37.36,1742.1082763671875 +37.37,1742.2188720703125 +37.38,1742.330810546875 +37.39,1742.4442138671875 +37.4,1742.558837890625 +37.41,1742.6746826171875 +37.42,1742.791748046875 +37.43,1742.903564453125 +37.44,1743.0164794921875 +37.45,1743.111328125 +37.46,1743.2073974609375 +37.47,1743.2877197265625 +37.48,1743.3607177734375 +37.49,1743.41796875 +37.5,1743.476806640625 +37.51,1743.5263671875 +37.52,1743.575439453125 +37.53,1743.6131591796875 +37.54,1743.590576171875 +37.55,1743.563720703125 +37.56,1743.5028076171875 +37.57,1743.4423828125 +37.58,1743.3780517578125 +37.59,1743.262939453125 +37.6,1743.1488037109375 +37.61,1743.0078125 +37.62,1742.82958984375 +37.63,1742.65087890625 +37.64,1742.452392578125 +37.65,1742.2364501953125 +37.66,1742.022705078125 +37.67,1741.8065185546875 +37.68,1741.5625 +37.69,1741.32080078125 +37.7,1741.08349609375 +37.71,1740.842041015625 +37.72,1740.59228515625 +37.73,1740.3321533203125 +37.74,1740.048828125 +37.75,1739.7490234375 +37.76,1739.4478759765625 +37.77,1739.1412353515625 +37.78,1738.83984375 +37.79,1738.52001953125 +37.8,1738.203369140625 +37.81,1737.8856201171875 +37.82,1737.5604248046875 +37.83,1737.22998046875 +37.84,1736.9071044921875 +37.85,1736.5897216796875 +37.86,1736.271240234375 +37.87,1735.9581298828125 +37.88,1735.618408203125 +37.89,1735.284423828125 +37.9,1734.9559326171875 +37.91,1734.630859375 +37.92,1734.3114013671875 +37.93,1733.990966796875 +37.94,1733.676025390625 +37.95,1733.36669921875 +37.96,1733.062744140625 +37.97,1732.7640380859375 +37.98,1732.470703125 +37.99,1732.1827392578125 +38,1731.91064453125 +38.01,1731.6436767578125 +38.02,1731.3817138671875 +38.03,1731.1246337890625 +38.04,1730.8831787109375 +38.05,1730.6529541015625 +38.06,1730.43798828125 +38.07,1730.2425537109375 +38.08,1730.07275390625 +38.09,1729.9132080078125 +38.1,1729.7618408203125 +38.11,1729.6697998046875 +38.12,1729.5853271484375 +38.13,1729.5167236328125 +38.14,1729.4573974609375 +38.15,1729.4095458984375 +38.16,1729.3857421875 +38.17,1729.3642578125 +38.18,1729.3472900390625 +38.19,1729.334716796875 +38.2,1729.3372802734375 +38.21,1729.3460693359375 +38.22,1729.3824462890625 +38.23,1729.458984375 +38.24,1729.538818359375 +38.25,1729.6368408203125 +38.26,1729.74658203125 +38.27,1729.8719482421875 +38.28,1729.998046875 +38.29,1730.1246337890625 +38.3,1730.251708984375 +38.31,1730.34716796875 +38.32,1730.4327392578125 +38.33,1730.5150146484375 +38.34,1730.5809326171875 +38.35,1730.64794921875 +38.36,1730.7158203125 +38.37,1730.78466796875 +38.38,1730.8543701171875 +38.39,1730.920654296875 +38.4,1730.97509765625 +38.41,1731.0303955078125 +38.42,1731.0823974609375 +38.43,1731.124755859375 +38.44,1731.1573486328125 +38.45,1731.1654052734375 +38.46,1731.159912109375 +38.47,1731.13232421875 +38.48,1731.0958251953125 +38.49,1731.0546875 +38.5,1731.015380859375 +38.51,1730.9052734375 +38.52,1730.757080078125 +38.53,1730.596923828125 +38.54,1730.3992919921875 +38.55,1730.196533203125 +38.56,1729.9716796875 +38.57,1729.7420654296875 +38.58,1729.486328125 +38.59,1729.221923828125 +38.6,1728.9488525390625 +38.61,1728.6737060546875 +38.62,1728.360107421875 +38.63,1728.042724609375 +38.64,1727.7109375 +38.65,1727.360595703125 +38.66,1727.0069580078125 +38.67,1726.658447265625 +38.68,1726.302490234375 +38.69,1725.9454345703125 +38.7,1725.5806884765625 +38.71,1725.2044677734375 +38.72,1724.83154296875 +38.73,1724.423828125 +38.74,1724.0135498046875 +38.75,1723.596435546875 +38.76,1723.185302734375 +38.77,1722.77392578125 +38.78,1722.3621826171875 +38.79,1721.95654296875 +38.8,1721.550537109375 +38.81,1721.1484375 +38.82,1720.73974609375 +38.83,1720.337158203125 +38.84,1719.940673828125 +38.85,1719.55029296875 +38.86,1719.1552734375 +38.87,1718.766357421875 +38.88,1718.40478515625 +38.89,1718.0489501953125 +38.9,1717.698974609375 +38.91,1717.3590087890625 +38.92,1717.0245361328125 +38.93,1716.6956787109375 +38.94,1716.3681640625 +38.95,1716.046142578125 +38.96,1715.73388671875 +38.97,1715.4439697265625 +38.98,1715.161376953125 +38.99,1714.890380859375 +39,1714.6243896484375 +39.01,1714.3568115234375 +39.02,1714.09423828125 +39.03,1713.836669921875 +39.04,1713.5838623046875 +39.05,1713.333740234375 +39.06,1713.084228515625 +39.07,1712.8203125 +39.08,1712.554931640625 +39.09,1712.2882080078125 +39.1,1712.0244140625 +39.11,1711.74853515625 +39.12,1711.4522705078125 +39.13,1711.1378173828125 +39.14,1710.82470703125 +39.15,1710.51708984375 +39.16,1710.2149658203125 +39.17,1709.9202880859375 +39.18,1709.62890625 +39.19,1709.3428955078125 +39.2,1709.0599365234375 +39.21,1708.78662109375 +39.22,1708.51416015625 +39.23,1708.2510986328125 +39.24,1707.9952392578125 +39.25,1707.7591552734375 +39.26,1707.5279541015625 +39.27,1707.3077392578125 +39.28,1707.0921630859375 +39.29,1706.885498046875 +39.3,1706.6854248046875 +39.31,1706.50244140625 +39.32,1706.3343505859375 +39.33,1706.1937255859375 +39.34,1706.061279296875 +39.35,1705.9600830078125 +39.36,1705.8642578125 +39.37,1705.788818359375 +39.38,1705.71630859375 +39.39,1705.6468505859375 +39.4,1705.60595703125 +39.41,1705.578369140625 +39.42,1705.5596923828125 +39.43,1705.5540771484375 +39.44,1705.5782470703125 +39.45,1705.6064453125 +39.46,1705.6407470703125 +39.47,1705.6788330078125 +39.48,1705.729248046875 +39.49,1705.7918701171875 +39.5,1705.8599853515625 +39.51,1705.931640625 +39.52,1706.00439453125 +39.53,1706.078369140625 +39.54,1706.153564453125 +39.55,1706.2276611328125 +39.56,1706.294189453125 +39.57,1706.3427734375 +39.58,1706.36279296875 +39.59,1706.3525390625 +39.6,1706.3206787109375 +39.61,1706.2738037109375 +39.62,1706.195068359375 +39.63,1706.108154296875 +39.64,1706.0133056640625 +39.65,1705.8934326171875 +39.66,1705.7679443359375 +39.67,1705.643310546875 +39.68,1705.51953125 +39.69,1705.3902587890625 +39.7,1705.2574462890625 +39.71,1705.1192626953125 +39.72,1704.9649658203125 +39.73,1704.7947998046875 +39.74,1704.617431640625 +39.75,1704.44140625 +39.76,1704.268798828125 +39.77,1704.1019287109375 +39.78,1703.9361572265625 +39.79,1703.7760009765625 +39.8,1703.6234130859375 +39.81,1703.4761962890625 +39.82,1703.3321533203125 +39.83,1703.2061767578125 +39.84,1703.0831298828125 +39.85,1702.9630126953125 +39.86,1702.8309326171875 +39.87,1702.7166748046875 +39.88,1702.59033203125 +39.89,1702.467041015625 +39.9,1702.338134765625 +39.91,1702.2120361328125 +39.92,1702.0740966796875 +39.93,1701.9541015625 +39.94,1701.8369140625 +39.95,1701.7225341796875 +39.96,1701.6109619140625 +39.97,1701.4979248046875 +39.98,1701.391845703125 +39.99,1701.2884521484375 +40,1701.1878662109375 +40.01,1701.0941162109375 +40.02,1701.0029296875 +40.03,1700.914306640625 +40.04,1700.8216552734375 +40.05,1700.7293701171875 +40.06,1700.6396484375 +40.07,1700.5523681640625 +40.08,1700.467529296875 +40.09,1700.3873291015625 +40.1,1700.3095703125 +40.11,1700.23193359375 +40.12,1700.1566162109375 +40.13,1700.1090087890625 +40.14,1700.0633544921875 +40.15,1700.019775390625 +40.16,1699.980224609375 +40.17,1699.9425048828125 +40.18,1699.9044189453125 +40.19,1699.8619384765625 +40.2,1699.8212890625 +40.21,1699.77392578125 +40.22,1699.728515625 +40.23,1699.6849365234375 +40.24,1699.6431884765625 +40.25,1699.5777587890625 +40.26,1699.5037841796875 +40.27,1699.431884765625 +40.28,1699.3428955078125 +40.29,1699.254150390625 +40.3,1699.1676025390625 +40.31,1699.083251953125 +40.32,1699.0010986328125 +40.33,1698.9190673828125 +40.34,1698.82861328125 +40.35,1698.687255859375 +40.36,1698.52734375 +40.37,1698.3641357421875 +40.38,1698.2103271484375 +40.39,1698.052978515625 +40.4,1697.898681640625 +40.41,1697.747314453125 +40.42,1697.598876953125 +40.43,1697.4595947265625 +40.44,1697.3338623046875 +40.45,1697.229736328125 +40.46,1697.13232421875 +40.47,1697.032958984375 +40.48,1696.93603515625 +40.49,1696.837158203125 +40.5,1696.744873046875 +40.51,1696.6590576171875 +40.52,1696.575439453125 +40.53,1696.509033203125 +40.54,1696.463623046875 +40.55,1696.4412841796875 +40.56,1696.4332275390625 +40.57,1696.426513671875 +40.58,1696.42333984375 +40.59,1696.42138671875 +40.6,1696.420654296875 +40.61,1696.4212646484375 +40.62,1696.429443359375 +40.63,1696.4578857421875 +40.64,1696.4935302734375 +40.65,1696.542724609375 +40.66,1696.5926513671875 +40.67,1696.64306640625 +40.68,1696.7047119140625 +40.69,1696.75634765625 +40.7,1696.8084716796875 +40.71,1696.8612060546875 +40.72,1696.91015625 +40.73,1696.9595947265625 +40.74,1697.009521484375 +40.75,1697.0599365234375 +40.76,1697.1002197265625 +40.77,1697.1409912109375 +40.78,1697.16748046875 +40.79,1697.188232421875 +40.8,1697.2054443359375 +40.81,1697.223388671875 +40.82,1697.216552734375 +40.83,1697.2022705078125 +40.84,1697.15283203125 +40.85,1697.098388671875 +40.86,1697.0284423828125 +40.87,1696.945068359375 +40.88,1696.8294677734375 +40.89,1696.716064453125 +40.9,1696.604736328125 +40.91,1696.495361328125 +40.92,1696.422119140625 +40.93,1696.3568115234375 +40.94,1696.2994384765625 +40.95,1696.2562255859375 +40.96,1696.2144775390625 +40.97,1696.1739501953125 +40.98,1696.1474609375 +40.99,1696.136962890625 +41,1696.12744140625 +41.01,1696.106201171875 +41.02,1696.0838623046875 +41.03,1696.0626220703125 +41.04,1696.0360107421875 +41.05,1696.010498046875 +41.06,1695.9095458984375 +41.07,1695.7978515625 +41.08,1695.6859130859375 +41.09,1695.5546875 +41.1,1695.40869140625 +41.11,1695.2650146484375 +41.12,1695.11083984375 +41.13,1694.9591064453125 +41.14,1694.784423828125 +41.15,1694.5867919921875 +41.16,1694.3773193359375 +41.17,1694.158203125 +41.18,1693.9359130859375 +41.19,1693.7061767578125 +41.2,1693.4288330078125 +41.21,1693.1446533203125 +41.22,1692.8155517578125 +41.23,1692.467529296875 +41.24,1692.064697265625 +41.25,1691.6629638671875 +41.26,1691.179443359375 +41.27,1690.7020263671875 +41.28,1690.2159423828125 +41.29,1689.7232666015625 +41.3,1689.2369384765625 +41.31,1688.7569580078125 +41.32,1688.2684326171875 +41.33,1687.786376953125 +41.34,1687.3106689453125 +41.35,1686.8370361328125 +41.36,1686.3697509765625 +41.37,1685.90869140625 +41.38,1685.4390869140625 +41.39,1684.9759521484375 +41.4,1684.5191650390625 +41.41,1684.0687255859375 +41.42,1683.6246337890625 +41.43,1683.1868896484375 +41.44,1682.7469482421875 +41.45,1682.3154296875 +41.46,1681.913330078125 +41.47,1681.5194091796875 +41.48,1681.1292724609375 +41.49,1680.736572265625 +41.5,1680.3582763671875 +41.51,1679.9771728515625 +41.52,1679.6019287109375 +41.53,1679.240966796875 +41.54,1678.9090576171875 +41.55,1678.58251953125 +41.56,1678.26123046875 +41.57,1677.966552734375 +41.58,1677.696044921875 +41.59,1677.481201171875 +41.6,1677.2706298828125 +41.61,1677.06640625 +41.62,1676.8663330078125 +41.63,1676.6702880859375 +41.64,1676.4825439453125 +41.65,1676.2987060546875 +41.66,1676.118896484375 +41.67,1675.9620361328125 +41.68,1675.7685546875 +41.69,1675.5791015625 +41.7,1675.3892822265625 +41.71,1675.2012939453125 +41.72,1675.0003662109375 +41.73,1674.803466796875 +41.74,1674.58935546875 +41.75,1674.3690185546875 +41.76,1674.1444091796875 +41.77,1673.9136962890625 +41.78,1673.6873779296875 +41.79,1673.46533203125 +41.8,1673.2454833984375 +41.81,1673.0299072265625 +41.82,1672.8165283203125 +41.83,1672.579833984375 +41.84,1672.332763671875 +41.85,1672.031005859375 +41.86,1671.7322998046875 +41.87,1671.4281005859375 +41.88,1671.1270751953125 +41.89,1670.7781982421875 +41.9,1670.392822265625 +41.91,1669.9775390625 +41.92,1669.5093994140625 +41.93,1669.0355224609375 +41.94,1668.5518798828125 +41.95,1668.0670166015625 +41.96,1667.544921875 +41.97,1666.986083984375 +41.98,1666.412109375 +41.99,1665.833740234375 +42,1665.2403564453125 +42.01,1664.638671875 +42.02,1664.0435791015625 +42.03,1663.4423828125 +42.04,1662.830810546875 +42.05,1662.2239990234375 +42.06,1661.6259765625 +42.07,1661.036865234375 +42.08,1660.45654296875 +42.09,1659.8848876953125 +42.1,1659.3260498046875 +42.11,1658.77587890625 +42.12,1658.2447509765625 +42.13,1657.7283935546875 +42.14,1657.29833984375 +42.15,1656.888427734375 +42.16,1656.504638671875 +42.17,1656.1297607421875 +42.18,1655.76171875 +42.19,1655.4046630859375 +42.2,1655.0584716796875 +42.21,1654.7421875 +42.22,1654.4404296875 +42.23,1654.1680908203125 +42.24,1653.9267578125 +42.25,1653.6951904296875 +42.26,1653.5091552734375 +42.27,1653.3470458984375 +42.28,1653.189453125 +42.29,1653.042724609375 +42.3,1652.9111328125 +42.31,1652.7879638671875 +42.32,1652.6689453125 +42.33,1652.5582275390625 +42.34,1652.4683837890625 +42.35,1652.409912109375 +42.36,1652.3590087890625 +42.37,1652.3114013671875 +42.38,1652.26708984375 +42.39,1652.2216796875 +42.4,1652.1793212890625 +42.41,1652.14013671875 +42.42,1652.0765380859375 +42.43,1651.9569091796875 +42.44,1651.8326416015625 +42.45,1651.6995849609375 +42.46,1651.5704345703125 +42.47,1651.443115234375 +42.48,1651.3197021484375 +42.49,1651.1705322265625 +42.5,1651.025390625 +42.51,1650.88427734375 +42.52,1650.717529296875 +42.53,1650.5465087890625 +42.54,1650.379638671875 +42.55,1650.2086181640625 +42.56,1650.03759765625 +42.57,1649.8602294921875 +42.58,1649.6744384765625 +42.59,1649.4571533203125 +42.6,1649.229736328125 +42.61,1648.9310302734375 +42.62,1648.6146240234375 +42.63,1648.2615966796875 +42.64,1647.9000244140625 +42.65,1647.5361328125 +42.66,1647.1236572265625 +42.67,1646.698974609375 +42.68,1646.255859375 +42.69,1645.80712890625 +42.7,1645.3656005859375 +42.71,1644.9080810546875 +42.72,1644.4537353515625 +42.73,1644.0067138671875 +42.74,1643.56689453125 +42.75,1643.138427734375 +42.76,1642.7403564453125 +42.77,1642.34912109375 +42.78,1641.9688720703125 +42.79,1641.597412109375 +42.8,1641.26416015625 +42.81,1640.9561767578125 +42.82,1640.681640625 +42.83,1640.4400634765625 +42.84,1640.22705078125 +42.85,1640.044189453125 +42.86,1639.8681640625 +42.87,1639.69677734375 +42.88,1639.5426025390625 +42.89,1639.394775390625 +42.9,1639.26806640625 +42.91,1639.196044921875 +42.92,1639.12744140625 +42.93,1639.068603515625 +42.94,1639.012939453125 +42.95,1638.96044921875 +42.96,1638.9112548828125 +42.97,1638.8524169921875 +42.98,1638.77978515625 +42.99,1638.6934814453125 +43,1638.6085205078125 +43.01,1638.522705078125 +43.02,1638.425537109375 +43.03,1638.325439453125 +43.04,1638.1654052734375 +43.05,1637.99462890625 +43.06,1637.8111572265625 +43.07,1637.632080078125 +43.08,1637.455078125 +43.09,1637.2506103515625 +43.1,1637.0506591796875 +43.11,1636.8341064453125 +43.12,1636.6094970703125 +43.13,1636.3917236328125 +43.14,1636.1702880859375 +43.15,1635.9534912109375 +43.16,1635.7413330078125 +43.17,1635.53369140625 +43.18,1635.33056640625 +43.19,1635.1319580078125 +43.2,1634.9378662109375 +43.21,1634.748046875 +43.22,1634.5625 +43.23,1634.38134765625 +43.24,1634.2044677734375 +43.25,1634.03173828125 +43.26,1633.8631591796875 +43.27,1633.696533203125 +43.28,1633.527587890625 +43.29,1633.333251953125 +43.3,1633.1727294921875 +43.31,1633.0162353515625 +43.32,1632.8341064453125 +43.33,1632.630859375 +43.34,1632.429931640625 +43.35,1632.2333984375 +43.36,1632.043212890625 +43.37,1631.8572998046875 +43.38,1631.675537109375 +43.39,1631.498046875 +43.4,1631.32470703125 +43.41,1631.170166015625 +43.42,1631.0279541015625 +43.43,1630.889404296875 +43.44,1630.7545166015625 +43.45,1630.6148681640625 +43.46,1630.45556640625 +43.47,1630.3001708984375 +43.48,1630.1466064453125 +43.49,1629.9862060546875 +43.5,1629.8255615234375 +43.51,1629.660400390625 +43.52,1629.4991455078125 +43.53,1629.3416748046875 +43.54,1629.18798828125 +43.55,1629.0380859375 +43.56,1628.90673828125 +43.57,1628.808349609375 +43.58,1628.713134765625 +43.59,1628.62109375 +43.6,1628.5322265625 +43.61,1628.4462890625 +43.62,1628.3634033203125 +43.63,1628.2686767578125 +43.64,1628.177001953125 +43.65,1628.0714111328125 +43.66,1627.9627685546875 +43.67,1627.8634033203125 +43.68,1627.7608642578125 +43.69,1627.6614990234375 +43.7,1627.5462646484375 +43.71,1627.4256591796875 +43.72,1627.308349609375 +43.73,1627.1837158203125 +43.74,1627.062255859375 +43.75,1626.93994140625 +43.76,1626.8145751953125 +43.77,1626.6923828125 +43.78,1626.5458984375 +43.79,1626.3966064453125 +43.8,1626.240234375 +43.81,1626.0831298828125 +43.82,1625.9296875 +43.83,1625.77978515625 +43.84,1625.63330078125 +43.85,1625.4903564453125 +43.86,1625.350830078125 +43.87,1625.214599609375 +43.88,1625.0816650390625 +43.89,1624.9520263671875 +43.9,1624.8255615234375 +43.91,1624.7022705078125 +43.92,1624.5675048828125 +43.93,1624.4190673828125 +43.94,1624.267822265625 +43.95,1624.0968017578125 +43.96,1623.929443359375 +43.97,1623.761474609375 +43.98,1623.5927734375 +43.99,1623.4298095703125 +44,1623.2662353515625 +44.01,1623.0618896484375 +44.02,1622.8931884765625 +44.03,1622.6964111328125 +44.04,1622.505615234375 +44.05,1622.3187255859375 +44.06,1622.1461181640625 +44.07,1621.9666748046875 +44.08,1621.7908935546875 +44.09,1621.618896484375 +44.1,1621.4505615234375 +44.11,1621.2646484375 +44.12,1621.103515625 +44.13,1620.9417724609375 +44.14,1620.7835693359375 +44.15,1620.62255859375 +44.16,1620.465087890625 +44.17,1620.300537109375 +44.18,1620.1395263671875 +44.19,1619.9925537109375 +44.2,1619.855224609375 +44.21,1619.72119140625 +44.22,1619.592529296875 +44.23,1619.4669189453125 +44.24,1619.4140625 +44.25,1619.363525390625 +44.26,1619.3258056640625 +44.27,1619.2904052734375 +44.28,1619.257080078125 +44.29,1619.2196044921875 +44.3,1619.1842041015625 +44.31,1619.1234130859375 +44.32,1619.0438232421875 +44.33,1618.9521484375 +44.34,1618.863037109375 +44.35,1618.7554931640625 +44.36,1618.648681640625 +44.37,1618.521484375 +44.38,1618.390869140625 +44.39,1618.26318359375 +44.4,1618.0943603515625 +44.41,1617.901611328125 +44.42,1617.6661376953125 +44.43,1617.41796875 +44.44,1617.1529541015625 +44.45,1616.869140625 +44.46,1616.52685546875 +44.47,1616.1834716796875 +44.48,1615.7822265625 +44.49,1615.3197021484375 +44.5,1614.827880859375 +44.51,1614.2568359375 +44.52,1613.6600341796875 +44.53,1613.0565185546875 +44.54,1612.4464111328125 +44.55,1611.8446044921875 +44.56,1611.238525390625 +44.57,1610.640869140625 +44.58,1610.059814453125 +44.59,1609.48681640625 +44.6,1608.92626953125 +44.61,1608.3778076171875 +44.62,1607.83935546875 +44.63,1607.3782958984375 +44.64,1606.941162109375 +44.65,1606.5108642578125 +44.66,1606.091552734375 +44.67,1605.7147216796875 +44.68,1605.34423828125 +44.69,1604.990478515625 +44.7,1604.663818359375 +44.71,1604.357666015625 +44.72,1604.0782470703125 +44.73,1603.850341796875 +44.74,1603.62744140625 +44.75,1603.4091796875 +44.76,1603.1956787109375 +44.77,1602.98681640625 +44.78,1602.7825927734375 +44.79,1602.5828857421875 +44.8,1602.377197265625 +44.81,1602.176025390625 +44.82,1601.970947265625 +44.83,1601.7705078125 +44.84,1601.530517578125 +44.85,1601.2847900390625 +44.86,1601.0440673828125 +44.87,1600.7977294921875 +44.88,1600.529052734375 +44.89,1600.25927734375 +44.9,1599.975830078125 +44.91,1599.6788330078125 +44.92,1599.3516845703125 +44.93,1599.0072021484375 +44.94,1598.658203125 +44.95,1598.300537109375 +44.96,1597.93017578125 +44.97,1597.541015625 +44.98,1597.15625 +44.99,1596.7591552734375 +45,1596.337158203125 +45.01,1595.9114990234375 +45.02,1595.48876953125 +45.03,1595.0372314453125 +45.04,1594.58251953125 +45.05,1594.13525390625 +45.06,1593.68896484375 +45.07,1593.2247314453125 +45.08,1592.793212890625 +45.09,1592.368896484375 +45.1,1591.95166015625 +45.11,1591.5435791015625 +45.12,1591.146728515625 +45.13,1590.762939453125 +45.14,1590.413330078125 +45.15,1590.08056640625 +45.16,1589.8065185546875 +45.17,1589.5465087890625 +45.18,1589.294189453125 +45.19,1589.0662841796875 +45.2,1588.849853515625 +45.21,1588.669921875 +45.22,1588.5135498046875 +45.23,1588.363525390625 +45.24,1588.2178955078125 +45.25,1588.1416015625 +45.26,1588.083740234375 +45.27,1588.041748046875 +45.28,1588.0469970703125 +45.29,1588.0548095703125 +45.3,1588.065185546875 +45.31,1588.078125 +45.32,1588.068359375 +45.33,1588.0338134765625 +45.34,1587.998046875 +45.35,1587.9588623046875 +45.36,1587.9014892578125 +45.37,1587.8472900390625 +45.38,1587.7939453125 +45.39,1587.7435302734375 +45.4,1587.696044921875 +45.41,1587.6514892578125 +45.42,1587.61181640625 +45.43,1587.5748291015625 +45.44,1587.5322265625 +45.45,1587.4923095703125 +45.46,1587.436279296875 +45.47,1587.383056640625 +45.48,1587.3138427734375 +45.49,1587.176025390625 +45.5,1587.0040283203125 +45.51,1586.817138671875 +45.52,1586.6324462890625 +45.53,1586.38037109375 +45.54,1586.1226806640625 +45.55,1585.813232421875 +45.56,1585.4671630859375 +45.57,1585.0828857421875 +45.58,1584.662841796875 +45.59,1584.2054443359375 +45.6,1583.7152099609375 +45.61,1583.22607421875 +45.62,1582.738037109375 +45.63,1582.2552490234375 +45.64,1581.7044677734375 +45.65,1581.1513671875 +45.66,1580.5960693359375 +45.67,1580.036376953125 +45.68,1579.4827880859375 +45.69,1578.933349609375 +45.7,1578.3922119140625 +45.71,1577.859375 +45.72,1577.334716796875 +45.73,1576.81591796875 +45.74,1576.30517578125 +45.75,1575.8026123046875 +45.76,1575.31005859375 +45.77,1574.8316650390625 +45.78,1574.361083984375 +45.79,1573.898193359375 +45.8,1573.44287109375 +45.81,1573.0118408203125 +45.82,1572.5882568359375 +45.83,1572.1844482421875 +45.84,1571.7939453125 +45.85,1571.4105224609375 +45.86,1571.040283203125 +45.87,1570.69140625 +45.88,1570.34912109375 +45.89,1570.013427734375 +45.9,1569.6842041015625 +45.91,1569.365478515625 +45.92,1569.0738525390625 +45.93,1568.7945556640625 +45.94,1568.5421142578125 +45.95,1568.2744140625 +45.96,1568.033447265625 +45.97,1567.7978515625 +45.98,1567.567626953125 +45.99,1567.35107421875 +46,1567.1397705078125 +46.01,1566.93359375 +46.02,1566.732421875 +46.03,1566.5635986328125 +46.04,1566.43505859375 +46.05,1566.3106689453125 +46.06,1566.2052001953125 +46.07,1566.1141357421875 +46.08,1566.052001953125 +46.09,1566.0247802734375 +46.1,1566.051025390625 +46.11,1566.0902099609375 +46.12,1566.1317138671875 +46.13,1566.175537109375 +46.14,1566.2215576171875 +46.15,1566.269775390625 +46.16,1566.284423828125 +46.17,1566.223876953125 +46.18,1566.15185546875 +46.19,1566.0244140625 +46.2,1565.8106689453125 +46.21,1565.513671875 +46.22,1565.128173828125 +46.23,1564.7093505859375 +46.24,1564.278564453125 +46.25,1563.854736328125 +46.26,1563.4378662109375 +46.27,1563.0279541015625 +46.28,1562.6248779296875 +46.29,1562.2996826171875 +46.3,1562.0035400390625 +46.31,1561.8240966796875 +46.32,1561.68896484375 +46.33,1561.56201171875 +46.34,1561.5081787109375 +46.35,1561.489013671875 +46.36,1561.4725341796875 +46.37,1561.4713134765625 +46.38,1561.483154296875 +46.39,1561.5078125 +46.4,1561.5472412109375 +46.41,1561.5887451171875 +46.42,1561.6197509765625 +46.43,1561.6527099609375 +46.44,1561.687744140625 +46.45,1561.7247314453125 +46.46,1561.763671875 +46.47,1561.804443359375 +46.48,1561.8406982421875 +46.49,1561.8746337890625 +46.5,1561.91455078125 +46.51,1561.9561767578125 +46.52,1561.9996337890625 +46.53,1562.044677734375 +46.54,1562.0977783203125 +46.55,1562.1522216796875 +46.56,1562.2103271484375 +46.57,1562.2740478515625 +46.58,1562.339111328125 +46.59,1562.401123046875 +46.6,1562.4644775390625 +46.61,1562.529052734375 +46.62,1562.5948486328125 +46.63,1562.6533203125 +46.64,1562.7130126953125 +46.65,1562.7655029296875 +46.66,1562.806640625 +46.67,1562.84912109375 +46.68,1562.848876953125 +46.69,1562.8482666015625 +46.7,1562.8367919921875 +46.71,1562.8082275390625 +46.72,1562.781494140625 +46.73,1562.7568359375 +46.74,1562.7171630859375 +46.75,1562.6522216796875 +46.76,1562.574951171875 +46.77,1562.4896240234375 +46.78,1562.4068603515625 +46.79,1562.3201904296875 +46.8,1562.231689453125 +46.81,1562.1185302734375 +46.82,1562.008056640625 +46.83,1561.83544921875 +46.84,1561.6556396484375 +46.85,1561.472900390625 +46.86,1561.293701171875 +46.87,1561.1177978515625 +46.88,1560.9410400390625 +46.89,1560.7635498046875 +46.9,1560.5726318359375 +46.91,1560.379150390625 +46.92,1560.1807861328125 +46.93,1559.986083984375 +46.94,1559.755126953125 +46.95,1559.515625 +46.96,1559.265625 +46.97,1559.015625 +46.98,1558.732177734375 +46.99,1558.448974609375 +47,1558.17041015625 +47.01,1557.8922119140625 +47.02,1557.6185302734375 +47.03,1557.34716796875 +47.04,1557.080322265625 +47.05,1556.8138427734375 +47.06,1556.5517578125 +47.07,1556.2940673828125 +47.08,1556.0450439453125 +47.09,1555.80029296875 +47.1,1555.5556640625 +47.11,1555.336181640625 +47.12,1555.1248779296875 +47.13,1554.9176025390625 +47.14,1554.71630859375 +47.15,1554.5189208984375 +47.16,1554.3358154296875 +47.17,1554.1563720703125 +47.18,1553.98681640625 +47.19,1553.8876953125 +47.2,1553.7913818359375 +47.21,1553.6978759765625 +47.22,1553.6070556640625 +47.23,1553.5189208984375 +47.24,1553.3623046875 +47.25,1553.2027587890625 +47.26,1553.0382080078125 +47.27,1552.824951171875 +47.28,1552.607177734375 +47.29,1552.3787841796875 +47.3,1552.1273193359375 +47.31,1551.8739013671875 +47.32,1551.616455078125 +47.33,1551.35302734375 +47.34,1551.071044921875 +47.35,1550.7750244140625 +47.36,1550.473388671875 +47.37,1550.166259765625 +47.38,1549.8369140625 +47.39,1549.5086669921875 +47.4,1549.1856689453125 +47.41,1548.83447265625 +47.42,1548.4866943359375 +47.43,1548.1297607421875 +47.44,1547.7659912109375 +47.45,1547.4058837890625 +47.46,1547.0513916015625 +47.47,1546.700439453125 +47.48,1546.33203125 +47.49,1545.9591064453125 +47.5,1545.587890625 +47.51,1545.2225341796875 +47.52,1544.863037109375 +47.53,1544.5093994140625 +47.54,1544.1363525390625 +47.55,1543.7587890625 +47.56,1543.3873291015625 +47.57,1542.996826171875 +47.58,1542.6063232421875 +47.59,1542.2158203125 +47.6,1541.83154296875 +47.61,1541.4471435546875 +47.62,1541.0667724609375 +47.63,1540.6781005859375 +47.64,1540.287353515625 +47.65,1539.8883056640625 +47.66,1539.4957275390625 +47.67,1539.109619140625 +47.68,1538.71728515625 +47.69,1538.3314208984375 +47.7,1537.951904296875 +47.71,1537.59326171875 +47.72,1537.2408447265625 +47.73,1536.896484375 +47.74,1536.57275390625 +47.75,1536.259033203125 +47.76,1535.950927734375 +47.77,1535.658935546875 +47.78,1535.3848876953125 +47.79,1535.1163330078125 +47.8,1534.8529052734375 +47.81,1534.5947265625 +47.82,1534.3416748046875 +47.83,1534.0811767578125 +47.84,1533.8133544921875 +47.85,1533.55078125 +47.86,1533.287109375 +47.87,1533.0286865234375 +47.88,1532.7733154296875 +47.89,1532.5230712890625 +47.9,1532.27587890625 +47.91,1532.021240234375 +47.92,1531.771728515625 +47.93,1531.5272216796875 +47.94,1531.289794921875 +47.95,1531.0740966796875 +47.96,1530.863037109375 +47.97,1530.660888671875 +47.98,1530.46337890625 +47.99,1530.2703857421875 +48,1530.096435546875 +48.01,1529.94140625 +48.02,1529.811279296875 +48.03,1529.6932373046875 +48.04,1529.595703125 +48.05,1529.5286865234375 +48.06,1529.5107421875 +48.07,1529.495361328125 +48.08,1529.515869140625 +48.09,1529.578369140625 +48.1,1529.648681640625 +48.11,1529.720458984375 +48.12,1529.7979736328125 +48.13,1529.9376220703125 +48.14,1530.0780029296875 +48.15,1530.2191162109375 +48.16,1530.3607177734375 +48.17,1530.5030517578125 +48.18,1530.620849609375 +48.19,1530.7060546875 +48.2,1530.6669921875 +48.21,1530.5426025390625 +48.22,1530.3984375 +48.23,1530.2242431640625 +48.24,1529.9786376953125 +48.25,1529.735595703125 +48.26,1529.4405517578125 +48.27,1529.1463623046875 +48.28,1528.8050537109375 +48.29,1528.431640625 +48.3,1528.0391845703125 +48.31,1527.6485595703125 +48.32,1527.25146484375 +48.33,1526.822998046875 +48.34,1526.386474609375 +48.35,1525.9169921875 +48.36,1525.4461669921875 +48.37,1524.9822998046875 +48.38,1524.5255126953125 +48.39,1524.0755615234375 +48.4,1523.6324462890625 +48.41,1523.1878662109375 +48.42,1522.7376708984375 +48.43,1522.29443359375 +48.44,1521.858154296875 +48.45,1521.4140625 +48.46,1520.9769287109375 +48.47,1520.5362548828125 +48.48,1520.1026611328125 +48.49,1519.6759033203125 +48.5,1519.2706298828125 +48.51,1518.8824462890625 +48.52,1518.5257568359375 +48.53,1518.17724609375 +48.54,1517.87451171875 +48.55,1517.583740234375 +48.56,1517.3212890625 +48.57,1517.068359375 +48.58,1516.84765625 +48.59,1516.6422119140625 +48.6,1516.44140625 +48.61,1516.2576904296875 +48.62,1516.0784912109375 +48.63,1515.903564453125 +48.64,1515.7496337890625 +48.65,1515.6351318359375 +48.66,1515.5242919921875 +48.67,1515.4337158203125 +48.68,1515.3465576171875 +48.69,1515.2628173828125 +48.7,1515.182373046875 +48.71,1515.1051025390625 +48.72,1515.0205078125 +48.73,1514.930908203125 +48.74,1514.8446044921875 +48.75,1514.761474609375 +48.76,1514.681640625 +48.77,1514.6048583984375 +48.78,1514.52490234375 +48.79,1514.41064453125 +48.8,1514.2998046875 +48.81,1514.1923828125 +48.82,1514.09033203125 +48.83,1513.9915771484375 +48.84,1513.89599609375 +48.85,1513.8121337890625 +48.86,1513.754150390625 +48.87,1513.703125 +48.88,1513.673583984375 +48.89,1513.6484375 +48.9,1513.625732421875 +48.91,1513.6053466796875 +48.92,1513.5872802734375 +48.93,1513.571533203125 +48.94,1513.5557861328125 +48.95,1513.525634765625 +48.96,1513.4686279296875 +48.97,1513.40576171875 +48.98,1513.3267822265625 +48.99,1513.181884765625 +49,1512.9884033203125 +49.01,1512.794677734375 +49.02,1512.6007080078125 +49.03,1512.343994140625 +49.04,1512.0399169921875 +49.05,1511.7388916015625 +49.06,1511.4429931640625 +49.07,1511.152099609375 +49.08,1510.8662109375 +49.09,1510.562255859375 +49.1,1510.2635498046875 +49.11,1509.969970703125 +49.12,1509.71484375 +49.13,1509.4664306640625 +49.14,1509.22265625 +49.15,1508.9833984375 +49.16,1508.76953125 +49.17,1508.57861328125 +49.18,1508.4146728515625 +49.19,1508.2545166015625 +49.2,1508.09814453125 +49.21,1507.9454345703125 +49.22,1507.79638671875 +49.23,1507.575927734375 +49.24,1507.3575439453125 +49.25,1507.0582275390625 +49.26,1506.722412109375 +49.27,1506.365234375 +49.28,1505.886962890625 +49.29,1505.3843994140625 +49.3,1504.8331298828125 +49.31,1504.2896728515625 +49.32,1503.754150390625 +49.33,1503.226318359375 +49.34,1502.706298828125 +49.35,1502.2333984375 +49.36,1501.830078125 +49.37,1501.4498291015625 +49.38,1501.075927734375 +49.39,1500.7103271484375 +49.4,1500.350830078125 +49.41,1499.9974365234375 +49.42,1499.6522216796875 +49.43,1499.3109130859375 +49.44,1498.9736328125 +49.45,1498.64013671875 +49.46,1498.310546875 +49.47,1497.9639892578125 +49.48,1497.621337890625 +49.49,1497.2806396484375 +49.5,1496.939697265625 +49.51,1496.602783203125 +49.52,1496.269775390625 +49.53,1495.932373046875 +49.54,1495.598876953125 +49.55,1495.260986328125 +49.56,1494.925048828125 +49.57,1494.5765380859375 +49.58,1494.227783203125 +49.59,1493.872802734375 +49.6,1493.51171875 +49.61,1493.14453125 +49.62,1492.7567138671875 +49.63,1492.371337890625 +49.64,1491.9801025390625 +49.65,1491.5914306640625 +49.66,1491.2012939453125 +49.67,1490.81787109375 +49.68,1490.4306640625 +49.69,1490.0418701171875 +49.7,1489.64111328125 +49.71,1489.2452392578125 +49.72,1488.8458251953125 +49.73,1488.4532470703125 +49.74,1488.059326171875 +49.75,1487.655517578125 +49.76,1487.239990234375 +49.77,1486.8316650390625 +49.78,1486.3970947265625 +49.79,1485.94921875 +49.8,1485.5087890625 +49.81,1485.0592041015625 +49.82,1484.6171875 +49.83,1484.161865234375 +49.84,1483.69775390625 +49.85,1483.2413330078125 +49.86,1482.786376953125 +49.87,1482.3328857421875 +49.88,1481.87255859375 +49.89,1481.4344482421875 +49.9,1481.00390625 +49.91,1480.5809326171875 +49.92,1480.1861572265625 +49.93,1479.8089599609375 +49.94,1479.4490966796875 +49.95,1479.1209716796875 +49.96,1478.8118896484375 +49.97,1478.5484619140625 +49.98,1478.305419921875 +49.99,1478.0887451171875 +50,1477.9044189453125 +50.01,1477.7332763671875 +50.02,1477.5836181640625 +50.03,1477.448974609375 +50.04,1477.32080078125 +50.05,1477.2156982421875 +50.06,1477.1353759765625 +50.07,1477.0816650390625 +50.08,1477.039794921875 +50.09,1477.0054931640625 +50.1,1476.9974365234375 +50.11,1477.035888671875 +50.12,1477.078857421875 +50.13,1477.138671875 +50.14,1477.219482421875 +50.15,1477.30419921875 +50.16,1477.3907470703125 +50.17,1477.489501953125 +50.18,1477.59619140625 +50.19,1477.7042236328125 +50.2,1477.813720703125 +50.21,1477.924560546875 +50.22,1478.036865234375 +50.23,1478.1234130859375 +50.24,1478.176025390625 +50.25,1478.1826171875 +50.26,1478.1915283203125 +50.27,1478.2027587890625 +50.28,1478.2078857421875 +50.29,1478.215087890625 +50.3,1478.2059326171875 +50.31,1478.198974609375 +50.32,1478.2130126953125 +50.33,1478.2166748046875 +50.34,1478.222412109375 +50.35,1478.230224609375 +50.36,1478.2835693359375 +50.37,1478.340576171875 +50.38,1478.3990478515625 +50.39,1478.4818115234375 +50.4,1478.565673828125 +50.41,1478.634033203125 +50.42,1478.7222900390625 +50.43,1478.8052978515625 +50.44,1478.889404296875 +50.45,1478.9744873046875 +50.46,1479.05224609375 +50.47,1479.1309814453125 +50.48,1479.20654296875 +50.49,1479.27685546875 +50.5,1479.3480224609375 +50.51,1479.3994140625 +50.52,1479.4520263671875 +50.53,1479.5264892578125 +50.54,1479.601806640625 +50.55,1479.6572265625 +50.56,1479.7115478515625 +50.57,1479.7667236328125 +50.58,1479.822998046875 +50.59,1479.880126953125 +50.6,1479.9381103515625 +50.61,1479.9576416015625 +50.62,1479.95751953125 +50.63,1479.9464111328125 +50.64,1479.9326171875 +50.65,1479.9141845703125 +50.66,1479.8973388671875 +50.67,1479.873779296875 +50.68,1479.851806640625 +50.69,1479.8314208984375 +50.7,1479.8126220703125 +50.71,1479.7952880859375 +50.72,1479.7960205078125 +50.73,1479.8250732421875 +50.74,1479.898681640625 +50.75,1479.9810791015625 +50.76,1480.06396484375 +50.77,1480.147216796875 +50.78,1480.230712890625 +50.79,1480.3145751953125 +50.8,1480.3988037109375 +50.81,1480.4583740234375 +50.82,1480.5018310546875 +50.83,1480.5460205078125 +50.84,1480.580322265625 +50.85,1480.580078125 +50.86,1480.5704345703125 +50.87,1480.566162109375 +50.88,1480.558837890625 +50.89,1480.542236328125 +50.9,1480.514404296875 +50.91,1480.48583984375 +50.92,1480.3983154296875 +50.93,1480.2794189453125 +50.94,1480.150390625 +50.95,1479.965576171875 +50.96,1479.7733154296875 +50.97,1479.5550537109375 +50.98,1479.331787109375 +50.99,1479.1097412109375 +51,1478.8392333984375 +51.01,1478.555908203125 +51.02,1478.21240234375 +51.03,1477.859130859375 +51.04,1477.5084228515625 +51.05,1477.1314697265625 +51.06,1476.6995849609375 +51.07,1476.23193359375 +51.08,1475.7537841796875 +51.09,1475.2796630859375 +51.1,1474.809814453125 +51.11,1474.3192138671875 +51.12,1473.8165283203125 +51.13,1473.3204345703125 +51.14,1472.8309326171875 +51.15,1472.345947265625 +51.16,1471.867431640625 +51.17,1471.3955078125 +51.18,1470.9300537109375 +51.19,1470.4814453125 +51.2,1470.04541015625 +51.21,1469.6280517578125 +51.22,1469.21875 +51.23,1468.8155517578125 +51.24,1468.42236328125 +51.25,1468.0350341796875 +51.26,1467.6617431640625 +51.27,1467.2943115234375 +51.28,1466.9324951171875 +51.29,1466.5970458984375 +51.3,1466.2669677734375 +51.31,1465.942138671875 +51.32,1465.6226806640625 +51.33,1465.2733154296875 +51.34,1464.9439697265625 +51.35,1464.6014404296875 +51.36,1464.2706298828125 +51.37,1463.93896484375 +51.38,1463.606689453125 +51.39,1463.2694091796875 +51.4,1462.937744140625 +51.41,1462.594970703125 +51.42,1462.2371826171875 +51.43,1461.8748779296875 +51.44,1461.4915771484375 +51.45,1461.1123046875 +51.46,1460.693603515625 +51.47,1460.2791748046875 +51.48,1459.864990234375 +51.49,1459.455322265625 +51.5,1459.050048828125 +51.51,1458.6119384765625 +51.52,1458.1744384765625 +51.53,1457.74169921875 +51.54,1457.3011474609375 +51.55,1456.8675537109375 +51.56,1456.4407958984375 +51.57,1456.016845703125 +51.58,1455.5994873046875 +51.59,1455.18896484375 +51.6,1454.770751953125 +51.61,1454.359130859375 +51.62,1453.954345703125 +51.63,1453.5706787109375 +51.64,1453.193603515625 +51.65,1452.787841796875 +51.66,1452.388671875 +51.67,1452.0128173828125 +51.68,1451.6434326171875 +51.69,1451.299072265625 +51.7,1450.9630126953125 +51.71,1450.6329345703125 +51.72,1450.3089599609375 +51.73,1449.990966796875 +51.74,1449.6871337890625 +51.75,1449.3890380859375 +51.76,1449.098876953125 +51.77,1448.8162841796875 +51.78,1448.539306640625 +51.79,1448.2718505859375 +51.8,1448.01611328125 +51.81,1447.765625 +51.82,1447.5203857421875 +51.83,1447.2864990234375 +51.84,1447.0784912109375 +51.85,1446.875244140625 +51.86,1446.6768798828125 +51.87,1446.483154296875 +51.88,1446.3167724609375 +51.89,1446.1588134765625 +51.9,1446.01123046875 +51.91,1445.8677978515625 +51.92,1445.728515625 +51.93,1445.59521484375 +51.94,1445.47412109375 +51.95,1445.3692626953125 +51.96,1445.28466796875 +51.97,1445.18701171875 +51.98,1445.0927734375 +51.99,1444.9896240234375 +52,1444.9024658203125 +52.01,1444.818603515625 +52.02,1444.738037109375 +52.03,1444.6483154296875 +52.04,1444.5618896484375 +52.05,1444.478759765625 +52.06,1444.3988037109375 +52.07,1444.322021484375 +52.08,1444.2401123046875 +52.09,1444.134521484375 +52.1,1444.0323486328125 +52.11,1443.9273681640625 +52.12,1443.8258056640625 +52.13,1443.7130126953125 +52.14,1443.603759765625 +52.15,1443.489501953125 +52.16,1443.37451171875 +52.17,1443.262939453125 +52.18,1443.15478515625 +52.19,1443.0499267578125 +52.2,1442.931884765625 +52.21,1442.8172607421875 +52.22,1442.730712890625 +52.23,1442.65966796875 +52.24,1442.591552734375 +52.25,1442.559326171875 +52.26,1442.53369140625 +52.27,1442.5103759765625 +52.28,1442.4893798828125 +52.29,1442.505859375 +52.3,1442.5345458984375 +52.31,1442.5650634765625 +52.32,1442.6241455078125 +52.33,1442.6844482421875 +52.34,1442.7398681640625 +52.35,1442.796630859375 +52.36,1442.854736328125 +52.37,1442.9141845703125 +52.38,1442.9705810546875 +52.39,1443.0281982421875 +52.4,1443.0870361328125 +52.41,1443.0870361328125 +52.42,1443.043212890625 +52.43,1442.9871826171875 +52.44,1442.87548828125 +52.45,1442.7314453125 +52.46,1442.572021484375 +52.47,1442.3663330078125 +52.48,1442.1065673828125 +52.49,1441.78515625 +52.5,1441.46044921875 +52.51,1441.0933837890625 +52.52,1440.6595458984375 +52.53,1440.225830078125 +52.54,1439.7489013671875 +52.55,1439.278564453125 +52.56,1438.8150634765625 +52.57,1438.3582763671875 +52.58,1437.9080810546875 +52.59,1437.49755859375 +52.6,1437.097412109375 +52.61,1436.7073974609375 +52.62,1436.34423828125 +52.63,1436.0384521484375 +52.64,1435.741943359375 +52.65,1435.4794921875 +52.66,1435.232177734375 +52.67,1435.0101318359375 +52.68,1434.82958984375 +52.69,1434.6654052734375 +52.7,1434.533935546875 +52.71,1434.416259765625 +52.72,1434.322509765625 +52.73,1434.2318115234375 +52.74,1434.14599609375 +52.75,1434.0859375 +52.76,1434.038818359375 +52.77,1433.9984130859375 +52.78,1433.966552734375 +52.79,1433.9368896484375 +52.8,1433.9095458984375 +52.81,1433.8843994140625 +52.82,1433.8614501953125 +52.83,1433.8406982421875 +52.84,1433.8013916015625 +52.85,1433.7374267578125 +52.86,1433.667724609375 +52.87,1433.5634765625 +52.88,1433.4578857421875 +52.89,1433.355224609375 +52.9,1433.2513427734375 +52.91,1433.150390625 +52.92,1433.048095703125 +52.93,1432.9404296875 +52.94,1432.821044921875 +52.95,1432.66552734375 +52.96,1432.513427734375 +52.97,1432.3646240234375 +52.98,1432.2191162109375 +52.99,1432.076904296875 +53,1431.9378662109375 +53.01,1431.7813720703125 +53.02,1431.6490478515625 +53.03,1431.519775390625 +53.04,1431.3936767578125 +53.05,1431.2559814453125 +53.06,1431.1214599609375 +53.07,1430.9901123046875 +53.08,1430.8619384765625 +53.09,1430.728515625 +53.1,1430.59814453125 +53.11,1430.4708251953125 +53.12,1430.3466796875 +53.13,1430.2069091796875 +53.14,1430.0640869140625 +53.15,1429.90380859375 +53.16,1429.74267578125 +53.17,1429.5828857421875 +53.18,1429.4285888671875 +53.19,1429.277587890625 +53.2,1429.127685546875 +53.21,1428.9830322265625 +53.22,1428.8416748046875 +53.23,1428.7012939453125 +53.24,1428.566162109375 +53.25,1428.4342041015625 +53.26,1428.3114013671875 +53.27,1428.1915283203125 +53.28,1428.2213134765625 +53.29,1428.25244140625 +53.3,1428.284912109375 +53.31,1428.318603515625 +53.32,1428.3536376953125 +53.33,1428.389892578125 +53.34,1428.3983154296875 +53.35,1428.4083251953125 +53.36,1428.3865966796875 +53.37,1428.3665771484375 +53.38,1428.30908203125 +53.39,1428.2454833984375 +53.4,1428.183837890625 +53.41,1428.1243896484375 +53.42,1428.0670166015625 +53.43,1428.01171875 +53.44,1427.9398193359375 +53.45,1427.8184814453125 +53.46,1427.6978759765625 +53.47,1427.5777587890625 +53.48,1427.394287109375 +53.49,1427.19140625 +53.5,1426.988037109375 +53.51,1426.7757568359375 +53.52,1426.558837890625 +53.53,1426.232177734375 +53.54,1425.9000244140625 +53.55,1425.5377197265625 +53.56,1425.1209716796875 +53.57,1424.6708984375 +53.58,1424.2249755859375 +53.59,1423.7811279296875 +53.6,1423.33935546875 +53.61,1422.9017333984375 +53.62,1422.4189453125 +53.63,1421.9283447265625 +53.64,1421.444580078125 +53.65,1420.967529296875 +53.66,1420.4788818359375 +53.67,1419.9969482421875 +53.68,1419.5198974609375 +53.69,1419.0496826171875 +53.7,1418.5738525390625 +53.71,1418.1029052734375 +53.72,1417.6202392578125 +53.73,1417.1116943359375 +53.74,1416.6063232421875 +53.75,1416.1041259765625 +53.76,1415.6051025390625 +53.77,1415.1134033203125 +53.78,1414.6290283203125 +53.79,1414.15185546875 +53.8,1413.6817626953125 +53.81,1413.2230224609375 +53.82,1412.771240234375 +53.83,1412.3758544921875 +53.84,1411.9910888671875 +53.85,1411.63525390625 +53.86,1411.3514404296875 +53.87,1411.0914306640625 +53.88,1410.8385009765625 +53.89,1410.5906982421875 +53.9,1410.358154296875 +53.91,1410.1199951171875 +53.92,1409.88671875 +53.93,1409.6583251953125 +53.94,1409.4324951171875 +53.95,1409.186767578125 +53.96,1408.9129638671875 +53.97,1408.6444091796875 +53.98,1408.372802734375 +53.99,1408.10009765625 +54,1407.744140625 +54.01,1407.3634033203125 +54.02,1406.9849853515625 +54.03,1406.5120849609375 +54.04,1406.02587890625 +54.05,1405.4896240234375 +54.06,1404.9490966796875 +54.07,1404.406494140625 +54.08,1403.8720703125 +54.09,1403.3436279296875 +54.1,1402.8253173828125 +54.11,1402.3150634765625 +54.12,1401.812744140625 +54.13,1401.318359375 +54.14,1400.8319091796875 +54.15,1400.361328125 +54.16,1399.9046630859375 +54.17,1399.45556640625 +54.18,1399.0179443359375 +54.19,1398.60009765625 +54.2,1398.214111328125 +54.21,1397.8370361328125 +54.22,1397.466796875 +54.23,1397.1072998046875 +54.24,1396.7545166015625 +54.25,1396.408203125 +54.26,1396.062255859375 +54.27,1395.7186279296875 +54.28,1395.37939453125 +54.29,1395.046630859375 +54.3,1394.693603515625 +54.31,1394.316162109375 +54.32,1393.9312744140625 +54.33,1393.506103515625 +54.34,1393.075927734375 +54.35,1392.6346435546875 +54.36,1392.200927734375 +54.37,1391.774658203125 +54.38,1391.3558349609375 +54.39,1390.9588623046875 +54.4,1390.569091796875 +54.41,1390.1966552734375 +54.42,1389.8372802734375 +54.43,1389.531982421875 +54.44,1389.265869140625 +54.45,1389.112548828125 +54.46,1389.01318359375 +54.47,1388.9427490234375 +54.48,1388.92333984375 +54.49,1388.96875 +54.5,1389.0496826171875 +54.51,1389.1328125 +54.52,1389.2178955078125 +54.53,1389.304931640625 +54.54,1389.3939208984375 +54.55,1389.480712890625 +54.56,1389.565185546875 +54.57,1389.6124267578125 +54.58,1389.6495361328125 +54.59,1389.6600341796875 +54.6,1389.6627197265625 +54.61,1389.66796875 +54.62,1389.67578125 +54.63,1389.6859130859375 +54.64,1389.6820068359375 +54.65,1389.6806640625 +54.66,1389.677734375 +54.67,1389.664794921875 +54.68,1389.6195068359375 +54.69,1389.57080078125 +54.7,1389.516845703125 +54.71,1389.4349365234375 +54.72,1389.3458251953125 +54.73,1389.2476806640625 +54.74,1389.15283203125 +54.75,1389.054931640625 +54.76,1388.9212646484375 +54.77,1388.78515625 +54.78,1388.5992431640625 +54.79,1388.39697265625 +54.8,1388.131103515625 +54.81,1387.85986328125 +54.82,1387.587646484375 +54.83,1387.3101806640625 +54.84,1387.0357666015625 +54.85,1386.76025390625 +54.86,1386.48779296875 +54.87,1386.2060546875 +54.88,1385.929443359375 +54.89,1385.6435546875 +54.9,1385.3363037109375 +54.91,1385.05712890625 +54.92,1384.783203125 +54.93,1384.5142822265625 +54.94,1384.2506103515625 +54.95,1383.991943359375 +54.96,1383.73828125 +54.97,1383.4669189453125 +54.98,1383.2232666015625 +54.99,1382.9844970703125 +55,1382.715576171875 +55.01,1382.4517822265625 +55.02,1382.193115234375 +55.03,1381.9393310546875 +55.04,1381.711181640625 +55.05,1381.5 +55.06,1381.29541015625 +55.07,1381.09326171875 +55.08,1380.8955078125 +55.09,1380.7042236328125 +55.1,1380.5172119140625 +55.11,1380.33447265625 +55.12,1380.156005859375 +55.13,1379.981689453125 +55.14,1379.82373046875 +55.15,1379.669677734375 +55.16,1379.51953125 +55.17,1379.373291015625 +55.18,1379.2308349609375 +55.19,1379.07568359375 +55.2,1378.9244384765625 +55.21,1378.7708740234375 +55.22,1378.614990234375 +55.23,1378.4547119140625 +55.24,1378.29833984375 +55.25,1378.1436767578125 +55.26,1377.992919921875 +55.27,1377.845947265625 +55.28,1377.676025390625 +55.29,1377.4976806640625 +55.3,1377.3172607421875 +55.31,1377.140869140625 +55.32,1376.9541015625 +55.33,1376.771484375 +55.34,1376.609375 +55.35,1376.451171875 +55.36,1376.2967529296875 +55.37,1376.1461181640625 +55.38,1375.9991455078125 +55.39,1375.8558349609375 +55.4,1375.7161865234375 +55.41,1375.5802001953125 +55.42,1375.44775390625 +55.43,1375.3291015625 +55.44,1375.2156982421875 +55.45,1375.1055908203125 +55.46,1375.0028076171875 +55.47,1374.899169921875 +55.48,1374.796630859375 +55.49,1374.6971435546875 +55.5,1374.600830078125 +55.51,1374.5054931640625 +55.52,1374.3988037109375 +55.53,1374.295166015625 +55.54,1374.200927734375 +55.55,1374.1094970703125 +55.56,1374.0211181640625 +55.57,1373.935546875 +55.58,1373.852783203125 +55.59,1373.7811279296875 +55.6,1373.712158203125 +55.61,1373.67041015625 +55.62,1373.6373291015625 +55.63,1373.6002197265625 +55.64,1373.561279296875 +55.65,1373.504150390625 +55.66,1373.4493408203125 +55.67,1373.39697265625 +55.68,1373.3675537109375 +55.69,1373.34033203125 +55.7,1373.315185546875 +55.71,1373.2919921875 +55.72,1373.2708740234375 +55.73,1373.24755859375 +55.74,1373.226318359375 +55.75,1373.2069091796875 +55.76,1373.189453125 +55.77,1373.1739501953125 +55.78,1373.1376953125 +55.79,1373.1014404296875 +55.8,1373.04052734375 +55.81,1372.9757080078125 +55.82,1372.908935546875 +55.83,1372.81787109375 +55.84,1372.7252197265625 +55.85,1372.622802734375 +55.86,1372.52099609375 +55.87,1372.388916015625 +55.88,1372.21875 +55.89,1372.0478515625 +55.9,1371.8660888671875 +55.91,1371.6834716796875 +55.92,1371.453125 +55.93,1371.226806640625 +55.94,1371.00439453125 +55.95,1370.78173828125 +55.96,1370.5528564453125 +55.97,1370.3280029296875 +55.98,1370.10498046875 +55.99,1369.8858642578125 +56,1369.670654296875 +56.01,1369.459228515625 +56.02,1369.251708984375 +56.03,1369.0479736328125 +56.04,1368.8460693359375 +56.05,1368.64794921875 +56.06,1368.4473876953125 +56.07,1368.2506103515625 +56.08,1368.0574951171875 +56.09,1367.8680419921875 +56.1,1367.688232421875 +56.11,1367.505859375 +56.12,1367.31689453125 +56.13,1367.131591796875 +56.14,1366.9498291015625 +56.15,1366.77783203125 +56.16,1366.637939453125 +56.17,1366.499267578125 +56.18,1366.36572265625 +56.19,1366.2352294921875 +56.2,1366.1324462890625 +56.21,1366.034423828125 +56.22,1365.9371337890625 +56.23,1365.8424072265625 +56.24,1365.7503662109375 +56.25,1365.6588134765625 +56.26,1365.56982421875 +56.27,1365.477294921875 +56.28,1365.358642578125 +56.29,1365.2427978515625 +56.3,1365.12158203125 +56.31,1365.003173828125 +56.32,1364.8814697265625 +56.33,1364.7626953125 +56.34,1364.6385498046875 +56.35,1364.517333984375 +56.36,1364.3763427734375 +56.37,1364.2384033203125 +56.38,1364.099365234375 +56.39,1363.9449462890625 +56.4,1363.7855224609375 +56.41,1363.6292724609375 +56.42,1363.47216796875 +56.43,1363.314208984375 +56.44,1363.151123046875 +56.45,1362.9853515625 +56.46,1362.7921142578125 +56.47,1362.5860595703125 +56.48,1362.400146484375 +56.49,1362.2012939453125 +56.5,1362.0020751953125 +56.51,1361.804443359375 +56.52,1361.5941162109375 +56.53,1361.3834228515625 +56.54,1361.1744384765625 +56.55,1360.963134765625 +56.56,1360.7474365234375 +56.57,1360.53564453125 +56.58,1360.32763671875 +56.59,1360.1070556640625 +56.6,1359.88427734375 +56.61,1359.663330078125 +56.62,1359.43408203125 +56.63,1359.1700439453125 +56.64,1358.9041748046875 +56.65,1358.60595703125 +56.66,1358.2900390625 +56.67,1357.9708251953125 +56.68,1357.6485595703125 +56.69,1357.3232421875 +56.7,1356.9927978515625 +56.71,1356.6571044921875 +56.72,1356.316650390625 +56.73,1355.932373046875 +56.74,1355.5211181640625 +56.75,1355.0648193359375 +56.76,1354.60498046875 +56.77,1354.1334228515625 +56.78,1353.64208984375 +56.79,1353.1556396484375 +56.8,1352.670166015625 +56.81,1352.179443359375 +56.82,1351.69189453125 +56.83,1351.201171875 +56.84,1350.70947265625 +56.85,1350.21484375 +56.86,1349.723388671875 +56.87,1349.2391357421875 +56.88,1348.7662353515625 +56.89,1348.3004150390625 +56.9,1347.847900390625 +56.91,1347.4066162109375 +56.92,1347.01513671875 +56.93,1346.632080078125 +56.94,1346.27783203125 +56.95,1345.931640625 +56.96,1345.634521484375 +56.97,1345.346923828125 +56.98,1345.09130859375 +56.99,1344.857177734375 +57,1344.6605224609375 +57.01,1344.480712890625 +57.02,1344.364501953125 +57.03,1344.2662353515625 +57.04,1344.175537109375 +57.05,1344.126953125 +57.06,1344.081298828125 +57.07,1344.03857421875 +57.08,1344.0107421875 +57.09,1343.985595703125 +57.1,1343.9630126953125 +57.11,1343.92236328125 +57.12,1343.884521484375 +57.13,1343.832763671875 +57.14,1343.775634765625 +57.15,1343.67626953125 +57.16,1343.5718994140625 +57.17,1343.462646484375 +57.18,1343.3360595703125 +57.19,1343.2110595703125 +57.2,1343.0731201171875 +57.21,1342.912109375 +57.22,1342.750732421875 +57.23,1342.5706787109375 +57.24,1342.39453125 +57.25,1342.222412109375 +57.26,1342.0460205078125 +57.27,1341.87353515625 +57.28,1341.692626953125 +57.29,1341.5074462890625 +57.3,1341.305908203125 +57.31,1341.1085205078125 +57.32,1340.905029296875 +57.33,1340.675048828125 +57.34,1340.4495849609375 +57.35,1340.228515625 +57.36,1340.0118408203125 +57.37,1339.8363037109375 +57.38,1339.66455078125 +57.39,1339.4967041015625 +57.4,1339.3367919921875 +57.41,1339.197021484375 +57.42,1339.0750732421875 +57.43,1338.9666748046875 +57.44,1338.8614501953125 +57.45,1338.7921142578125 +57.46,1338.7276611328125 +57.47,1338.665771484375 +57.48,1338.6290283203125 +57.49,1338.5721435546875 +57.5,1338.5179443359375 +57.51,1338.4642333984375 +57.52,1338.4027099609375 +57.53,1338.3438720703125 +57.54,1338.2874755859375 +57.55,1338.2274169921875 +57.56,1338.169921875 +57.57,1338.11083984375 +57.58,1338.05419921875 +57.59,1337.9468994140625 +57.6,1337.83837890625 +57.61,1337.728759765625 +57.62,1337.5975341796875 +57.63,1337.4326171875 +57.64,1337.259033203125 +57.65,1337.033935546875 +57.66,1336.8006591796875 +57.67,1336.4183349609375 +57.68,1336.0418701171875 +57.69,1335.6712646484375 +57.7,1335.2838134765625 +57.71,1334.887939453125 +57.72,1334.4757080078125 +57.73,1334.069580078125 +57.74,1333.669677734375 +57.75,1333.2696533203125 +57.76,1332.8758544921875 +57.77,1332.486083984375 +57.78,1332.1043701171875 +57.79,1331.726806640625 +57.8,1331.3551025390625 +57.81,1330.9893798828125 +57.82,1330.629638671875 +57.83,1330.2757568359375 +57.84,1329.9276123046875 +57.85,1329.585205078125 +57.86,1329.2506103515625 +57.87,1328.921630859375 +57.88,1328.6309814453125 +57.89,1328.34765625 +57.9,1328.1123046875 +57.91,1327.8919677734375 +57.92,1327.6822509765625 +57.93,1327.5074462890625 +57.94,1327.369384765625 +57.95,1327.239013671875 +57.96,1327.120361328125 +57.97,1327.0155029296875 +57.98,1326.9158935546875 +57.99,1326.819580078125 +58,1326.7406005859375 +58.01,1326.6810302734375 +58.02,1326.63232421875 +58.03,1326.582275390625 +58.04,1326.5347900390625 +58.05,1326.48583984375 +58.06,1326.439453125 +58.07,1326.38330078125 +58.08,1326.3258056640625 +58.09,1326.2464599609375 +58.1,1326.1617431640625 +58.11,1326.079833984375 +58.12,1325.990478515625 +58.13,1325.902099609375 +58.14,1325.7940673828125 +58.15,1325.6871337890625 +58.16,1325.5831298828125 +58.17,1325.4678955078125 +58.18,1325.353759765625 +58.19,1325.214111328125 +58.2,1325.0675048828125 +58.21,1324.9244384765625 +58.22,1324.7459716796875 +58.23,1324.56103515625 +58.24,1324.3697509765625 +58.25,1324.1822509765625 +58.26,1323.9986572265625 +58.27,1323.8189697265625 +58.28,1323.64501953125 +58.29,1323.4788818359375 +58.3,1323.3162841796875 +58.31,1323.1920166015625 +58.32,1323.0811767578125 +58.33,1322.973388671875 +58.34,1322.8685302734375 +58.35,1322.7667236328125 +58.36,1322.6678466796875 +58.37,1322.565673828125 +58.38,1322.46435546875 +58.39,1322.33935546875 +58.4,1322.19091796875 +58.41,1322.039794921875 +58.42,1321.8736572265625 +58.43,1321.7049560546875 +58.44,1321.5377197265625 +58.45,1321.367919921875 +58.46,1321.20166015625 +58.47,1321.03271484375 +58.48,1320.867431640625 +58.49,1320.7076416015625 +58.5,1320.5513916015625 +58.51,1320.410888671875 +58.52,1320.30419921875 +58.53,1320.235107421875 +58.54,1320.1685791015625 +58.55,1320.120849609375 +58.56,1320.0753173828125 +58.57,1320.042236328125 +58.58,1320.0133056640625 +58.59,1319.9842529296875 +58.6,1319.9307861328125 +58.61,1319.87548828125 +58.62,1319.814208984375 +58.63,1319.7552490234375 +58.64,1319.684326171875 +58.65,1319.61572265625 +58.66,1319.52099609375 +58.67,1319.4083251953125 +58.68,1319.290283203125 +58.69,1319.160888671875 +58.7,1319.034423828125 +58.71,1318.910888671875 +58.72,1318.7841796875 +58.73,1318.6502685546875 +58.74,1318.519287109375 +58.75,1318.3912353515625 +58.76,1318.24560546875 +58.77,1318.1031494140625 +58.78,1317.9617919921875 +58.79,1317.8172607421875 +58.8,1317.655517578125 +58.81,1317.492919921875 +58.82,1317.3336181640625 +58.83,1317.16943359375 +58.84,1316.994140625 +58.85,1316.8223876953125 +58.86,1316.6539306640625 +58.87,1316.48876953125 +58.88,1316.3270263671875 +58.89,1316.16845703125 +58.9,1316.0050048828125 +58.91,1315.8408203125 +58.92,1315.679931640625 +58.93,1315.520263671875 +58.94,1315.3555908203125 +58.95,1315.188232421875 +58.96,1315.005859375 +58.97,1314.8004150390625 +58.98,1314.5987548828125 +58.99,1314.38037109375 +59,1314.1617431640625 +59.01,1313.947021484375 +59.02,1313.736083984375 +59.03,1313.5086669921875 +59.04,1313.2689208984375 +59.05,1313.025146484375 +59.06,1312.771240234375 +59.07,1312.5032958984375 +59.08,1312.2215576171875 +59.09,1311.9423828125 +59.1,1311.6431884765625 +59.11,1311.3387451171875 +59.12,1311.0308837890625 +59.13,1310.7178955078125 +59.14,1310.40576171875 +59.15,1310.0762939453125 +59.16,1309.7479248046875 +59.17,1309.4227294921875 +59.18,1309.0802001953125 +59.19,1308.7391357421875 +59.2,1308.3707275390625 +59.21,1307.9794921875 +59.22,1307.580078125 +59.23,1307.17041015625 +59.24,1306.7220458984375 +59.25,1306.2720947265625 +59.26,1305.7962646484375 +59.27,1305.327392578125 +59.28,1304.843017578125 +59.29,1304.3515625 +59.3,1303.8612060546875 +59.31,1303.3719482421875 +59.32,1302.889892578125 +59.33,1302.398681640625 +59.34,1301.90869140625 +59.35,1301.421875 +59.36,1300.9241943359375 +59.37,1300.433837890625 +59.38,1299.950927734375 +59.39,1299.4754638671875 +59.4,1299.0072021484375 +59.41,1298.5401611328125 +59.42,1298.080322265625 +59.43,1297.625732421875 +59.44,1297.1783447265625 +59.45,1296.73388671875 +59.46,1296.284423828125 +59.47,1295.850341796875 +59.48,1295.4151611328125 +59.49,1294.98095703125 +59.5,1294.5537109375 +59.51,1294.1397705078125 +59.52,1293.732666015625 +59.53,1293.340576171875 +59.54,1292.9571533203125 +59.55,1292.5865478515625 +59.56,1292.2244873046875 +59.57,1291.8831787109375 +59.58,1291.5604248046875 +59.59,1291.2537841796875 +59.6,1290.96533203125 +59.61,1290.6947021484375 +59.62,1290.4683837890625 +59.63,1290.253173828125 +59.64,1290.044921875 +59.65,1289.8680419921875 +59.66,1289.6998291015625 +59.67,1289.5421142578125 +59.68,1289.388671875 +59.69,1289.239501953125 +59.7,1289.10888671875 +59.71,1288.9781494140625 +59.72,1288.859619140625 +59.73,1288.7469482421875 +59.74,1288.6380615234375 +59.75,1288.545166015625 +59.76,1288.455810546875 +59.77,1288.3699951171875 +59.78,1288.28759765625 +59.79,1288.1983642578125 +59.8,1288.1226806640625 +59.81,1288.066650390625 +59.82,1288.0035400390625 +59.83,1287.9373779296875 +59.84,1287.8642578125 +59.85,1287.7840576171875 +59.86,1287.70703125 +59.87,1287.63330078125 +59.88,1287.5504150390625 +59.89,1287.4647216796875 +59.9,1287.3720703125 +59.91,1287.28271484375 +59.92,1287.1884765625 +59.93,1287.08935546875 +59.94,1286.9853515625 +59.95,1286.8441162109375 +59.96,1286.6922607421875 +59.97,1286.544189453125 +59.98,1286.389892578125 +59.99,1286.233154296875 +60,1286.045654296875 +60.01,1285.84619140625 +60.02,1285.64892578125 +60.03,1285.3968505859375 +60.04,1285.13134765625 +60.05,1284.8504638671875 +60.06,1284.5687255859375 +60.07,1284.1966552734375 +60.08,1283.8204345703125 +60.09,1283.440185546875 +60.1,1283.03369140625 +60.11,1282.6011962890625 +60.12,1282.1512451171875 +60.13,1281.7000732421875 +60.14,1281.24169921875 +60.15,1280.761962890625 +60.16,1280.2572021484375 +60.17,1279.7540283203125 +60.18,1279.234130859375 +60.19,1278.7220458984375 +60.2,1278.2177734375 +60.21,1277.709228515625 +60.22,1277.20849609375 +60.23,1276.7276611328125 +60.24,1276.25439453125 +60.25,1275.831298828125 +60.26,1275.415283203125 +60.27,1275.0123291015625 +60.28,1274.622314453125 +60.29,1274.2410888671875 +60.3,1273.8807373046875 +60.31,1273.5736083984375 +60.32,1273.2742919921875 +60.33,1272.9891357421875 +60.34,1272.7095947265625 +60.35,1272.449951171875 +60.36,1272.2080078125 +60.37,1271.975341796875 +60.38,1271.812744140625 +60.39,1271.675048828125 +60.4,1271.54541015625 +60.41,1271.423828125 +60.42,1271.3104248046875 +60.43,1271.200927734375 +60.44,1271.0972900390625 +60.45,1270.999267578125 +60.46,1270.906982421875 +60.47,1270.826416015625 +60.48,1270.749267578125 +60.49,1270.66748046875 +60.5,1270.5869140625 +60.51,1270.509765625 +60.52,1270.4339599609375 +60.53,1270.2801513671875 +60.54,1270.1060791015625 +60.55,1269.895751953125 +60.56,1269.68603515625 +60.57,1269.440185546875 +60.58,1269.1973876953125 +60.59,1268.9473876953125 +60.6,1268.67822265625 +60.61,1268.3818359375 +60.62,1268.0523681640625 +60.63,1267.70654296875 +60.64,1267.3363037109375 +60.65,1266.944091796875 +60.66,1266.5421142578125 +60.67,1266.12451171875 +60.68,1265.7138671875 +60.69,1265.2958984375 +60.7,1264.884765625 +60.71,1264.4744873046875 +60.72,1264.0528564453125 +60.73,1263.6280517578125 +60.74,1263.1981201171875 +60.75,1262.7752685546875 +60.76,1262.357421875 +60.77,1261.9466552734375 +60.78,1261.5428466796875 +60.79,1261.1458740234375 +60.8,1260.755859375 +60.81,1260.37255859375 +60.82,1259.99609375 +60.83,1259.63037109375 +60.84,1259.2794189453125 +60.85,1258.94287109375 +60.86,1258.649169921875 +60.87,1258.379638671875 +60.88,1258.1156005859375 +60.89,1257.8631591796875 +60.9,1257.620361328125 +60.91,1257.388916015625 +60.92,1257.1707763671875 +60.93,1256.9658203125 +60.94,1256.781982421875 +60.95,1256.619140625 +60.96,1256.4647216796875 +60.97,1256.3370361328125 +60.98,1256.2215576171875 +60.99,1256.1202392578125 +61,1256.0531005859375 +61.01,1255.99951171875 +61.02,1255.951171875 +61.03,1255.912109375 +61.04,1255.8760986328125 +61.05,1255.8489990234375 +61.06,1255.81884765625 +61.07,1255.8037109375 +61.08,1255.791259765625 +61.09,1255.769287109375 +61.1,1255.7459716796875 +61.11,1255.672607421875 +61.12,1255.59033203125 +61.13,1255.50927734375 +61.14,1255.3868408203125 +61.15,1255.247802734375 +61.16,1255.1085205078125 +61.17,1254.9710693359375 +61.18,1254.8251953125 +61.19,1254.6446533203125 +61.2,1254.4014892578125 +61.21,1254.1368408203125 +61.22,1253.8670654296875 +61.23,1253.6025390625 +61.24,1253.3248291015625 +61.25,1253.0443115234375 +61.26,1252.74267578125 +61.27,1252.434326171875 +61.28,1252.131591796875 +61.29,1251.8262939453125 +61.3,1251.5103759765625 +61.31,1251.1939697265625 +61.32,1250.88330078125 +61.33,1250.5660400390625 +61.34,1250.228271484375 +61.35,1249.880126953125 +61.36,1249.5340576171875 +61.37,1249.1878662109375 +61.38,1248.82958984375 +61.39,1248.4775390625 +61.4,1248.1314697265625 +61.41,1247.78955078125 +61.42,1247.44970703125 +61.43,1247.1077880859375 +61.44,1246.7496337890625 +61.45,1246.3917236328125 +61.46,1246.02587890625 +61.47,1245.6663818359375 +61.48,1245.3131103515625 +61.49,1244.964111328125 +61.5,1244.621337890625 +61.51,1244.28466796875 +61.52,1243.9542236328125 +61.53,1243.6175537109375 +61.54,1243.287109375 +61.55,1242.962646484375 +61.56,1242.61376953125 +61.57,1242.271240234375 +61.58,1241.9349365234375 +61.59,1241.604736328125 +61.6,1241.288818359375 +61.61,1240.9788818359375 +61.62,1240.6849365234375 +61.63,1240.4007568359375 +61.64,1240.1221923828125 +61.65,1239.8492431640625 +61.66,1239.595947265625 +61.67,1239.3480224609375 +61.68,1239.1072998046875 +61.69,1238.885986328125 +61.7,1238.6796875 +61.71,1238.492431640625 +61.72,1238.3260498046875 +61.73,1238.1802978515625 +61.74,1238.0589599609375 +61.75,1237.9637451171875 +61.76,1237.896484375 +61.77,1237.83251953125 +61.78,1237.798095703125 +61.79,1237.772705078125 +61.8,1237.7581787109375 +61.81,1237.7606201171875 +61.82,1237.7777099609375 +61.83,1237.797119140625 +61.84,1237.8167724609375 +61.85,1237.8406982421875 +61.86,1237.8809814453125 +61.87,1237.908935546875 +61.88,1237.93896484375 +61.89,1237.9852294921875 +61.9,1238.01708984375 +61.91,1238.0509033203125 +61.92,1238.0926513671875 +61.93,1238.1361083984375 +61.94,1238.1812744140625 +61.95,1238.2281494140625 +61.96,1238.2767333984375 +61.97,1238.3267822265625 +61.98,1238.3724365234375 +61.99,1238.419677734375 +62,1238.444091796875 +62.01,1238.4700927734375 +62.02,1238.4837646484375 +62.03,1238.499267578125 +62.04,1238.5164794921875 +62.05,1238.535400390625 +62.06,1238.5560302734375 +62.07,1238.5782470703125 +62.08,1238.60205078125 +62.09,1238.6275634765625 +62.1,1238.654541015625 +62.11,1238.65869140625 +62.12,1238.66455078125 +62.13,1238.672119140625 +62.14,1238.6812744140625 +62.15,1238.7042236328125 +62.16,1238.7286376953125 +62.17,1238.75439453125 +62.18,1238.7694091796875 +62.19,1238.7777099609375 +62.2,1238.7877197265625 +62.21,1238.782958984375 +62.22,1238.775634765625 +62.23,1238.755859375 +62.24,1238.7196044921875 +62.25,1238.673095703125 +62.26,1238.5657958984375 +62.27,1238.448974609375 +62.28,1238.2801513671875 +62.29,1238.0457763671875 +62.3,1237.787109375 +62.31,1237.50830078125 +62.32,1237.1590576171875 +62.33,1236.7139892578125 +62.34,1236.216552734375 +62.35,1235.7198486328125 +62.36,1235.20361328125 +62.37,1234.6842041015625 +62.38,1234.1617431640625 +62.39,1233.646484375 +62.4,1233.1383056640625 +62.41,1232.63720703125 +62.42,1232.1431884765625 +62.43,1231.6561279296875 +62.44,1231.2164306640625 +62.45,1230.7933349609375 +62.46,1230.380615234375 +62.47,1229.98828125 +62.48,1229.656494140625 +62.49,1229.3443603515625 +62.5,1229.0435791015625 +62.51,1228.7681884765625 +62.52,1228.501708984375 +62.53,1228.262451171875 +62.54,1228.03369140625 +62.55,1227.83154296875 +62.56,1227.643798828125 +62.57,1227.4842529296875 +62.58,1227.33447265625 +62.59,1227.1884765625 +62.6,1227.0460205078125 +62.61,1226.9232177734375 +62.62,1226.8038330078125 +62.63,1226.6978759765625 +62.64,1226.594970703125 +62.65,1226.4952392578125 +62.66,1226.3883056640625 +62.67,1226.2845458984375 +62.68,1226.1939697265625 +62.69,1226.104248046875 +62.7,1226.0174560546875 +62.71,1225.93359375 +62.72,1225.816162109375 +62.73,1225.6956787109375 +62.74,1225.5784912109375 +62.75,1225.46435546875 +62.76,1225.347412109375 +62.77,1225.223388671875 +62.78,1225.1005859375 +62.79,1224.9830322265625 +62.8,1224.8685302734375 +62.81,1224.757080078125 +62.82,1224.648681640625 +62.83,1224.5372314453125 +62.84,1224.4146728515625 +62.85,1224.2650146484375 +62.86,1224.100341796875 +62.87,1223.9393310546875 +62.88,1223.7799072265625 +62.89,1223.623779296875 +62.9,1223.4732666015625 +62.91,1223.3260498046875 +62.92,1223.1903076171875 +62.93,1223.0498046875 +62.94,1222.9124755859375 +62.95,1222.766357421875 +62.96,1222.62353515625 +62.97,1222.4920654296875 +62.98,1222.355712890625 +62.99,1222.22265625 +63,1222.086669921875 +63.01,1221.939697265625 +63.02,1221.7900390625 +63.03,1221.6356201171875 +63.04,1221.478515625 +63.05,1221.2823486328125 +63.06,1221.06982421875 +63.07,1220.861328125 +63.08,1220.638671875 +63.09,1220.391845703125 +63.1,1220.13720703125 +63.11,1219.87890625 +63.12,1219.5684814453125 +63.13,1219.224853515625 +63.14,1218.822021484375 +63.15,1218.4071044921875 +63.16,1217.951904296875 +63.17,1217.4710693359375 +63.18,1216.9710693359375 +63.19,1216.4478759765625 +63.2,1215.9281005859375 +63.21,1215.4156494140625 +63.22,1214.91064453125 +63.23,1214.4129638671875 +63.24,1213.9224853515625 +63.25,1213.44140625 +63.26,1213.001708984375 +63.27,1212.5888671875 +63.28,1212.2633056640625 +63.29,1211.9615478515625 +63.3,1211.7073974609375 +63.31,1211.46435546875 +63.32,1211.234130859375 +63.33,1211.101318359375 +63.34,1211.00244140625 +63.35,1210.9271240234375 +63.36,1210.8709716796875 +63.37,1210.817626953125 +63.38,1210.76708984375 +63.39,1210.7071533203125 +63.4,1210.6622314453125 +63.41,1210.6077880859375 +63.42,1210.5560302734375 +63.43,1210.5191650390625 +63.44,1210.484619140625 +63.45,1210.4586181640625 +63.46,1210.43701171875 +63.47,1210.4178466796875 +63.48,1210.4007568359375 +63.49,1210.385986328125 +63.5,1210.38134765625 +63.51,1210.3707275390625 +63.52,1210.35009765625 +63.53,1210.32763671875 +63.54,1210.264892578125 +63.55,1210.1783447265625 +63.56,1210.064453125 +63.57,1209.9091796875 +63.58,1209.7271728515625 +63.59,1209.5428466796875 +63.6,1209.2816162109375 +63.61,1209.0208740234375 +63.62,1208.7586669921875 +63.63,1208.47900390625 +63.64,1208.1739501953125 +63.65,1207.8416748046875 +63.66,1207.488525390625 +63.67,1207.1248779296875 +63.68,1206.7366943359375 +63.69,1206.3544921875 +63.7,1205.978271484375 +63.71,1205.60595703125 +63.72,1205.2396240234375 +63.73,1204.879150390625 +63.74,1204.5203857421875 +63.75,1204.165283203125 +63.76,1203.8160400390625 +63.77,1203.474609375 +63.78,1203.1387939453125 +63.79,1202.806640625 +63.8,1202.4801025390625 +63.81,1202.1590576171875 +63.82,1201.8375244140625 +63.83,1201.521484375 +63.84,1201.1988525390625 +63.85,1200.8778076171875 +63.86,1200.562255859375 +63.87,1200.2401123046875 +63.88,1199.9356689453125 +63.89,1199.63671875 +63.9,1199.3470458984375 +63.91,1199.0748291015625 +63.92,1198.8238525390625 +63.93,1198.603759765625 +63.94,1198.390380859375 +63.95,1198.1815185546875 +63.96,1197.97705078125 +63.97,1197.787109375 +63.98,1197.6014404296875 +63.99,1197.4259033203125 +64,1197.2725830078125 +64.01,1197.141357421875 +64.02,1197.017822265625 +64.03,1196.87548828125 +64.04,1196.7591552734375 +64.05,1196.6563720703125 +64.06,1196.556884765625 +64.07,1196.470703125 +64.08,1196.3775634765625 +64.09,1196.3016357421875 +64.1,1196.214599609375 +64.11,1196.1710205078125 +64.12,1196.1300048828125 +64.13,1196.0914306640625 +64.14,1196.055419921875 +64.15,1196.02587890625 +64.16,1195.998779296875 +64.17,1195.9962158203125 +64.18,1195.9957275390625 +64.19,1195.9971923828125 +64.2,1196.0006103515625 +64.21,1196.0059814453125 +64.22,1196.00927734375 +64.23,1195.9700927734375 +64.24,1195.9171142578125 +64.25,1195.8524169921875 +64.26,1195.790283203125 +64.27,1195.7266845703125 +64.28,1195.6634521484375 +64.29,1195.5987548828125 +64.3,1195.5325927734375 +64.31,1195.466796875 +64.32,1195.387451171875 +64.33,1195.3106689453125 +64.34,1195.2083740234375 +64.35,1195.088623046875 +64.36,1194.9718017578125 +64.37,1194.85400390625 +64.38,1194.7030029296875 +64.39,1194.55126953125 +64.4,1194.390869140625 +64.41,1194.23388671875 +64.42,1194.0721435546875 +64.43,1193.899658203125 +64.44,1193.7227783203125 +64.45,1193.5211181640625 +64.46,1193.3233642578125 +64.47,1193.1092529296875 +64.48,1192.8970947265625 +64.49,1192.602294921875 +64.5,1192.308349609375 +64.51,1191.97900390625 +64.52,1191.6265869140625 +64.53,1191.25146484375 +64.54,1190.8740234375 +64.55,1190.47216796875 +64.56,1190.05419921875 +64.57,1189.606201171875 +64.58,1189.138671875 +64.59,1188.6759033203125 +64.6,1188.2059326171875 +64.61,1187.724609375 +64.62,1187.236328125 +64.63,1186.73095703125 +64.64,1186.23095703125 +64.65,1185.732177734375 +64.66,1185.224609375 +64.67,1184.7183837890625 +64.68,1184.211669921875 +64.69,1183.7044677734375 +64.7,1183.1826171875 +64.71,1182.66845703125 +64.72,1182.1619873046875 +64.73,1181.6611328125 +64.74,1181.1636962890625 +64.75,1180.677978515625 +64.76,1180.19970703125 +64.77,1179.7288818359375 +64.78,1179.2713623046875 +64.79,1178.8211669921875 +64.8,1178.378173828125 +64.81,1177.9423828125 +64.82,1177.5196533203125 +64.83,1177.1058349609375 +64.84,1176.7049560546875 +64.85,1176.3109130859375 +64.86,1175.9237060546875 +64.87,1175.547119140625 +64.88,1175.193115234375 +64.89,1174.8475341796875 +64.9,1174.518310546875 +64.91,1174.19921875 +64.92,1173.8861083984375 +64.93,1173.594970703125 +64.94,1173.3116455078125 +64.95,1173.0460205078125 +64.96,1172.7938232421875 +64.97,1172.549072265625 +64.98,1172.3197021484375 +64.99,1172.1195068359375 +65,1171.954345703125 +65.01,1171.79345703125 +65.02,1171.636962890625 +65.03,1171.49072265625 +65.04,1171.3487548828125 +65.05,1171.2349853515625 +65.06,1171.1251220703125 +65.07,1171.009033203125 +65.08,1170.8826904296875 +65.09,1170.7542724609375 +65.1,1170.629638671875 +65.11,1170.5089111328125 +65.12,1170.3719482421875 +65.13,1170.2288818359375 +65.14,1170.083740234375 +65.15,1169.9326171875 +65.16,1169.779541015625 +65.17,1169.6103515625 +65.18,1169.4415283203125 +65.19,1169.2628173828125 +65.2,1169.0885009765625 +65.21,1168.91650390625 +65.22,1168.748779296875 +65.23,1168.5731201171875 +65.24,1168.3935546875 +65.25,1168.2183837890625 +65.26,1168.0474853515625 +65.27,1167.8023681640625 +65.28,1167.5321044921875 +65.29,1167.2530517578125 +65.3,1166.9652099609375 +65.31,1166.638671875 +65.32,1166.303955078125 +65.33,1165.965087890625 +65.34,1165.5999755859375 +65.35,1165.2391357421875 +65.36,1164.868408203125 +65.37,1164.4580078125 +65.38,1164.05224609375 +65.39,1163.6512451171875 +65.4,1163.2528076171875 +65.41,1162.8551025390625 +65.42,1162.4500732421875 +65.43,1162.0518798828125 +65.44,1161.65234375 +65.45,1161.267578125 +65.46,1160.889404296875 +65.47,1160.5118408203125 +65.48,1160.1387939453125 +65.49,1159.7742919921875 +65.5,1159.416259765625 +65.51,1159.0745849609375 +65.52,1158.7412109375 +65.53,1158.4139404296875 +65.54,1158.080810546875 +65.55,1157.75390625 +65.56,1157.4490966796875 +65.57,1157.1502685546875 +65.58,1156.857177734375 +65.59,1156.56982421875 +65.6,1156.288330078125 +65.61,1156.0164794921875 +65.62,1155.752197265625 +65.63,1155.4932861328125 +65.64,1155.2379150390625 +65.65,1154.9818115234375 +65.66,1154.731201171875 +65.67,1154.4818115234375 +65.68,1154.189697265625 +65.69,1153.865234375 +65.7,1153.540771484375 +65.71,1153.184326171875 +65.72,1152.788330078125 +65.73,1152.3751220703125 +65.74,1151.932861328125 +65.75,1151.453857421875 +65.76,1150.960693359375 +65.77,1150.475341796875 +65.78,1149.9879150390625 +65.79,1149.5064697265625 +65.8,1149.0328369140625 +65.81,1148.569091796875 +65.82,1148.123046875 +65.83,1147.6925048828125 +65.84,1147.2694091796875 +65.85,1146.8818359375 +65.86,1146.5291748046875 +65.87,1146.2314453125 +65.88,1145.94384765625 +65.89,1145.7142333984375 +65.9,1145.4920654296875 +65.91,1145.2811279296875 +65.92,1145.075439453125 +65.93,1144.8748779296875 +65.94,1144.69140625 +65.95,1144.500732421875 +65.96,1144.3150634765625 +65.97,1144.1341552734375 +65.98,1143.9521484375 +65.99,1143.781005859375 +66,1143.6143798828125 +66.01,1143.4664306640625 +66.02,1143.3270263671875 +66.03,1143.19189453125 +66.04,1143.085205078125 +66.05,1142.984375 +66.06,1142.9034423828125 +66.07,1142.8321533203125 +66.08,1142.766357421875 +66.09,1142.7100830078125 +66.1,1142.6832275390625 +66.11,1142.681396484375 +66.12,1142.6883544921875 +66.13,1142.701904296875 +66.14,1142.7440185546875 +66.15,1142.8023681640625 +66.16,1142.8626708984375 +66.17,1142.9249267578125 +66.18,1142.989013671875 +66.19,1143.054931640625 +66.2,1143.1226806640625 +66.21,1143.1619873046875 +66.22,1143.197265625 +66.23,1143.202392578125 +66.24,1143.209716796875 +66.25,1143.2193603515625 +66.26,1143.25927734375 +66.27,1143.304931640625 +66.28,1143.3583984375 +66.29,1143.4556884765625 +66.3,1143.5540771484375 +66.31,1143.653564453125 +66.32,1143.754150390625 +66.33,1143.8035888671875 +66.34,1143.8165283203125 +66.35,1143.8072509765625 +66.36,1143.7979736328125 +66.37,1143.790771484375 +66.38,1143.7335205078125 +66.39,1143.6607666015625 +66.4,1143.5906982421875 +66.41,1143.5172119140625 +66.42,1143.4464111328125 +66.43,1143.378173828125 +66.44,1143.3125 +66.45,1143.2353515625 +66.46,1143.1387939453125 +66.47,1143.0450439453125 +66.48,1142.8939208984375 +66.49,1142.710205078125 +66.5,1142.5201416015625 +66.51,1142.287841796875 +66.52,1142.0517578125 +66.53,1141.7598876953125 +66.54,1141.46484375 +66.55,1141.138671875 +66.56,1140.815673828125 +66.57,1140.491943359375 +66.58,1140.1734619140625 +66.59,1139.8541259765625 +66.6,1139.5299072265625 +66.61,1139.20703125 +66.62,1138.87353515625 +66.63,1138.5413818359375 +66.64,1138.2105712890625 +66.65,1137.8851318359375 +66.66,1137.567138671875 +66.67,1137.25439453125 +66.68,1136.947021484375 +66.69,1136.6448974609375 +66.7,1136.347900390625 +66.71,1136.0560302734375 +66.72,1135.7691650390625 +66.73,1135.4873046875 +66.74,1135.198486328125 +66.75,1134.9107666015625 +66.76,1134.628173828125 +66.77,1134.338623046875 +66.78,1134.05419921875 +66.79,1133.7747802734375 +66.8,1133.5003662109375 +66.81,1133.23095703125 +66.82,1132.966552734375 +66.83,1132.718994140625 +66.84,1132.47021484375 +66.85,1132.238037109375 +66.86,1132.026611328125 +66.87,1131.8294677734375 +66.88,1131.6705322265625 +66.89,1131.5255126953125 +66.9,1131.4442138671875 +66.91,1131.39990234375 +66.92,1131.3802490234375 +66.93,1131.388916015625 +66.94,1131.4156494140625 +66.95,1131.4442138671875 +66.96,1131.4765625 +66.97,1131.510498046875 +66.98,1131.55810546875 +66.99,1131.6173095703125 +67,1131.677734375 +67.01,1131.739501953125 +67.02,1131.79248046875 +67.03,1131.846923828125 +67.04,1131.892578125 +67.05,1131.9395751953125 +67.06,1131.9879150390625 +67.07,1132.0374755859375 +67.08,1132.08837890625 +67.09,1132.1544189453125 +67.1,1132.221435546875 +67.11,1132.2894287109375 +67.12,1132.3583984375 +67.13,1132.42822265625 +67.14,1132.4649658203125 +67.15,1132.496826171875 +67.16,1132.501953125 +67.17,1132.506591796875 +67.18,1132.4927978515625 +67.19,1132.474609375 +67.2,1132.43994140625 +67.21,1132.361083984375 +67.22,1132.2685546875 +67.23,1132.16650390625 +67.24,1132.0528564453125 +67.25,1131.921875 +67.26,1131.7857666015625 +67.27,1131.558349609375 +67.28,1131.30078125 +67.29,1131.0052490234375 +67.3,1130.71044921875 +67.31,1130.3641357421875 +67.32,1129.996826171875 +67.33,1129.6309814453125 +67.34,1129.2545166015625 +67.35,1128.87548828125 +67.36,1128.501953125 +67.37,1128.1319580078125 +67.38,1127.75341796875 +67.39,1127.37646484375 +67.4,1127.0010986328125 +67.41,1126.63134765625 +67.42,1126.2630615234375 +67.43,1125.896484375 +67.44,1125.5355224609375 +67.45,1125.1800537109375 +67.46,1124.830078125 +67.47,1124.5513916015625 +67.48,1124.2874755859375 +67.49,1124.0701904296875 +67.5,1123.876953125 +67.51,1123.6956787109375 +67.52,1123.5201416015625 +67.53,1123.356201171875 +67.54,1123.19775390625 +67.55,1123.0948486328125 +67.56,1123.1629638671875 +67.57,1123.264404296875 +67.58,1123.39453125 +67.59,1123.525146484375 +67.6,1123.6561279296875 +67.61,1123.7874755859375 +67.62,1123.89111328125 +67.63,1123.92138671875 +67.64,1123.9290771484375 +67.65,1123.9322509765625 +67.66,1123.8448486328125 +67.67,1123.7579345703125 +67.68,1123.6676025390625 +67.69,1123.5577392578125 +67.7,1123.446533203125 +67.71,1123.3380126953125 +67.72,1123.2242431640625 +67.73,1123.09326171875 +67.74,1122.953125 +67.75,1122.780029296875 +67.76,1122.6102294921875 +67.77,1122.44384765625 +67.78,1122.254638671875 +67.79,1122.06103515625 +67.8,1121.85693359375 +67.81,1121.6505126953125 +67.82,1121.44775390625 +67.83,1121.2467041015625 +67.84,1121.04931640625 +67.85,1120.84765625 +67.86,1120.649658203125 +67.87,1120.4552001953125 +67.88,1120.264404296875 +67.89,1120.0771484375 +67.9,1119.8974609375 +67.91,1119.72119140625 +67.92,1119.5504150390625 +67.93,1119.3829345703125 +67.94,1119.2188720703125 +67.95,1119.05810546875 +67.96,1118.900634765625 +67.97,1118.7403564453125 +67.98,1118.5654296875 +67.99,1118.3759765625 +68,1118.1900634765625 +68.01,1117.99365234375 +68.02,1117.794921875 +68.03,1117.5938720703125 +68.04,1117.3746337890625 +68.05,1117.145263671875 +68.06,1116.911865234375 +68.07,1116.6905517578125 +68.08,1116.4652099609375 +68.09,1116.243896484375 +68.1,1116.0264892578125 +68.11,1115.81298828125 +68.12,1115.601318359375 +68.13,1115.3914794921875 +68.14,1115.1177978515625 +68.15,1114.8465576171875 +68.16,1114.5599365234375 +68.17,1114.2779541015625 +68.18,1113.99658203125 +68.19,1113.7138671875 +68.2,1113.4359130859375 +68.21,1113.1685791015625 +68.22,1112.8917236328125 +68.23,1112.6195068359375 +68.24,1112.3519287109375 +68.25,1112.078857421875 +68.26,1111.8065185546875 +68.27,1111.5347900390625 +68.28,1111.223876953125 +68.29,1110.9119873046875 +68.3,1110.5992431640625 +68.31,1110.2916259765625 +68.32,1109.9830322265625 +68.33,1109.673583984375 +68.34,1109.3592529296875 +68.35,1109.050048828125 +68.36,1108.7440185546875 +68.37,1108.443115234375 +68.38,1108.143310546875 +68.39,1107.82666015625 +68.4,1107.5152587890625 +68.41,1107.1990966796875 +68.42,1106.8883056640625 +68.43,1106.582763671875 +68.44,1106.2823486328125 +68.45,1105.9930419921875 +68.46,1105.7088623046875 +68.47,1105.4237060546875 +68.48,1105.1495361328125 +68.49,1104.874267578125 +68.5,1104.60986328125 +68.51,1104.350341796875 +68.52,1104.0716552734375 +68.53,1103.7840576171875 +68.54,1103.4755859375 +68.55,1103.1424560546875 +68.56,1102.8070068359375 +68.57,1102.47509765625 +68.58,1102.144775390625 +68.59,1101.818115234375 +68.6,1101.4930419921875 +68.61,1101.129638671875 +68.62,1100.770263671875 +68.63,1100.388916015625 +68.64,1100.01171875 +68.65,1099.6326904296875 +68.66,1099.25390625 +68.67,1098.8873291015625 +68.68,1098.52685546875 +68.69,1098.16650390625 +68.7,1097.8240966796875 +68.71,1097.487548828125 +68.72,1097.15283203125 +68.73,1096.8240966796875 +68.74,1096.505126953125 +68.75,1096.191650390625 +68.76,1095.8800048828125 +68.77,1095.573974609375 +68.78,1095.281494140625 +68.79,1094.9864501953125 +68.8,1094.6968994140625 +68.81,1094.4267578125 +68.82,1094.161865234375 +68.83,1093.902099609375 +68.84,1093.6495361328125 +68.85,1093.404052734375 +68.86,1093.1654052734375 +68.87,1092.9437255859375 +68.88,1092.728759765625 +68.89,1092.5164794921875 +68.9,1092.3109130859375 +68.91,1092.10986328125 +68.92,1091.9033203125 +68.93,1091.701416015625 +68.94,1091.5040283203125 +68.95,1091.299072265625 +68.96,1091.0966796875 +68.97,1090.896728515625 +68.98,1090.7032470703125 +68.99,1090.51220703125 +69,1090.3255615234375 +69.01,1090.145263671875 +69.02,1089.96923828125 +69.03,1089.79345703125 +69.04,1089.621826171875 +69.05,1089.424560546875 +69.06,1089.2276611328125 +69.07,1089.025146484375 +69.08,1088.8271484375 +69.09,1088.631591796875 +69.1,1088.4364013671875 +69.11,1088.2276611328125 +69.12,1088.01953125 +69.13,1087.8157958984375 +69.14,1087.6065673828125 +69.15,1087.389892578125 +69.16,1087.173828125 +69.17,1086.9503173828125 +69.18,1086.7294921875 +69.19,1086.5133056640625 +69.2,1086.3016357421875 +69.21,1086.0865478515625 +69.22,1085.8740234375 +69.23,1085.666015625 +69.24,1085.4625244140625 +69.25,1085.265380859375 +69.26,1085.0806884765625 +69.27,1084.912109375 +69.28,1084.7515869140625 +69.29,1084.594970703125 +69.3,1084.498046875 +69.31,1084.42041015625 +69.32,1084.345947265625 +69.33,1084.2744140625 +69.34,1084.2059326171875 +69.35,1084.12646484375 +69.36,1084.048095703125 +69.37,1083.962646484375 +69.38,1083.872314453125 +69.39,1083.7791748046875 +69.4,1083.6531982421875 +69.41,1083.5286865234375 +69.42,1083.3778076171875 +69.43,1083.2266845703125 +69.44,1083.041259765625 +69.45,1082.8419189453125 +69.46,1082.5791015625 +69.47,1082.30126953125 +69.48,1082.0284423828125 +69.49,1081.7564697265625 +69.5,1081.489501953125 +69.51,1081.2314453125 +69.52,1080.9742431640625 +69.53,1080.7198486328125 +69.54,1080.4642333984375 +69.55,1080.20947265625 +69.56,1079.9595947265625 +69.57,1079.6468505859375 +69.58,1079.3175048828125 +69.59,1078.98974609375 +69.6,1078.6497802734375 +69.61,1078.2857666015625 +69.62,1077.9276123046875 +69.63,1077.577392578125 +69.64,1077.233154296875 +69.65,1076.8946533203125 +69.66,1076.56201171875 +69.67,1076.2508544921875 +69.68,1075.9453125 +69.69,1075.6612548828125 +69.7,1075.3824462890625 +69.71,1075.10888671875 +69.72,1074.852294921875 +69.73,1074.606689453125 +69.74,1074.3798828125 +69.75,1074.15771484375 +69.76,1073.9442138671875 +69.77,1073.7392578125 +69.78,1073.5546875 +69.79,1073.374267578125 +69.8,1073.2041015625 +69.81,1073.0380859375 +69.82,1072.8760986328125 +69.83,1072.7298583984375 +69.84,1072.621337890625 +69.85,1072.5262451171875 +69.86,1072.432373046875 +69.87,1072.333740234375 +69.88,1072.2464599609375 +69.89,1072.164306640625 +69.9,1072.083251953125 +69.91,1072.005126953125 +69.92,1071.93408203125 +69.93,1071.8619384765625 +69.94,1071.792724609375 +69.95,1071.7303466796875 +69.96,1071.6707763671875 +69.97,1071.61376953125 +69.98,1071.5595703125 +69.99,1071.508056640625 +70,1071.4591064453125 +70.01,1071.40283203125 +70.02,1071.34912109375 +70.03,1071.2581787109375 +70.04,1071.1463623046875 +70.05,1071.0296630859375 +70.06,1070.8983154296875 +70.07,1070.770263671875 +70.08,1070.6416015625 +70.09,1070.5142822265625 +70.1,1070.3922119140625 +70.11,1070.2713623046875 +70.12,1070.1397705078125 +70.13,1070.011474609375 +70.14,1069.8865966796875 +70.15,1069.77880859375 +70.16,1069.674072265625 +70.17,1069.558349609375 +70.18,1069.4456787109375 +70.19,1069.3260498046875 +70.2,1069.2056884765625 +70.21,1069.0885009765625 +70.22,1068.974365234375 +70.23,1068.86328125 +70.24,1068.755126953125 +70.25,1068.63818359375 +70.26,1068.524169921875 +70.27,1068.4251708984375 +70.28,1068.317138671875 +70.29,1068.2120361328125 +70.3,1068.1256103515625 +70.31,1067.9942626953125 +70.32,1067.860107421875 +70.33,1067.7291259765625 +70.34,1067.6011962890625 +70.35,1067.472412109375 +70.36,1067.3367919921875 +70.37,1067.1964111328125 +70.38,1067.0592041015625 +70.39,1066.9251708984375 +70.4,1066.7705078125 +70.41,1066.619140625 +70.42,1066.443359375 +70.43,1066.2711181640625 +70.44,1066.1024169921875 +70.45,1065.9371337890625 +70.46,1065.7396240234375 +70.47,1065.5458984375 +70.48,1065.3480224609375 +70.49,1065.161865234375 +70.5,1064.9793701171875 +70.51,1064.800537109375 +70.52,1064.6253662109375 +70.53,1064.4498291015625 +70.54,1064.2559814453125 +70.55,1064.0579833984375 +70.56,1063.86376953125 +70.57,1063.6514892578125 +70.58,1063.4432373046875 +70.59,1063.2388916015625 +70.6,1063.038330078125 +70.61,1062.8416748046875 +70.62,1062.6488037109375 +70.63,1062.4598388671875 +70.64,1062.2745361328125 +70.65,1062.093017578125 +70.66,1061.8892822265625 +70.67,1061.689453125 +70.68,1061.4735107421875 +70.69,1061.2816162109375 +70.7,1061.093505859375 +70.71,1060.9090576171875 +70.72,1060.7423095703125 +70.73,1060.5849609375 +70.74,1060.43115234375 +70.75,1060.28271484375 +70.76,1060.137451171875 +70.77,1059.9996337890625 +70.78,1059.864990234375 +70.79,1059.7335205078125 +70.8,1059.605224609375 +70.81,1059.47607421875 +70.82,1059.3101806640625 +70.83,1059.1478271484375 +70.84,1058.98095703125 +70.85,1058.817626953125 +70.86,1058.6578369140625 +70.87,1058.50146484375 +70.88,1058.346435546875 +70.89,1058.19482421875 +70.9,1058.03662109375 +70.91,1057.842041015625 +70.92,1057.6512451171875 +70.93,1057.460205078125 +70.94,1057.2728271484375 +70.95,1057.0673828125 +70.96,1056.8240966796875 +70.97,1056.6029052734375 +70.98,1056.367919921875 +70.99,1056.1490478515625 +71,1055.934326171875 +71.01,1055.709716796875 +71.02,1055.4891357421875 +71.03,1055.2646484375 +71.04,1055.0523681640625 +71.05,1054.8560791015625 +71.06,1054.66357421875 +71.07,1054.474853515625 +71.08,1054.2779541015625 +71.09,1054.0849609375 +71.1,1053.8857421875 +71.11,1053.668701171875 +71.12,1053.4556884765625 +71.13,1053.2467041015625 +71.14,1053.0218505859375 +71.15,1052.78125 +71.16,1052.5311279296875 +71.17,1052.285400390625 +71.18,1052.0440673828125 +71.19,1051.80712890625 +71.2,1051.5784912109375 +71.21,1051.3541259765625 +71.22,1051.1339111328125 +71.23,1050.9078369140625 +71.24,1050.696044921875 +71.25,1050.4903564453125 +71.26,1050.2926025390625 +71.27,1050.0927734375 +71.28,1049.8870849609375 +71.29,1049.6715087890625 +71.3,1049.4580078125 +71.31,1049.2447509765625 +71.32,1048.999755859375 +71.33,1048.745361328125 +71.34,1048.479736328125 +71.35,1048.141357421875 +71.36,1047.78662109375 +71.37,1047.4375 +71.38,1047.04638671875 +71.39,1046.6573486328125 +71.4,1046.2603759765625 +71.41,1045.8616943359375 +71.42,1045.4532470703125 +71.43,1045.0213623046875 +71.44,1044.590087890625 +71.45,1044.157470703125 +71.46,1043.709716796875 +71.47,1043.2529296875 +71.48,1042.801025390625 +71.49,1042.35595703125 +71.5,1041.913818359375 +71.51,1041.478515625 +71.52,1041.0262451171875 +71.53,1040.576904296875 +71.54,1040.1346435546875 +71.55,1039.6893310546875 +71.56,1039.2510986328125 +71.57,1038.815673828125 +71.58,1038.3792724609375 +71.59,1037.951904296875 +71.6,1037.5294189453125 +71.61,1037.11376953125 +71.62,1036.704833984375 +71.63,1036.3046875 +71.64,1035.9112548828125 +71.65,1035.5421142578125 +71.66,1035.201171875 +71.67,1034.8922119140625 +71.68,1034.5889892578125 +71.69,1034.29931640625 +71.7,1034.023193359375 +71.71,1033.75244140625 +71.72,1033.5050048828125 +71.73,1033.29443359375 +71.74,1033.0985107421875 +71.75,1032.9151611328125 +71.76,1032.7421875 +71.77,1032.5736083984375 +71.78,1032.4271240234375 +71.79,1032.30859375 +71.8,1032.1998291015625 +71.81,1032.1064453125 +71.82,1032.0167236328125 +71.83,1031.9403076171875 +71.84,1031.8690185546875 +71.85,1031.799072265625 +71.86,1031.7222900390625 +71.87,1031.640869140625 +71.88,1031.5626220703125 +71.89,1031.5015869140625 +71.9,1031.4434814453125 +71.91,1031.394287109375 +71.92,1031.342041015625 +71.93,1031.292724609375 +71.94,1031.260009765625 +71.95,1031.2457275390625 +71.96,1031.2359619140625 +71.97,1031.2265625 +71.98,1031.219482421875 +71.99,1031.214599609375 +72,1031.2119140625 +72.01,1031.2135009765625 +72.02,1031.2152099609375 +72.03,1031.218994140625 +72.04,1031.2247314453125 +72.05,1031.228515625 +72.06,1031.218505859375 +72.07,1031.210693359375 +72.08,1031.198974609375 +72.09,1031.183349609375 +72.1,1031.152099609375 +72.11,1031.105224609375 +72.12,1031.0408935546875 +72.13,1030.979248046875 +72.14,1030.87255859375 +72.15,1030.7628173828125 +72.16,1030.644287109375 +72.17,1030.52880859375 +72.18,1030.4124755859375 +72.19,1030.2833251953125 +72.2,1030.1732177734375 +72.21,1030.0601806640625 +72.22,1029.9501953125 +72.23,1029.8431396484375 +72.24,1029.739013671875 +72.25,1029.6376953125 +72.26,1029.5135498046875 +72.27,1029.3509521484375 +72.28,1029.146240234375 +72.29,1028.943359375 +72.3,1028.7265625 +72.31,1028.49609375 +72.32,1028.2440185546875 +72.33,1027.9825439453125 +72.34,1027.7236328125 +72.35,1027.453369140625 +72.36,1027.144287109375 +72.37,1026.8382568359375 +72.38,1026.53515625 +72.39,1026.237060546875 +72.4,1025.9381103515625 +72.41,1025.6361083984375 +72.42,1025.3214111328125 +72.43,1024.982177734375 +72.44,1024.630615234375 +72.45,1024.2806396484375 +72.46,1023.9204711914062 +72.47,1023.538330078125 +72.48,1023.158203125 +72.49,1022.780029296875 +72.5,1022.3900756835938 +72.51,1021.9903564453125 +72.52,1021.561279296875 +72.53,1021.1090698242188 +72.54,1020.6636962890625 +72.55,1020.2211303710938 +72.56,1019.7813110351562 +72.57,1019.3305053710938 +72.58,1018.8568725585938 +72.59,1018.386474609375 +72.6,1017.9232177734375 +72.61,1017.4512939453125 +72.62,1016.96484375 +72.63,1016.4818115234375 +72.64,1016.0061645507812 +72.65,1015.5377807617188 +72.66,1015.0806274414062 +72.67,1014.6307373046875 +72.68,1014.2017822265625 +72.69,1013.7935180664062 +72.7,1013.39599609375 +72.71,1013.0347900390625 +72.72,1012.689697265625 +72.73,1012.3546752929688 +72.74,1012.04345703125 +72.75,1011.7479858398438 +72.76,1011.466064453125 +72.77,1011.2053833007812 +72.78,1010.9678344726562 +72.79,1010.7352905273438 +72.8,1010.55126953125 +72.81,1010.3737182617188 +72.82,1010.21044921875 +72.83,1010.0632934570312 +72.84,1009.9241943359375 +72.85,1009.806884765625 +72.86,1009.7012329101562 +72.87,1009.5992431640625 +72.88,1009.5007934570312 +72.89,1009.4058227539062 +72.9,1009.330078125 +72.91,1009.2418823242188 +72.92,1009.156982421875 +72.93,1009.075439453125 +72.94,1008.9931030273438 +72.95,1008.9002075195312 +72.96,1008.8106079101562 +72.97,1008.7105102539062 +72.98,1008.6078491210938 +72.99,1008.4927368164062 +73,1008.381103515625 +73.01,1008.27099609375 +73.02,1008.1485595703125 +73.03,1008.019775390625 +73.04,1007.8866577148438 +73.05,1007.7572631835938 +73.06,1007.609619140625 +73.07,1007.4617919921875 +73.08,1007.3137817382812 +73.09,1007.1675415039062 +73.1,1007.0151977539062 +73.11,1006.8389892578125 +73.12,1006.6668090820312 +73.13,1006.49462890625 +73.14,1006.3126220703125 +73.15,1006.1248168945312 +73.16,1005.9134521484375 +73.17,1005.7025146484375 +73.18,1005.4762573242188 +73.19,1005.2545166015625 +73.2,1005.0155029296875 +73.21,1004.7791748046875 +73.22,1004.5237426757812 +73.23,1004.2612915039062 +73.24,1004.0018310546875 +73.25,1003.7472534179688 +73.26,1003.4896240234375 +73.27,1003.2269897460938 +73.28,1002.96337890625 +73.29,1002.6829833984375 +73.3,1002.3978271484375 +73.31,1002.1178588867188 +73.32,1001.8390502929688 +73.33,1001.5535278320312 +73.34,1001.265380859375 +73.35,1000.9745483398438 +73.36,1000.68310546875 +73.37,1000.3948974609375 +73.38,1000.0863037109375 +73.39,999.7811889648438 +73.4,999.4796142578125 +73.41,999.1814575195312 +73.42,998.888671875 +73.43,998.603271484375 +73.44,998.3211059570312 +73.45,998.044189453125 +73.46,997.7705078125 +73.47,997.5001220703125 +73.48,997.2249145507812 +73.49,996.9549560546875 +73.5,996.6901245117188 +73.51,996.4244995117188 +73.52,996.1639404296875 +73.53,995.9143676757812 +73.54,995.6697998046875 +73.55,995.43408203125 +73.56,995.1932373046875 +73.57,994.9671630859375 +73.58,994.7457885742188 +73.59,994.5369873046875 +73.6,994.332763671875 +73.61,994.1369018554688 +73.62,993.9474487304688 +73.63,993.7622680664062 +73.64,993.5972290039062 +73.65,993.446044921875 +73.66,993.30078125 +73.67,993.167236328125 +73.68,993.0374145507812 +73.69,992.921142578125 +73.7,992.8162841796875 +73.71,992.7147827148438 +73.72,992.6185302734375 +73.73,992.5078125 +73.74,992.4182739257812 +73.75,992.3319091796875 +73.76,992.2485961914062 +73.77,992.1585083007812 +73.78,992.0636596679688 +73.79,991.966064453125 +73.8,991.8578491210938 +73.81,991.7449951171875 +73.82,991.6295776367188 +73.83,991.503662109375 +73.84,991.3634033203125 +73.85,991.2109375 +73.86,991.0385131835938 +73.87,990.8009033203125 +73.88,990.5579833984375 +73.89,990.2625732421875 +73.9,989.9663696289062 +73.91,989.645751953125 +73.92,989.3267211914062 +73.93,988.9913940429688 +73.94,988.65185546875 +73.95,988.3160400390625 +73.96,987.9444580078125 +73.97,987.565185546875 +73.98,987.17041015625 +73.99,986.7779541015625 +74,986.391845703125 +74.01,986.011962890625 +74.02,985.6422729492188 +74.03,985.2924194335938 +74.04,984.9564208984375 +74.05,984.628173828125 +74.06,984.3194580078125 +74.07,984.0557250976562 +74.08,983.8108520507812 +74.09,983.5728759765625 +74.1,983.3397216796875 +74.11,983.111328125 +74.12,982.8895874023438 +74.13,982.682373046875 +74.14,982.4796752929688 +74.15,982.2814331054688 +74.16,982.0758056640625 +74.17,981.8549194335938 +74.18,981.6327514648438 +74.19,981.4013671875 +74.2,981.1629028320312 +74.21,980.9075927734375 +74.22,980.6415405273438 +74.23,980.319580078125 +74.24,979.9993286132812 +74.25,979.6276245117188 +74.26,979.2424926757812 +74.27,978.8637084960938 +74.28,978.4912719726562 +74.29,978.1251220703125 +74.3,977.76513671875 +74.31,977.411376953125 +74.32,977.0755004882812 +74.33,976.7515258789062 +74.34,976.4609375 +74.35,976.199462890625 +74.36,975.96484375 +74.37,975.7410278320312 +74.38,975.5338134765625 +74.39,975.3549194335938 +74.4,975.1823120117188 +74.41,975.0159301757812 +74.42,974.855712890625 +74.43,974.7232666015625 +74.44,974.6143798828125 +74.45,974.5267944335938 +74.46,974.4682006835938 +74.47,974.4243774414062 +74.48,974.3893432617188 +74.49,974.3589477539062 +74.5,974.3333129882812 +74.51,974.3102416992188 +74.52,974.2916259765625 +74.53,974.2755126953125 +74.54,974.2618408203125 +74.55,974.2505493164062 +74.56,974.231689453125 +74.57,974.2034301757812 +74.58,974.1500854492188 +74.59,974.0975341796875 +74.6,974.045654296875 +74.61,973.9313354492188 +74.62,973.8085327148438 +74.63,973.6871337890625 +74.64,973.4962158203125 +74.65,973.2640380859375 +74.66,973.0265502929688 +74.67,972.7030639648438 +74.68,972.3829956054688 +74.69,972.0133056640625 +74.7,971.6495361328125 +74.71,971.2916259765625 +74.72,970.9395751953125 +74.73,970.5933837890625 +74.74,970.286376953125 +74.75,969.9926147460938 +74.76,969.741455078125 +74.77,969.5128173828125 +74.78,969.3734130859375 +74.79,969.2376098632812 +74.8,969.1212158203125 +74.81,969.0594482421875 +74.82,969.0221557617188 +74.83,969.0110473632812 +74.84,969.0061645507812 +74.85,969.0349731445312 +74.86,969.0714721679688 +74.87,969.1096801757812 +74.88,969.1495361328125 +74.89,969.1753540039062 +74.9,969.2028198242188 +74.91,969.2319946289062 +74.92,969.2490234375 +74.93,969.2638549804688 +74.94,969.2804565429688 +74.95,969.2948608398438 +74.96,969.3070678710938 +74.97,969.3170166015625 +74.98,969.31689453125 +74.99,969.3126220703125 +75,969.3102416992188 +75.01,969.3076782226562 +75.02,969.3069458007812 +75.03,969.3079223632812 +75.04,969.3106079101562 +75.05,969.3109741210938 +75.06,969.30908203125 +75.07,969.3088989257812 +75.08,969.3143310546875 +75.09,969.3212890625 +75.1,969.329833984375 +75.11,969.3181762695312 +75.12,969.3004150390625 +75.13,969.2823486328125 +75.14,969.2561645507812 +75.15,969.2318725585938 +75.16,969.203369140625 +75.17,969.1766967773438 +75.18,969.1478271484375 +75.19,969.09521484375 +75.2,969.062255859375 +75.21,969.0390014648438 +75.22,969.0095825195312 +75.23,968.98193359375 +75.24,968.938232421875 +75.25,968.8964233398438 +75.26,968.836669921875 +75.27,968.75146484375 +75.28,968.6506958007812 +75.29,968.5208740234375 +75.3,968.3623046875 +75.31,968.1870727539062 +75.32,967.9520874023438 +75.33,967.6953735351562 +75.34,967.4131469726562 +75.35,967.1333618164062 +75.36,966.8126220703125 +75.37,966.4769897460938 +75.38,966.1226806640625 +75.39,965.7637329101562 +75.4,965.4041137695312 +75.41,965.0398559570312 +75.42,964.680908203125 +75.43,964.3095703125 +75.44,963.929931640625 +75.45,963.5322265625 +75.46,963.1383666992188 +75.47,962.750244140625 +75.48,962.3422241210938 +75.49,961.9401245117188 +75.5,961.5439453125 +75.51,961.1536254882812 +75.52,960.7691040039062 +75.53,960.384521484375 +75.54,959.995849609375 +75.55,959.613037109375 +75.56,959.2243041992188 +75.57,958.8414916992188 +75.58,958.4429321289062 +75.59,958.0386352539062 +75.6,957.6384887695312 +75.61,957.2444458007812 +75.62,956.8486938476562 +75.63,956.4511108398438 +75.64,956.0420532226562 +75.65,955.6392822265625 +75.66,955.2427978515625 +75.67,954.8701782226562 +75.68,954.5133056640625 +75.69,954.1799926757812 +75.7,953.860107421875 +75.71,953.5496215820312 +75.72,953.293701171875 +75.73,953.0485229492188 +75.74,952.8355102539062 +75.75,952.6622924804688 +75.76,952.4950561523438 +75.77,952.3336791992188 +75.78,952.1761474609375 +75.79,952.0244140625 +75.8,951.8764038085938 +75.81,951.7301025390625 +75.82,951.5894165039062 +75.83,951.4542846679688 +75.84,951.3424072265625 +75.85,951.2338256835938 +75.86,951.1284790039062 +75.87,951.0263671875 +75.88,950.9155883789062 +75.89,950.8198852539062 +75.9,950.7272338867188 +75.91,950.6257934570312 +75.92,950.5235595703125 +75.93,950.41455078125 +75.94,950.3087768554688 +75.95,950.2001953125 +75.96,950.0613403320312 +75.97,949.9200439453125 +75.98,949.7802124023438 +75.99,949.6222534179688 +76,949.4521484375 +76.01,949.25830078125 +76.02,949.0606079101562 +76.03,948.861083984375 +76.04,948.604736328125 +76.05,948.3451538085938 +76.06,948.0686645507812 +76.07,947.7714233398438 +76.08,947.4734497070312 +76.09,947.1609497070312 +76.1,946.8026733398438 +76.11,946.4462280273438 +76.12,946.070068359375 +76.13,945.6683959960938 +76.14,945.2533569335938 +76.15,944.8349609375 +76.16,944.4210815429688 +76.17,944.0136108398438 +76.18,943.6007690429688 +76.19,943.1884765625 +76.2,942.78076171875 +76.21,942.3755493164062 +76.22,941.976806640625 +76.23,941.5845336914062 +76.24,941.1985473632812 +76.25,940.8189086914062 +76.26,940.453369140625 +76.27,940.105712890625 +76.28,939.8032836914062 +76.29,939.5142211914062 +76.3,939.2344360351562 +76.31,938.9638061523438 +76.32,938.6983642578125 +76.33,938.4380493164062 +76.34,938.182861328125 +76.35,937.9366455078125 +76.36,937.6973266601562 +76.37,937.46484375 +76.38,937.2371826171875 +76.39,937.0280151367188 +76.4,936.8234252929688 +76.41,936.6233520507812 +76.42,936.431640625 +76.43,936.2560424804688 +76.44,936.0846557617188 +76.45,935.9154663085938 +76.46,935.7503662109375 +76.47,935.59130859375 +76.48,935.438232421875 +76.49,935.2911376953125 +76.5,935.16357421875 +76.51,935.0455322265625 +76.52,934.9310302734375 +76.53,934.823974609375 +76.54,934.7281494140625 +76.55,934.6689453125 +76.56,934.6244506835938 +76.57,934.6455078125 +76.58,934.6822509765625 +76.59,934.7994384765625 +76.6,934.9215087890625 +76.61,935.0503540039062 +76.62,935.20556640625 +76.63,935.3612670898438 +76.64,935.5743408203125 +76.65,935.8343505859375 +76.66,936.0936279296875 +76.67,936.3521728515625 +76.68,936.60986328125 +76.69,936.86669921875 +76.7,937.0735473632812 +76.71,937.2229614257812 +76.72,937.3155517578125 +76.73,937.4029541015625 +76.74,937.4556884765625 +76.75,937.4996337890625 +76.76,937.5408325195312 +76.77,937.5792236328125 +76.78,937.5952758789062 +76.79,937.5733642578125 +76.8,937.5452880859375 +76.81,937.5131225585938 +76.82,937.4807739257812 +76.83,937.4443359375 +76.84,937.4038696289062 +76.85,937.3554077148438 +76.86,937.293212890625 +76.87,937.20556640625 +76.88,937.1203002929688 +76.89,937.0431518554688 +76.9,936.96826171875 +76.91,936.8896484375 +76.92,936.813232421875 +76.93,936.7389526367188 +76.94,936.6668701171875 +76.95,936.5909423828125 +76.96,936.5172119140625 +76.97,936.445556640625 +76.98,936.3760375976562 +76.99,936.3085327148438 +77,936.2431030273438 +77.01,936.1815795898438 +77.02,936.1201171875 +77.03,936.0508422851562 +77.04,935.9894409179688 +77.05,935.929931640625 +77.06,935.8665161132812 +77.07,935.8049926757812 +77.08,935.745361328125 +77.09,935.6876831054688 +77.1,935.6318359375 +77.11,935.577880859375 +77.12,935.5217895507812 +77.13,935.467529296875 +77.14,935.4150390625 +77.15,935.3643798828125 +77.16,935.3154907226562 +77.17,935.2662963867188 +77.18,935.2109375 +77.19,935.1416625976562 +77.2,935.0645751953125 +77.21,934.9855346679688 +77.22,934.902587890625 +77.23,934.8217163085938 +77.24,934.7428588867188 +77.25,934.6698608398438 +77.26,934.5988159179688 +77.27,934.5296630859375 +77.28,934.4584350585938 +77.29,934.3381958007812 +77.3,934.2163696289062 +77.31,934.096923828125 +77.32,933.9740600585938 +77.33,933.8496704101562 +77.34,933.7003173828125 +77.35,933.5536499023438 +77.36,933.40966796875 +77.37,933.2662963867188 +77.38,933.1256103515625 +77.39,932.983642578125 +77.4,932.8384399414062 +77.41,932.6920166015625 +77.42,932.5169067382812 +77.43,932.3447875976562 +77.44,932.152099609375 +77.45,931.962646484375 +77.46,931.7704467773438 +77.47,931.5520629882812 +77.48,931.333251953125 +77.49,931.1061401367188 +77.5,930.8532104492188 +77.51,930.55517578125 +77.52,930.245849609375 +77.53,929.9409790039062 +77.54,929.6386108398438 +77.55,929.3406982421875 +77.56,929.0413208007812 +77.57,928.7406005859375 +77.58,928.4443359375 +77.59,928.1251220703125 +77.6,927.8106079101562 +77.61,927.4948120117188 +77.62,927.1779174804688 +77.63,926.8716430664062 +77.64,926.5700073242188 +77.65,926.2788696289062 +77.66,926.0000610351562 +77.67,925.766845703125 +77.68,925.53955078125 +77.69,925.316162109375 +77.7,925.0966796875 +77.71,924.8810424804688 +77.72,924.6868286132812 +77.73,924.4961547851562 +77.74,924.3090209960938 +77.75,924.1470336914062 +77.76,923.98828125 +77.77,923.834716796875 +77.78,923.684326171875 +77.79,923.537109375 +77.8,923.3910522460938 +77.81,923.2401733398438 +77.82,923.0924682617188 +77.83,922.9479370117188 +77.84,922.8064575195312 +77.85,922.6680297851562 +77.86,922.5325927734375 +77.87,922.3982543945312 +77.88,922.266845703125 +77.89,922.1383666992188 +77.9,922.0264892578125 +77.91,921.9173583984375 +77.92,921.8187866210938 +77.93,921.7012329101562 +77.94,921.58642578125 +77.95,921.472412109375 +77.96,921.3611450195312 +77.97,921.2446899414062 +77.98,921.1035766601562 +77.99,920.9576416015625 +78,920.7677612304688 +78.01,920.569580078125 +78.02,920.373046875 +78.03,920.1664428710938 +78.04,919.9654541015625 +78.05,919.74658203125 +78.06,919.531494140625 +78.07,919.3202514648438 +78.08,919.11279296875 +78.09,918.9090576171875 +78.1,918.697265625 +78.11,918.4891967773438 +78.12,918.273193359375 +78.13,918.0531616210938 +78.14,917.8076171875 +78.15,917.5447387695312 +78.16,917.2470703125 +78.17,916.8621826171875 +78.18,916.47705078125 +78.19,916.0486450195312 +78.2,915.6009521484375 +78.21,915.1244506835938 +78.22,914.6350708007812 +78.23,914.1212768554688 +78.24,913.6146240234375 +78.25,913.1150512695312 +78.26,912.62255859375 +78.27,912.1370849609375 +78.28,911.6918334960938 +78.29,911.255126953125 +78.3,910.850341796875 +78.31,910.4616088867188 +78.32,910.1101684570312 +78.33,909.7644653320312 +78.34,909.4420166015625 +78.35,909.1270141601562 +78.36,908.8310546875 +78.37,908.546142578125 +78.38,908.2662963867188 +78.39,907.9993286132812 +78.4,907.7450561523438 +78.41,907.5210571289062 +78.42,907.301513671875 +78.43,907.08837890625 +78.44,906.8776245117188 +78.45,906.6712646484375 +78.46,906.4574584960938 +78.47,906.248046875 +78.48,906.0116577148438 +78.49,905.7681274414062 +78.5,905.52734375 +78.51,905.2697143554688 +78.52,905.0169067382812 +78.53,904.7454223632812 +78.54,904.4711303710938 +78.55,904.2018432617188 +78.56,903.9199829101562 +78.57,903.6354370117188 +78.58,903.35595703125 +78.59,903.0797119140625 +78.6,902.8085327148438 +78.61,902.5423583984375 +78.62,902.3046264648438 +78.63,902.0774536132812 +78.64,901.8587646484375 +78.65,901.650390625 +78.66,901.4464111328125 +78.67,901.266357421875 +78.68,901.1471557617188 +78.69,901.0314331054688 +78.7,900.9268798828125 +78.71,900.8256225585938 +78.72,900.7276000976562 +78.73,900.625 +78.74,900.523681640625 +78.75,900.3727416992188 +78.76,900.1942749023438 +78.77,899.9826049804688 +78.78,899.7303466796875 +78.79,899.3909912109375 +78.8,899.0299072265625 +78.81,898.5673828125 +78.82,898.11181640625 +78.83,897.6631469726562 +78.84,897.2213134765625 +78.85,896.7863159179688 +78.86,896.4127807617188 +78.87,896.09228515625 +78.88,895.7852172851562 +78.89,895.6438598632812 +78.9,895.57275390625 +78.91,895.5046997070312 +78.92,895.4435424804688 +78.93,895.3853149414062 +78.94,895.3299560546875 +78.95,895.2911376953125 +78.96,895.2549438476562 +78.97,895.2056884765625 +78.98,895.1532592773438 +78.99,895.1016235351562 +79,895.0115966796875 +79.01,894.9207763671875 +79.02,894.8330078125 +79.03,894.7404174804688 +79.04,894.6509399414062 +79.05,894.5645141601562 +79.06,894.4810180664062 +79.07,894.4063110351562 +79.08,894.3344116210938 +79.09,894.2554931640625 +79.1,894.179443359375 +79.11,894.104248046875 +79.12,894.0318603515625 +79.13,893.962158203125 +79.14,893.8873901367188 +79.15,893.7821044921875 +79.16,893.6681518554688 +79.17,893.5534057617188 +79.18,893.4417724609375 +79.19,893.3331909179688 +79.2,893.2276000976562 +79.21,893.1250610351562 +79.22,892.9962158203125 +79.23,892.8666381835938 +79.24,892.7246704101562 +79.25,892.5860595703125 +79.26,892.4507446289062 +79.27,892.3187255859375 +79.28,892.18408203125 +79.29,892.0389404296875 +79.3,891.889404296875 +79.31,891.7393188476562 +79.32,891.5927124023438 +79.33,891.451416015625 +79.34,891.3056030273438 +79.35,891.1630859375 +79.36,891.01220703125 +79.37,890.86474609375 +79.38,890.7185668945312 +79.39,890.5777587890625 +79.4,890.440185546875 +79.41,890.3038940429688 +79.42,890.1610717773438 +79.43,889.9864501953125 +79.44,889.8154296875 +79.45,889.66943359375 +79.46,889.5052490234375 +79.47,889.3446044921875 +79.48,889.1795043945312 +79.49,888.9945068359375 +79.5,888.7996215820312 +79.51,888.5753173828125 +79.52,888.3336791992188 +79.53,888.0729370117188 +79.54,887.8167114257812 +79.55,887.56494140625 +79.56,887.3156127929688 +79.57,887.0629272460938 +79.58,886.822509765625 +79.59,886.58642578125 +79.6,886.3566284179688 +79.61,886.1310424804688 +79.62,885.93310546875 +79.63,885.748779296875 +79.64,885.5740966796875 +79.65,885.406982421875 +79.66,885.2434692382812 +79.67,885.08349609375 +79.68,884.9270629882812 +79.69,884.7702026367188 +79.7,884.5836791992188 +79.71,884.4009399414062 +79.72,884.2219848632812 +79.73,884.0466918945312 +79.74,883.8750610351562 +79.75,883.71484375 +79.76,883.58154296875 +79.77,883.4593505859375 +79.78,883.3402709960938 +79.79,883.2496337890625 +79.8,883.1617431640625 +79.81,883.0844116210938 +79.82,883.0097045898438 +79.83,882.9297485351562 +79.84,882.8524169921875 +79.85,882.7991333007812 +79.86,882.7364501953125 +79.87,882.6878662109375 +79.88,882.6473999023438 +79.89,882.6032104492188 +79.9,882.549560546875 +79.91,882.4981689453125 +79.92,882.448974609375 +79.93,882.3356323242188 +79.94,882.22509765625 +79.95,882.1173706054688 +79.96,882.000732421875 +79.97,881.886962890625 +79.98,881.7584838867188 +79.99,881.6290893554688 +80,881.5026245117188 +80.01,881.379150390625 +80.02,881.258544921875 +80.03,881.135009765625 +80.04,880.9987182617188 +80.05,880.8654174804688 +80.06,880.7351684570312 +80.07,880.6078491210938 +80.08,880.4834594726562 +80.09,880.352294921875 +80.1,880.22412109375 +80.11,880.098876953125 +80.12,879.9765014648438 +80.13,879.8569946289062 +80.14,879.6622924804688 +80.15,879.4711303710938 +80.16,879.2698974609375 +80.17,879.0372924804688 +80.18,878.8087158203125 +80.19,878.5743408203125 +80.2,878.3440551757812 +80.21,878.1119384765625 +80.22,877.8818969726562 +80.23,877.6558227539062 +80.24,877.4240112304688 +80.25,877.1962280273438 +80.26,876.9725341796875 +80.27,876.7351684570312 +80.28,876.501953125 +80.29,876.2728881835938 +80.3,876.0654296875 +80.31,875.86181640625 +80.32,875.6619873046875 +80.33,875.4659423828125 +80.34,875.2736206054688 +80.35,875.0713500976562 +80.36,874.8728637695312 +80.37,874.65478515625 +80.38,874.438720703125 +80.39,874.1876831054688 +80.4,873.9077758789062 +80.41,873.6248168945312 +80.42,873.3445434570312 +80.43,873.0689697265625 +80.44,872.7980346679688 +80.45,872.5336303710938 +80.46,872.2717895507812 +80.47,872.0144653320312 +80.48,871.7362670898438 +80.49,871.4627075195312 +80.5,871.1939086914062 +80.51,870.923828125 +80.52,870.6544799804688 +80.53,870.3839111328125 +80.54,870.1102905273438 +80.55,869.8413696289062 +80.56,869.5751953125 +80.57,869.3136596679688 +80.58,869.0372314453125 +80.59,868.7655639648438 +80.6,868.4947509765625 +80.61,868.2266845703125 +80.62,867.9633178710938 +80.63,867.7046508789062 +80.64,867.4214477539062 +80.65,867.13720703125 +80.66,866.85595703125 +80.67,866.5816040039062 +80.68,866.31201171875 +80.69,866.0472412109375 +80.7,865.7871704101562 +80.71,865.5299072265625 +80.72,865.2909545898438 +80.73,865.0565185546875 +80.74,864.8264770507812 +80.75,864.6008911132812 +80.76,864.3699340820312 +80.77,864.1414794921875 +80.78,863.9174194335938 +80.79,863.68212890625 +80.8,863.4610595703125 +80.81,863.2443237304688 +80.82,863.0319213867188 +80.83,862.8237915039062 +80.84,862.610107421875 +80.85,862.3987426757812 +80.86,862.2033081054688 +80.87,862.011962890625 +80.88,861.8343505859375 +80.89,861.66064453125 +80.9,861.4907836914062 +80.91,861.32470703125 +80.92,861.181884765625 +80.93,861.0445556640625 +80.94,860.91650390625 +80.95,860.791748046875 +80.96,860.6761474609375 +80.97,860.5521240234375 +80.98,860.431396484375 +80.99,860.3178100585938 +81,860.2073974609375 +81.01,860.1467895507812 +81.02,860.0985717773438 +81.03,860.052734375 +81.04,860.0151977539062 +81.05,859.97412109375 +81.06,859.9412841796875 +81.07,859.91064453125 +81.08,859.8822021484375 +81.09,859.85595703125 +81.1,859.827880859375 +81.11,859.8019409179688 +81.12,859.772216796875 +81.13,859.73876953125 +81.14,859.69189453125 +81.15,859.647216796875 +81.16,859.5872192382812 +81.17,859.5159912109375 +81.18,859.447265625 +81.19,859.3809814453125 +81.2,859.3053588867188 +81.21,859.2263793945312 +81.22,859.1031494140625 +81.23,858.98095703125 +81.24,858.81494140625 +81.25,858.5978393554688 +81.26,858.3846435546875 +81.27,858.1753540039062 +81.28,857.966064453125 +81.29,857.7410888671875 +81.3,857.5104370117188 +81.31,857.2819213867188 +81.32,857.0574951171875 +81.33,856.8370971679688 +81.34,856.5973510742188 +81.35,856.35986328125 +81.36,856.083740234375 +81.37,855.798583984375 +81.38,855.4987182617188 +81.39,855.1978759765625 +81.4,854.8863525390625 +81.41,854.5468139648438 +81.42,854.15234375 +81.43,853.7598876953125 +81.44,853.3616333007812 +81.45,852.9052734375 +81.46,852.4263916015625 +81.47,851.9019165039062 +81.48,851.3654174804688 +81.49,850.8267211914062 +81.5,850.2761840820312 +81.51,849.7256469726562 +81.52,849.1441040039062 +81.53,848.55517578125 +81.54,847.9744873046875 +81.55,847.4020385742188 +81.56,846.8377685546875 +81.57,846.2815551757812 +81.58,845.7334594726562 +81.59,845.201171875 +81.6,844.6864013671875 +81.61,844.2298583984375 +81.62,843.7998657226562 +81.63,843.3768310546875 +81.64,842.9625854492188 +81.65,842.5784301757812 +81.66,842.2105712890625 +81.67,841.8490600585938 +81.68,841.51513671875 +81.69,841.22412109375 +81.7,840.940673828125 +81.71,840.6665649414062 +81.72,840.4212036132812 +81.73,840.2178955078125 +81.74,840.019287109375 +81.75,839.8330078125 +81.76,839.6512451171875 +81.77,839.4661254882812 +81.78,839.293212890625 +81.79,839.1324462890625 +81.8,838.9758911132812 +81.81,838.8234252929688 +81.82,838.682861328125 +81.83,838.567626953125 +81.84,838.4561157226562 +81.85,838.3482055664062 +81.86,838.243896484375 +81.87,838.1236572265625 +81.88,838.0071411132812 +81.89,837.8922729492188 +81.9,837.777099609375 +81.91,837.6597290039062 +81.92,837.5440063476562 +81.93,837.431884765625 +81.94,837.2960815429688 +81.95,837.1561889648438 +81.96,837.0201416015625 +81.97,836.8878173828125 +81.98,836.7319946289062 +81.99,836.580078125 +82,836.453369140625 +82.01,836.3400268554688 +82.02,836.2340698242188 +82.03,836.1275634765625 +82.04,836.0243530273438 +82.05,835.9147338867188 +82.06,835.7871704101562 +82.07,835.6534423828125 +82.08,835.5135498046875 +82.09,835.3695678710938 +82.1,835.2156372070312 +82.11,835.034423828125 +82.12,834.8553466796875 +82.13,834.6764526367188 +82.14,834.4918823242188 +82.15,834.2977294921875 +82.16,834.0689697265625 +82.17,833.8408203125 +82.18,833.6094970703125 +82.19,833.369140625 +82.2,833.1043090820312 +82.21,832.8192138671875 +82.22,832.5042724609375 +82.23,832.1792602539062 +82.24,831.84619140625 +82.25,831.4761352539062 +82.26,831.0442504882812 +82.27,830.611328125 +82.28,830.169677734375 +82.29,829.7154541015625 +82.3,829.260498046875 +82.31,828.8125610351562 +82.32,828.3716430664062 +82.33,827.9376220703125 +82.34,827.510498046875 +82.35,827.0902099609375 +82.36,826.6825561523438 +82.37,826.3223266601562 +82.38,825.9760131835938 +82.39,825.6455078125 +82.4,825.3363647460938 +82.41,825.0756225585938 +82.42,824.8414306640625 +82.43,824.6218872070312 +82.44,824.4303588867188 +82.45,824.2510986328125 +82.46,824.1013793945312 +82.47,823.9577026367188 +82.48,823.8179931640625 +82.49,823.68603515625 +82.5,823.561767578125 +82.51,823.4548950195312 +82.52,823.390380859375 +82.53,823.340576171875 +82.54,823.3014526367188 +82.55,823.26708984375 +82.56,823.2431640625 +82.57,823.2335205078125 +82.58,823.2262573242188 +82.59,823.221435546875 +82.6,823.2189331054688 +82.61,823.2186889648438 +82.62,823.220703125 +82.63,823.2171630859375 +82.64,823.2158203125 +82.65,823.2050170898438 +82.66,823.1906127929688 +82.67,823.1707153320312 +82.68,823.1375732421875 +82.69,823.064208984375 +82.7,822.9839477539062 +82.71,822.8871459960938 +82.72,822.7584228515625 +82.73,822.5923461914062 +82.74,822.4280395507812 +82.75,822.2364501953125 +82.76,822.0410766601562 +82.77,821.836181640625 +82.78,821.6334228515625 +82.79,821.434814453125 +82.8,821.2363891601562 +82.81,821.0419921875 +82.82,820.8419799804688 +82.83,820.6460571289062 +82.84,820.4463500976562 +82.85,820.258544921875 +82.86,820.0902099609375 +82.87,819.9411010742188 +82.88,819.8013916015625 +82.89,819.6903686523438 +82.9,819.5882568359375 +82.91,819.4891967773438 +82.92,819.40087890625 +82.93,819.3232421875 +82.94,819.2638549804688 +82.95,819.2186279296875 +82.96,819.185546875 +82.97,819.1546630859375 +82.98,819.1259765625 +82.99,819.0994873046875 +83,819.0751953125 +83.01,819.0413208007812 +83.02,818.99609375 +83.03,818.9453125 +83.04,818.89501953125 +83.05,818.82373046875 +83.06,818.7200927734375 +83.07,818.6094970703125 +83.08,818.4960327148438 +83.09,818.383544921875 +83.1,818.2584228515625 +83.11,818.1343994140625 +83.12,817.9998779296875 +83.13,817.8626098632812 +83.14,817.7110595703125 +83.15,817.5453491210938 +83.16,817.3792114257812 +83.17,817.2088012695312 +83.18,817.0418701171875 +83.19,816.8705444335938 +83.2,816.7027587890625 +83.21,816.5307006835938 +83.22,816.2981567382812 +83.23,816.0658569335938 +83.24,815.8299560546875 +83.25,815.574951171875 +83.26,815.3185424804688 +83.27,815.0084228515625 +83.28,814.693603515625 +83.29,814.3257446289062 +83.3,813.9634399414062 +83.31,813.6047973632812 +83.32,813.247802734375 +83.33,812.8634643554688 +83.34,812.4849243164062 +83.35,812.1122436523438 +83.36,811.7297973632812 +83.37,811.353271484375 +83.38,810.998046875 +83.39,810.6484985351562 +83.4,810.3103637695312 +83.41,809.9835205078125 +83.42,809.6737060546875 +83.43,809.3690795898438 +83.44,809.0579833984375 +83.45,808.7657470703125 +83.46,808.4785766601562 +83.47,808.180908203125 +83.48,807.8846435546875 +83.49,807.5838012695312 +83.5,807.2881469726562 +83.51,806.9976806640625 +83.52,806.725830078125 +83.53,806.4764404296875 +83.54,806.2317504882812 +83.55,806.0110473632812 +83.56,805.8006591796875 +83.57,805.596435546875 +83.58,805.4681396484375 +83.59,805.3685302734375 +83.6,805.2720336914062 +83.61,805.1786499023438 +83.62,805.0961303710938 +83.63,805.0281372070312 +83.64,804.9725952148438 +83.65,804.9119262695312 +83.66,804.8539428710938 +83.67,804.798583984375 +83.68,804.7361450195312 +83.69,804.6763916015625 +83.7,804.6212158203125 +83.71,804.568603515625 +83.72,804.5184936523438 +83.73,804.4688720703125 +83.74,804.4216918945312 +83.75,804.376953125 +83.76,804.3345336914062 +83.77,804.2905883789062 +83.78,804.2489624023438 +83.79,804.2096557617188 +83.8,804.1512451171875 +83.81,804.0952758789062 +83.82,804.033935546875 +83.83,803.9577026367188 +83.84,803.884033203125 +83.85,803.7780151367188 +83.86,803.6728515625 +83.87,803.570556640625 +83.88,803.45947265625 +83.89,803.3280639648438 +83.9,803.1997680664062 +83.91,803.0745849609375 +83.92,802.9175415039062 +83.93,802.72900390625 +83.94,802.5421752929688 +83.95,802.3416748046875 +83.96,802.1314086914062 +83.97,801.930908203125 +83.98,801.7244873046875 +83.99,801.5103759765625 +84,801.2750244140625 +84.01,801.0399780273438 +84.02,800.8092041015625 +84.03,800.5767211914062 +84.04,800.27294921875 +84.05,799.9683227539062 +84.06,799.662841796875 +84.07,799.3681640625 +84.08,799.0531616210938 +84.09,798.7432250976562 +84.1,798.4383544921875 +84.11,798.097900390625 +84.12,797.7628784179688 +84.13,797.4332275390625 +84.14,797.1339721679688 +84.15,796.8513793945312 +84.16,796.5736083984375 +84.17,796.3045043945312 +84.18,796.074951171875 +84.19,795.8497314453125 +84.2,795.6365356445312 +84.21,795.429443359375 +84.22,795.2263793945312 +84.23,795.0274658203125 +84.24,794.8228759765625 +84.25,794.6088256835938 +84.26,794.3970336914062 +84.27,794.1778564453125 +84.28,793.9705810546875 +84.29,793.7674560546875 +84.3,793.5548706054688 +84.31,793.3309936523438 +84.32,793.1113891601562 +84.33,792.8960571289062 +84.34,792.6848754882812 +84.35,792.4779052734375 +84.36,792.2538452148438 +84.37,792.0340576171875 +84.38,791.8185424804688 +84.39,791.5995483398438 +84.4,791.3848266601562 +84.41,791.17431640625 +84.42,790.962158203125 +84.43,790.7542114257812 +84.44,790.5504150390625 +84.45,790.3816528320312 +84.46,790.1857299804688 +84.47,789.9822387695312 +84.48,789.771240234375 +84.49,789.5682983398438 +84.5,789.3694458007812 +84.51,789.1746826171875 +84.52,788.9896850585938 +84.53,788.80859375 +84.54,788.6314086914062 +84.55,788.4580688476562 +84.56,788.3001098632812 +84.57,788.151611328125 +84.58,788.0143432617188 +84.59,787.8862915039062 +84.6,787.7615356445312 +84.61,787.6400756835938 +84.62,787.5217895507812 +84.63,787.40673828125 +84.64,787.2947998046875 +84.65,787.1859741210938 +84.66,787.0763549804688 +84.67,786.9407958984375 +84.68,786.80859375 +84.69,786.6796875 +84.7,786.5791625976562 +84.71,786.4815063476562 +84.72,786.3867797851562 +84.73,786.2987670898438 +84.74,786.2135009765625 +84.75,786.127197265625 +84.76,786.0435791015625 +84.77,785.9530029296875 +84.78,785.859375 +84.79,785.749267578125 +84.8,785.616943359375 +84.81,785.48779296875 +84.82,785.3521118164062 +84.83,785.2196655273438 +84.84,785.0807495117188 +84.85,784.9430541992188 +84.86,784.8047485351562 +84.87,784.6561279296875 +84.88,784.4992065429688 +84.89,784.3399047851562 +84.9,784.1820678710938 +84.91,784.0159912109375 +84.92,783.8534545898438 +84.93,783.6943359375 +84.94,783.5386352539062 +84.95,783.3998413085938 +84.96,783.2738647460938 +84.97,783.1702880859375 +84.98,783.069580078125 +84.99,782.9716796875 +85,782.8765258789062 +85.01,782.7686157226562 +85.02,782.65966796875 +85.03,782.4879150390625 +85.04,782.2442626953125 +85.05,782.0029907226562 +85.06,781.7620239257812 +85.07,781.515625 +85.08,781.263916015625 +85.09,780.9991455078125 +85.1,780.7214965820312 +85.11,780.4369506835938 +85.12,780.1571044921875 +85.13,779.8741455078125 +85.14,779.6036376953125 +85.15,779.3338623046875 +85.16,779.0648803710938 +85.17,778.8004760742188 +85.18,778.5406494140625 +85.19,778.2756958007812 +85.2,778.0037231445312 +85.21,777.7326049804688 +85.22,777.4661254882812 +85.23,777.2024536132812 +85.24,776.9317626953125 +85.25,776.665771484375 +85.26,776.4044799804688 +85.27,776.13232421875 +85.28,775.79931640625 +85.29,775.4716796875 +85.3,775.1494140625 +85.31,774.82275390625 +85.32,774.5014038085938 +85.33,774.1854248046875 +85.34,773.868896484375 +85.35,773.5576782226562 +85.36,773.2382202148438 +85.37,772.924072265625 +85.38,772.6074829101562 +85.39,772.2924194335938 +85.4,771.9788208007812 +85.41,771.6705322265625 +85.42,771.3675537109375 +85.43,771.0678100585938 +85.44,770.7733154296875 +85.45,770.4840087890625 +85.46,770.2017822265625 +85.47,769.9246215820312 +85.48,769.6331787109375 +85.49,769.346923828125 +85.5,769.0657958984375 +85.51,768.7897338867188 +85.52,768.5187377929688 +85.53,768.2507934570312 +85.54,767.9820556640625 +85.55,767.71826171875 +85.56,767.4613037109375 +85.57,767.2073364257812 +85.58,766.9542846679688 +85.59,766.7099609375 +85.6,766.4664916992188 +85.61,766.227783203125 +85.62,765.9937744140625 +85.63,765.764404296875 +85.64,765.5319213867188 +85.65,765.3040771484375 +85.66,765.0808715820312 +85.67,764.8834228515625 +85.68,764.6902465820312 +85.69,764.5012817382812 +85.7,764.3164672851562 +85.71,764.1318969726562 +85.72,763.9553833007812 +85.73,763.7886352539062 +85.74,763.6258544921875 +85.75,763.4669189453125 +85.76,763.30224609375 +85.77,763.1298828125 +85.78,762.9595947265625 +85.79,762.7855224609375 +85.8,762.6154174804688 +85.81,762.4376831054688 +85.82,762.2639770507812 +85.83,762.09423828125 +85.84,761.9052734375 +85.85,761.716552734375 +85.86,761.5299682617188 +85.87,761.3397827148438 +85.88,761.1537475585938 +85.89,760.9717407226562 +85.9,760.7937622070312 +85.91,760.5908813476562 +85.92,760.384521484375 +85.93,760.1785888671875 +85.94,759.9635009765625 +85.95,759.7450561523438 +85.96,759.4962768554688 +85.97,759.2425537109375 +85.98,758.98974609375 +85.99,758.74169921875 +86,758.4984130859375 +86.01,758.2540893554688 +86.02,758.0028076171875 +86.03,757.7447509765625 +86.04,757.4722290039062 +86.05,757.195068359375 +86.06,756.8805541992188 +86.07,756.5638427734375 +86.08,756.2294921875 +86.09,755.8854370117188 +86.1,755.5432739257812 +86.11,755.2011108398438 +86.12,754.8589477539062 +86.13,754.518798828125 +86.14,754.1073608398438 +86.15,753.69287109375 +86.16,753.2753295898438 +86.17,752.8355712890625 +86.18,752.3777465820312 +86.19,751.9173583984375 +86.2,751.4390869140625 +86.21,750.9547729492188 +86.22,750.4471435546875 +86.23,749.947265625 +86.24,749.4530639648438 +86.25,748.962646484375 +86.26,748.47412109375 +86.27,747.9701538085938 +86.28,747.4547729492188 +86.29,746.9415893554688 +86.3,746.4363403320312 +86.31,745.9293212890625 +86.32,745.4263916015625 +86.33,744.9295043945312 +86.34,744.4116821289062 +86.35,743.8904418945312 +86.36,743.3716430664062 +86.37,742.8610229492188 +86.38,742.34130859375 +86.39,741.8297729492188 +86.4,741.3264770507812 +86.41,740.8292846679688 +86.42,740.3402099609375 +86.43,739.8572998046875 +86.44,739.3670043945312 +86.45,738.8963012695312 +86.46,738.4335327148438 +86.47,737.9747314453125 +86.48,737.50830078125 +86.49,737.0498046875 +86.5,736.59912109375 +86.51,736.15625 +86.52,735.7210693359375 +86.53,735.2801513671875 +86.54,734.8468627929688 +86.55,734.4020385742188 +86.56,733.9592895507812 +86.57,733.5242919921875 +86.58,733.102783203125 +86.59,732.6907958984375 +86.6,732.2727661132812 +86.61,731.8623046875 +86.62,731.45361328125 +86.63,731.0524291992188 +86.64,730.6663818359375 +86.65,730.2914428710938 +86.66,729.9236450195312 +86.67,729.5629272460938 +86.68,729.2054443359375 +86.69,728.8587646484375 +86.7,728.5189208984375 +86.71,728.1859741210938 +86.72,727.8597412109375 +86.73,727.536376953125 +86.74,727.2177124023438 +86.75,726.89794921875 +86.76,726.5771484375 +86.77,726.2572021484375 +86.78,725.94384765625 +86.79,725.6370239257812 +86.8,725.336669921875 +86.81,725.042724609375 +86.82,724.7532348632812 +86.83,724.4680786132812 +86.84,724.1891479492188 +86.85,723.9164428710938 +86.86,723.6497802734375 +86.87,723.3853759765625 +86.88,723.1250610351562 +86.89,722.8706665039062 +86.9,722.6202392578125 +86.91,722.3583984375 +86.92,722.1005859375 +86.93,721.8486938476562 +86.94,721.6044921875 +86.95,721.3660888671875 +86.96,721.1333618164062 +86.97,720.9061889648438 +86.98,720.6845703125 +86.99,720.468505859375 +87,720.2578125 +87.01,720.0582885742188 +87.02,719.8582153320312 +87.03,719.6691284179688 +87.04,719.4851684570312 +87.05,719.3004150390625 +87.06,719.12646484375 +87.07,718.9574584960938 +87.08,718.7874145507812 +87.09,718.6279907226562 +87.1,718.467529296875 +87.11,718.3193969726562 +87.12,718.1719970703125 +87.13,718.032958984375 +87.14,717.9041748046875 +87.15,717.7797241210938 +87.16,717.6690673828125 +87.17,717.5625 +87.18,717.4599609375 +87.19,717.3671264648438 +87.2,717.2723999023438 +87.21,717.1873168945312 +87.22,717.1001586914062 +87.23,717.0224609375 +87.24,716.9580078125 +87.25,716.89892578125 +87.26,716.854736328125 +87.27,716.815673828125 +87.28,716.7893676757812 +87.29,716.7755737304688 +87.3,716.7742309570312 +87.31,716.7755126953125 +87.32,716.7813720703125 +87.33,716.7897338867188 +87.34,716.800537109375 +87.35,716.8137817382812 +87.36,716.829345703125 +87.37,716.8452758789062 +87.38,716.863525390625 +87.39,716.86865234375 +87.4,716.8645629882812 +87.41,716.8533325195312 +87.42,716.8272705078125 +87.43,716.7960815429688 +87.44,716.765625 +87.45,716.7223510742188 +87.46,716.6492309570312 +87.47,716.5426025390625 +87.48,716.4296875 +87.49,716.3125 +87.5,716.156494140625 +87.51,715.9793701171875 +87.52,715.78125 +87.53,715.5336303710938 +87.54,715.2697143554688 +87.55,714.98193359375 +87.56,714.6781616210938 +87.57,714.3682861328125 +87.58,714.0485229492188 +87.59,713.7343139648438 +87.6,713.425537109375 +87.61,713.1221923828125 +87.62,712.8434448242188 +87.63,712.56982421875 +87.64,712.3184814453125 +87.65,712.0797119140625 +87.66,711.8533325195312 +87.67,711.656494140625 +87.68,711.5081176757812 +87.69,711.365478515625 +87.7,711.2553100585938 +87.71,711.17919921875 +87.72,711.1426391601562 +87.73,711.1220703125 +87.74,711.1941528320312 +87.75,711.27734375 +87.76,711.3675537109375 +87.77,711.458984375 +87.78,711.551513671875 +87.79,711.6528930664062 +87.8,711.791748046875 +87.81,711.9522705078125 +87.82,712.114990234375 +87.83,712.2778930664062 +87.84,712.4410400390625 +87.85,712.6043090820312 +87.86,712.7658081054688 +87.87,712.9063110351562 +87.88,712.9932861328125 +87.89,713.0331420898438 +87.9,713.0684814453125 +87.91,713.1050415039062 +87.92,713.1428833007812 +87.93,713.1801147460938 +87.94,713.2089233398438 +87.95,713.25048828125 +87.96,713.2816772460938 +87.97,713.3082885742188 +87.98,713.336181640625 +87.99,713.3595581054688 +88,713.3822021484375 +88.01,713.4060668945312 +88.02,713.4158325195312 +88.03,713.4269409179688 +88.04,713.4393310546875 +88.05,713.4453125 +88.06,713.4525756835938 +88.07,713.4611206054688 +88.08,713.470947265625 +88.09,713.4819946289062 +88.1,713.4846801757812 +88.11,713.4617919921875 +88.12,713.4404296875 +88.13,713.4205932617188 +88.14,713.4022216796875 +88.15,713.3392333984375 +88.16,713.255126953125 +88.17,713.1731567382812 +88.18,713.0836791992188 +88.19,712.9925537109375 +88.2,712.901611328125 +88.21,712.8109130859375 +88.22,712.7166748046875 +88.23,712.5881958007812 +88.24,712.4450073242188 +88.25,712.3006591796875 +88.26,712.1552124023438 +88.27,712.0048828125 +88.28,711.8515014648438 +88.29,711.6932983398438 +88.3,711.5283813476562 +88.31,711.3453369140625 +88.32,711.144287109375 +88.33,710.9447021484375 +88.34,710.7445678710938 +88.35,710.5363159179688 +88.36,710.33154296875 +88.37,710.1262817382812 +88.38,709.9033813476562 +88.39,709.670654296875 +88.4,709.437744140625 +88.41,709.1951904296875 +88.42,708.9450073242188 +88.43,708.6968383789062 +88.44,708.4410400390625 +88.45,708.145263671875 +88.46,707.8328247070312 +88.47,707.517333984375 +88.48,707.2026977539062 +88.49,706.87548828125 +88.5,706.5340576171875 +88.51,706.1956176757812 +88.52,705.8564453125 +88.53,705.5127563476562 +88.54,705.1703491210938 +88.55,704.8272705078125 +88.56,704.4835815429688 +88.57,704.14501953125 +88.58,703.8115844726562 +88.59,703.4697875976562 +88.6,703.12744140625 +88.61,702.7960815429688 +88.62,702.4697875976562 +88.63,702.1658935546875 +88.64,701.8668823242188 +88.65,701.5726928710938 +88.66,701.273681640625 +88.67,700.9795532226562 +88.68,700.6902465820312 +88.69,700.4057006835938 +88.7,700.1181640625 +88.71,699.8335571289062 +88.72,699.5498657226562 +88.73,699.2709350585938 +88.74,698.9967651367188 +88.75,698.725341796875 +88.76,698.4528198242188 +88.77,698.1754760742188 +88.78,697.9086303710938 +88.79,697.6464233398438 +88.8,697.3887939453125 +88.81,697.1300659179688 +88.82,696.8759155273438 +88.83,696.6320190429688 +88.84,696.3926391601562 +88.85,696.1575927734375 +88.86,695.9269409179688 +88.87,695.7025756835938 +88.88,695.4824829101562 +88.89,695.2666015625 +88.9,695.0721435546875 +88.91,694.8816528320312 +88.92,694.7467651367188 +88.93,694.6248168945312 +88.94,694.506103515625 +88.95,694.3981323242188 +88.96,694.3085327148438 +88.97,694.2371215820312 +88.98,694.202880859375 +88.99,694.207275390625 +89,694.2979125976562 +89.01,694.4028930664062 +89.02,694.6160278320312 +89.03,694.8287353515625 +89.04,695.041015625 +89.05,695.2528686523438 +89.06,695.4641723632812 +89.07,695.6212158203125 +89.08,695.7648315429688 +89.09,695.89501953125 +89.1,696.015869140625 +89.11,696.1331787109375 +89.12,696.1549072265625 +89.13,696.1414794921875 +89.14,696.104736328125 +89.15,696.0506591796875 +89.16,695.9736328125 +89.17,695.8624267578125 +89.18,695.7250366210938 +89.19,695.5712890625 +89.2,695.4014282226562 +89.21,695.2232055664062 +89.22,695.0405883789062 +89.23,694.8153076171875 +89.24,694.536376953125 +89.25,694.2177734375 +89.26,693.8885498046875 +89.27,693.5642700195312 +89.28,693.2084350585938 +89.29,692.8329467773438 +89.3,692.3671875 +89.31,691.8906860351562 +89.32,691.4053955078125 +89.33,690.8865966796875 +89.34,690.36328125 +89.35,689.8375244140625 +89.36,689.2654418945312 +89.37,688.7010498046875 +89.38,688.1309814453125 +89.39,687.568603515625 +89.4,687.0120239257812 +89.41,686.463134765625 +89.42,685.9142456054688 +89.43,685.373046875 +89.44,684.8489990234375 +89.45,684.3362426757812 +89.46,683.8346557617188 +89.47,683.3710327148438 +89.48,682.9334106445312 +89.49,682.5101928710938 +89.5,682.097412109375 +89.51,681.6948852539062 +89.52,681.298828125 +89.53,680.9052734375 +89.54,680.5142822265625 +89.55,680.129638671875 +89.56,679.7513427734375 +89.57,679.3773803710938 +89.58,679.0057983398438 +89.59,678.6232299804688 +89.6,678.2412719726562 +89.61,677.8656616210938 +89.62,677.4945068359375 +89.63,677.129638671875 +89.64,676.77099609375 +89.65,676.4013061523438 +89.66,676.0169067382812 +89.67,675.6389770507812 +89.68,675.2731323242188 +89.69,674.91357421875 +89.7,674.5546264648438 +89.71,674.201904296875 +89.72,673.8554077148438 +89.73,673.5130615234375 +89.74,673.1749877929688 +89.75,672.8428955078125 +89.76,672.5092163085938 +89.77,672.15869140625 +89.78,671.8125 +89.79,671.4725341796875 +89.8,671.138671875 +89.81,670.7955932617188 +89.82,670.4739990234375 +89.83,670.1431274414062 +89.84,669.8335571289062 +89.85,669.5299072265625 +89.86,669.2244262695312 +89.87,668.9381103515625 +89.88,668.657470703125 +89.89,668.3690185546875 +89.9,668.109130859375 +89.91,667.848876953125 +89.92,667.5940551757812 +89.93,667.344482421875 +89.94,667.1002197265625 +89.95,666.8611450195312 +89.96,666.6195678710938 +89.97,666.37744140625 +89.98,666.1404418945312 +89.99,665.8914184570312 +90,665.6476440429688 +90.01,665.4090576171875 +90.02,665.1755981445312 +90.03,664.9472045898438 +90.04,664.723876953125 +90.05,664.5131225585938 +90.06,664.31103515625 +90.07,664.1136474609375 +90.08,663.9228515625 +90.09,663.7423706054688 +90.1,663.5682983398438 +90.11,663.4024658203125 +90.12,663.240966796875 +90.13,663.0989990234375 +90.14,662.96875 +90.15,662.8462524414062 +90.16,662.740966796875 +90.17,662.6527099609375 +90.18,662.5697631835938 +90.19,662.497802734375 +90.2,662.4520263671875 +90.21,662.4110717773438 +90.22,662.3959350585938 +90.23,662.3872680664062 +90.24,662.3887329101562 +90.25,662.3964233398438 +90.26,662.4273681640625 +90.27,662.4603271484375 +90.28,662.5067138671875 +90.29,662.554931640625 +90.3,662.6049194335938 +90.31,662.6451416015625 +90.32,662.6871337890625 +90.33,662.7289428710938 +90.34,662.7724609375 +90.35,662.8176879882812 +90.36,662.866455078125 +90.37,662.9148559570312 +90.38,662.9571533203125 +90.39,663.0010375976562 +90.4,663.0464477539062 +90.41,663.0838012695312 +90.42,663.095947265625 +90.43,663.0984497070312 +90.44,663.102783203125 +90.45,663.1069946289062 +90.46,663.0996704101562 +90.47,663.0885620117188 +90.48,663.0602416992188 +90.49,663.0225830078125 +90.5,662.987060546875 +90.51,662.9517822265625 +90.52,662.9186401367188 +90.53,662.8856201171875 +90.54,662.8241577148438 +90.55,662.7326049804688 +90.56,662.6303100585938 +90.57,662.5308227539062 +90.58,662.4148559570312 +90.59,662.2788696289062 +90.6,662.113525390625 +90.61,661.9515380859375 +90.62,661.77001953125 +90.63,661.5901489257812 +90.64,661.4080810546875 +90.65,661.2181396484375 +90.66,661.0089111328125 +90.67,660.7825317382812 +90.68,660.5277709960938 +90.69,660.2754516601562 +90.7,660.002685546875 +90.71,659.7210693359375 +90.72,659.4212036132812 +90.73,659.1261596679688 +90.74,658.824462890625 +90.75,658.510498046875 +90.76,658.1729125976562 +90.77,657.8349609375 +90.78,657.4851684570312 +90.79,657.1198120117188 +90.8,656.7562255859375 +90.81,656.37353515625 +90.82,655.9852294921875 +90.83,655.5819091796875 +90.84,655.16748046875 +90.85,654.7402954101562 +90.86,654.3194580078125 +90.87,653.903076171875 +90.88,653.4854125976562 +90.89,653.0740966796875 +90.9,652.6366577148438 +90.91,652.2058715820312 +90.92,651.7816162109375 +90.93,651.3524780273438 +90.94,650.9356689453125 +90.95,650.5253295898438 +90.96,650.1194458007812 +90.97,649.719970703125 +90.98,649.328857421875 +90.99,648.9534912109375 +91,648.5843505859375 +91.01,648.2212524414062 +91.02,647.8662109375 +91.03,647.5247192382812 +91.04,647.1890869140625 +91.05,646.866943359375 +91.06,646.5485229492188 +91.07,646.2396240234375 +91.08,645.9476928710938 +91.09,645.6611328125 +91.1,645.3798828125 +91.11,645.1002197265625 +91.12,644.8181762695312 +91.13,644.5414428710938 +91.14,644.2662353515625 +91.15,643.98291015625 +91.16,643.6934814453125 +91.17,643.4056396484375 +91.18,643.1231689453125 +91.19,642.8460693359375 +91.2,642.5742797851562 +91.21,642.3058471679688 +91.22,642.0426025390625 +91.23,641.7845458984375 +91.24,641.5087890625 +91.25,641.228759765625 +91.26,640.9464721679688 +91.27,640.6448364257812 +91.28,640.343017578125 +91.29,640.044921875 +91.3,639.7467041015625 +91.31,639.459716796875 +91.32,639.1725463867188 +91.33,638.8907470703125 +91.34,638.6143798828125 +91.35,638.3433837890625 +91.36,638.0776977539062 +91.37,637.8172607421875 +91.38,637.5714721679688 +91.39,637.3402709960938 +91.4,637.12158203125 +91.41,636.9114990234375 +91.42,636.7098388671875 +91.43,636.524169921875 +91.44,636.3428955078125 +91.45,636.1698608398438 +91.46,636.0296020507812 +91.47,635.8970947265625 +91.48,635.7893676757812 +91.49,635.6947631835938 +91.5,635.6397705078125 +91.51,635.6068115234375 +91.52,635.58984375 +91.53,635.5850219726562 +91.54,635.5845336914062 +91.55,635.5997314453125 +91.56,635.6170654296875 +91.57,635.6707763671875 +91.58,635.7261962890625 +91.59,635.783203125 +91.6,635.8418579101562 +91.61,635.902099609375 +91.62,635.94287109375 +91.63,635.9853515625 +91.64,636.0294189453125 +91.65,636.0960693359375 +91.66,636.1659545898438 +91.67,636.2485961914062 +91.68,636.3418579101562 +91.69,636.43994140625 +91.7,636.5389404296875 +91.71,636.6483154296875 +91.72,636.7584228515625 +91.73,636.87109375 +91.74,636.9729614257812 +91.75,637.06982421875 +91.76,637.1674194335938 +91.77,637.2638549804688 +91.78,637.3551635742188 +91.79,637.4320068359375 +91.8,637.5020751953125 +91.81,637.5653076171875 +91.82,637.6256713867188 +91.83,637.6832275390625 +91.84,637.7169189453125 +91.85,637.7060546875 +91.86,637.69677734375 +91.87,637.6052856445312 +91.88,637.5104370117188 +91.89,637.4066772460938 +91.9,637.305419921875 +91.91,637.200927734375 +91.92,637.0951538085938 +91.93,636.9843139648438 +91.94,636.8665161132812 +91.95,636.6943359375 +91.96,636.5215454101562 +91.97,636.3424072265625 +91.98,636.0923461914062 +91.99,635.8140258789062 +92,635.5286865234375 +92.01,635.2096557617188 +92.02,634.8897094726562 +92.03,634.5289306640625 +92.04,634.148681640625 +92.05,633.7340087890625 +92.06,633.3062133789062 +92.07,632.882568359375 +92.08,632.4611206054688 +92.09,632.0323486328125 +92.1,631.5983276367188 +92.11,631.1438598632812 +92.12,630.6825561523438 +92.13,630.21826171875 +92.14,629.7605590820312 +92.15,629.3037109375 +92.16,628.849609375 +92.17,628.3831176757812 +92.18,627.92333984375 +92.19,627.4513549804688 +92.2,626.9842529296875 +92.21,626.5126342773438 +92.22,626.0478515625 +92.23,625.5899658203125 +92.24,625.138916015625 +92.25,624.6946411132812 +92.26,624.2646484375 +92.27,623.8412475585938 +92.28,623.43017578125 +92.29,623.0445556640625 +92.3,622.6651000976562 +92.31,622.293701171875 +92.32,621.93603515625 +92.33,621.5842895507812 +92.34,621.238525390625 +92.35,620.9043579101562 +92.36,620.5816040039062 +92.37,620.304443359375 +92.38,620.076171875 +92.39,619.890625 +92.4,619.732177734375 +92.41,619.5776977539062 +92.42,619.4384765625 +92.43,619.3068237304688 +92.44,619.1997680664062 +92.45,619.1284790039062 +92.46,619.0696411132812 +92.47,619.0517578125 +92.48,619.0819091796875 +92.49,619.1216430664062 +92.5,619.1669311523438 +92.51,619.2138671875 +92.52,619.2625122070312 +92.53,619.3128051757812 +92.54,619.3646850585938 +92.55,619.33056640625 +92.56,619.289306640625 +92.57,619.2505493164062 +92.58,619.2122802734375 +92.59,619.1574096679688 +92.6,619.1032104492188 +92.61,619.0476684570312 +92.62,618.9984741210938 +92.63,618.9517211914062 +92.64,618.9073486328125 +92.65,618.88623046875 +92.66,618.874755859375 +92.67,618.9262084960938 +92.68,618.9827880859375 +92.69,619.0405883789062 +92.7,619.099609375 +92.71,619.1597900390625 +92.72,619.2229614257812 +92.73,619.2853393554688 +92.74,619.3468017578125 +92.75,619.39990234375 +92.76,619.4540405273438 +92.77,619.50927734375 +92.78,619.561767578125 +92.79,619.6152954101562 +92.8,619.666015625 +92.81,619.6815795898438 +92.82,619.6870727539062 +92.83,619.6616821289062 +92.84,619.6323852539062 +92.85,619.572509765625 +92.86,619.4729614257812 +92.87,619.3549194335938 +92.88,619.2395629882812 +92.89,619.1268920898438 +92.9,619.00732421875 +92.91,618.8904418945312 +92.92,618.7857055664062 +92.93,618.6834716796875 +92.94,618.5856323242188 +92.95,618.4940185546875 +92.96,618.4085693359375 +92.97,618.325439453125 +92.98,618.2407836914062 +92.99,618.1622314453125 +93,618.0858764648438 +93.01,618.0078735351562 +93.02,617.9320678710938 +93.03,617.8565673828125 +93.04,617.7794189453125 +93.05,617.6873168945312 +93.06,617.5880126953125 +93.07,617.4911499023438 +93.08,617.3643188476562 +93.09,617.2382202148438 +93.1,617.1128540039062 +93.11,616.9826049804688 +93.12,616.8416748046875 +93.13,616.6998291015625 +93.14,616.558837890625 +93.15,616.4074096679688 +93.16,616.2418212890625 +93.17,616.0545654296875 +93.18,615.8326416015625 +93.19,615.5687866210938 +93.2,615.3033447265625 +93.21,614.9813842773438 +93.22,614.6450805664062 +93.23,614.262451171875 +93.24,613.8795166015625 +93.25,613.48876953125 +93.26,613.0371704101562 +93.27,612.5576171875 +93.28,612.065673828125 +93.29,611.5803833007812 +93.3,611.1016845703125 +93.31,610.629638671875 +93.32,610.1641845703125 +93.33,609.7127685546875 +93.34,609.2734985351562 +93.35,608.8538208007812 +93.36,608.4725341796875 +93.37,608.1217041015625 +93.38,607.8104858398438 +93.39,607.5062255859375 +93.4,607.2506713867188 +93.41,607.0072021484375 +93.42,606.7871704101562 +93.43,606.5827026367188 +93.44,606.4277954101562 +93.45,606.285888671875 +93.46,606.187255859375 +93.47,606.0952758789062 +93.48,606.0194702148438 +93.49,605.9501342773438 +93.5,605.8928833007812 +93.51,605.8590087890625 +93.52,605.8500366210938 +93.53,605.8619995117188 +93.54,605.88330078125 +93.55,605.9061889648438 +93.56,605.9305419921875 +93.57,605.9564819335938 +93.58,605.9838256835938 +93.59,605.99169921875 +93.6,606.001220703125 +93.61,606.0123291015625 +93.62,606.02490234375 +93.63,606.0390625 +93.64,606.0546264648438 +93.65,606.0830078125 +93.66,606.1127319335938 +93.67,606.1436767578125 +93.68,606.1758422851562 +93.69,606.209228515625 +93.7,606.2437744140625 +93.71,606.2698974609375 +93.72,606.2800903320312 +93.73,606.287841796875 +93.74,606.2627563476562 +93.75,606.218505859375 +93.76,606.1760864257812 +93.77,606.135498046875 +93.78,606.0777587890625 +93.79,606.0105590820312 +93.8,605.9435424804688 +93.81,605.874755859375 +93.82,605.77392578125 +93.83,605.6754150390625 +93.84,605.5792846679688 +93.85,605.4854736328125 +93.86,605.39404296875 +93.87,605.301025390625 +93.88,605.2103271484375 +93.89,605.1218872070312 +93.9,605.0509033203125 +93.91,604.98193359375 +93.92,604.8941650390625 +93.93,604.8047485351562 +93.94,604.7156982421875 +93.95,604.5946655273438 +93.96,604.4591674804688 +93.97,604.31494140625 +93.98,604.1640014648438 +93.99,604.0026245117188 +94,603.802490234375 +94.01,603.5924682617188 +94.02,603.3782958984375 +94.03,603.1467895507812 +94.04,602.9114379882812 +94.05,602.6741333007812 +94.06,602.4349365234375 +94.07,602.138916015625 +94.08,601.830322265625 +94.09,601.5035400390625 +94.1,601.1796875 +94.11,600.8151245117188 +94.12,600.4424438476562 +94.13,600.0277709960938 +94.14,599.6075439453125 +94.15,599.1515502929688 +94.16,598.6468505859375 +94.17,598.1489868164062 +94.18,597.6559448242188 +94.19,597.1658935546875 +94.2,596.6825561523438 +94.21,596.1926879882812 +94.22,595.707763671875 +94.23,595.2296752929688 +94.24,594.750732421875 +94.25,594.2786254882812 +94.26,593.813232421875 +94.27,593.381103515625 +94.28,592.9705200195312 +94.29,592.5680541992188 +94.3,592.1736450195312 +94.31,591.80615234375 +94.32,591.4766235351562 +94.33,591.1544799804688 +94.34,590.8605346679688 +94.35,590.5773315429688 +94.36,590.3048095703125 +94.37,590.0391235351562 +94.38,589.7821044921875 +94.39,589.584716796875 +94.4,589.3914794921875 +94.41,589.2117919921875 +94.42,589.0360717773438 +94.43,588.8681030273438 +94.44,588.7058715820312 +94.45,588.5512084960938 +94.46,588.4002075195312 +94.47,588.260498046875 +94.48,588.1280517578125 +94.49,587.9990844726562 +94.5,587.8886108398438 +94.51,587.7813720703125 +94.52,587.67724609375 +94.53,587.5762939453125 +94.54,587.4783935546875 +94.55,587.3834838867188 +94.56,587.3048706054688 +94.57,587.2328491210938 +94.58,587.16357421875 +94.59,587.0932006835938 +94.6,587.0160522460938 +94.61,586.9415893554688 +94.62,586.869873046875 +94.63,586.80078125 +94.64,586.7135009765625 +94.65,586.6271362304688 +94.66,586.5169677734375 +94.67,586.4022216796875 +94.68,586.2772827148438 +94.69,586.1478881835938 +94.7,586.0179443359375 +94.71,585.8912353515625 +94.72,585.7676391601562 +94.73,585.6282348632812 +94.74,585.4883422851562 +94.75,585.3062133789062 +94.76,585.116455078125 +94.77,584.9247436523438 +94.78,584.7330932617188 +94.79,584.5376586914062 +94.8,584.3403930664062 +94.81,584.0447998046875 +94.82,583.7521362304688 +94.83,583.4625244140625 +94.84,583.13232421875 +94.85,582.78662109375 +94.86,582.3990478515625 +94.87,581.99658203125 +94.88,581.5775146484375 +94.89,581.1420288085938 +94.9,580.6846313476562 +94.91,580.2321166992188 +94.92,579.7655029296875 +94.93,579.2982177734375 +94.94,578.8283081054688 +94.95,578.3598022460938 +94.96,577.8812255859375 +94.97,577.40966796875 +94.98,576.9244995117188 +94.99,576.4105834960938 +95,575.9042358398438 +95.01,575.403564453125 +95.02,574.9103393554688 +95.03,574.424560546875 +95.04,573.9461059570312 +95.05,573.4750366210938 +95.06,573.0093383789062 +95.07,572.5509033203125 +95.08,572.0996704101562 +95.09,571.6555786132812 +95.1,571.2186279296875 +95.11,570.7678833007812 +95.12,570.3243408203125 +95.13,569.8804321289062 +95.14,569.4436645507812 +95.15,569.0140380859375 +95.16,568.6065063476562 +95.17,568.2096557617188 +95.18,567.8195190429688 +95.19,567.43994140625 +95.2,567.08203125 +95.21,566.7305297851562 +95.22,566.3853759765625 +95.23,566.0484008789062 +95.24,565.7289428710938 +95.25,565.425048828125 +95.26,565.1383056640625 +95.27,564.8724365234375 +95.28,564.6138916015625 +95.29,564.3853149414062 +95.3,564.1656494140625 +95.31,563.9661254882812 +95.32,563.7713623046875 +95.33,563.5813598632812 +95.34,563.39599609375 +95.35,563.2171630859375 +95.36,563.0428466796875 +95.37,562.873046875 +95.38,562.7077026367188 +95.39,562.5580444335938 +95.4,562.4012451171875 +95.41,562.2487182617188 +95.42,562.1212158203125 +95.43,561.9976196289062 +95.44,561.857177734375 +95.45,561.70751953125 +95.46,561.5582275390625 +95.47,561.4073486328125 +95.48,561.2435913085938 +95.49,561.0822143554688 +95.5,560.925048828125 +95.51,560.7550048828125 +95.52,560.58544921875 +95.53,560.4069213867188 +95.54,560.1950073242188 +95.55,559.9424438476562 +95.56,559.6722412109375 +95.57,559.4036254882812 +95.58,559.125244140625 +95.59,558.8427124023438 +95.6,558.507080078125 +95.61,558.1660766601562 +95.62,557.8141479492188 +95.63,557.44384765625 +95.64,557.027099609375 +95.65,556.6153564453125 +95.66,556.1934204101562 +95.67,555.7445068359375 +95.68,555.2952880859375 +95.69,554.8457641601562 +95.7,554.403564453125 +95.71,553.9685668945312 +95.72,553.5407104492188 +95.73,553.1238403320312 +95.74,552.7139892578125 +95.75,552.3299560546875 +95.76,551.9658203125 +95.77,551.6327514648438 +95.78,551.3097534179688 +95.79,551.0060424804688 +95.8,550.7138671875 +95.81,550.4483032226562 +95.82,550.2070922851562 +95.83,549.9900512695312 +95.84,549.7817993164062 +95.85,549.5993041992188 +95.86,549.45166015625 +95.87,549.3101806640625 +95.88,549.2049560546875 +95.89,549.1168212890625 +95.9,549.0322875976562 +95.91,548.9513549804688 +95.92,548.8889770507812 +95.93,548.8148803710938 +95.94,548.744140625 +95.95,548.6729736328125 +95.96,548.6051025390625 +95.97,548.54052734375 +95.98,548.4791870117188 +95.99,548.4248046875 +96,548.3734741210938 +96.01,548.3251953125 +96.02,548.2855224609375 +96.03,548.2506103515625 +96.04,548.2222900390625 +96.05,548.1966552734375 +96.06,548.1737060546875 +96.07,548.1533813476562 +96.08,548.1393432617188 +96.09,548.1240234375 +96.1,548.1111450195312 +96.11,548.1007080078125 +96.12,548.0794067382812 +96.13,548.0510864257812 +96.14,548.0252685546875 +96.15,548.0018920898438 +96.16,547.9808959960938 +96.17,547.9623413085938 +96.18,547.946044921875 +96.19,547.93017578125 +96.2,547.9146728515625 +96.21,547.9013671875 +96.22,547.9054565429688 +96.23,547.8963623046875 +96.24,547.904541015625 +96.25,547.9146118164062 +96.26,547.9114379882812 +96.27,547.8743896484375 +96.28,547.8396606445312 +96.29,547.8052978515625 +96.3,547.7675170898438 +96.31,547.7036743164062 +96.32,547.63671875 +96.33,547.5704345703125 +96.34,547.4443359375 +96.35,547.317626953125 +96.36,547.1903076171875 +96.37,547.0585327148438 +96.38,546.8771362304688 +96.39,546.68994140625 +96.4,546.4291381835938 +96.41,546.1709594726562 +96.42,545.8758544921875 +96.43,545.576171875 +96.44,545.226806640625 +96.45,544.8471069335938 +96.46,544.427978515625 +96.47,544.0112915039062 +96.48,543.5462036132812 +96.49,543.0728149414062 +96.5,542.6025390625 +96.51,542.1334228515625 +96.52,541.66748046875 +96.53,541.2084350585938 +96.54,540.7391967773438 +96.55,540.265625 +96.56,539.7953491210938 +96.57,539.3320922851562 +96.58,538.8757934570312 +96.59,538.4302368164062 +96.6,538.0198364257812 +96.61,537.6234741210938 +96.62,537.2447509765625 +96.63,536.8741455078125 +96.64,536.5172119140625 +96.65,536.18701171875 +96.66,535.8870849609375 +96.67,535.5925903320312 +96.68,535.3298950195312 +96.69,535.079833984375 +96.7,534.89501953125 +96.71,534.723876953125 +96.72,534.5569458007812 +96.73,534.4298706054688 +96.74,534.308349609375 +96.75,534.2055053710938 +96.76,534.1173706054688 +96.77,534.0381469726562 +96.78,533.9620361328125 +96.79,533.8889770507812 +96.8,533.8189697265625 +96.81,533.7462768554688 +96.82,533.6747436523438 +96.83,533.6061401367188 +96.84,533.5404663085938 +96.85,533.4550170898438 +96.86,533.3726806640625 +96.87,533.2989501953125 +96.88,533.2225341796875 +96.89,533.1470947265625 +96.9,533.0745849609375 +96.91,533.0049438476562 +96.92,532.9173583984375 +96.93,532.8289794921875 +96.94,532.7379760742188 +96.95,532.6367797851562 +96.96,532.5236206054688 +96.97,532.41357421875 +96.98,532.289794921875 +96.99,532.1693115234375 +97,532.0520629882812 +97.01,531.9342651367188 +97.02,531.8121337890625 +97.03,531.6688232421875 +97.04,531.5250854492188 +97.05,531.3810424804688 +97.06,531.2348022460938 +97.07,531.0619506835938 +97.08,530.8740234375 +97.09,530.6861572265625 +97.1,530.490966796875 +97.11,530.277099609375 +97.12,530.0504760742188 +97.13,529.81494140625 +97.14,529.5650024414062 +97.15,529.31396484375 +97.16,529.0336303710938 +97.17,528.7355346679688 +97.18,528.43310546875 +97.19,528.12451171875 +97.2,527.7815551757812 +97.21,527.4310302734375 +97.22,527.0617065429688 +97.23,526.6776123046875 +97.24,526.2994995117188 +97.25,525.8484497070312 +97.26,525.4003295898438 +97.27,524.9496459960938 +97.28,524.4793701171875 +97.29,524.0123901367188 +97.3,523.546875 +97.31,523.08837890625 +97.32,522.62744140625 +97.33,522.173583984375 +97.34,521.7267456054688 +97.35,521.2962646484375 +97.36,520.8744506835938 +97.37,520.46875 +97.38,520.071533203125 +97.39,519.692138671875 +97.4,519.3247680664062 +97.41,519.0050048828125 +97.42,518.698486328125 +97.43,518.4315185546875 +97.44,518.209228515625 +97.45,517.99365234375 +97.46,517.7902221679688 +97.47,517.6177368164062 +97.48,517.4664306640625 +97.49,517.334228515625 +97.5,517.2283935546875 +97.51,517.1260986328125 +97.52,517.0272827148438 +97.53,516.9375 +97.54,516.8567504882812 +97.55,516.7791748046875 +97.56,516.6991577148438 +97.57,516.6015625 +97.58,516.4998168945312 +97.59,516.3882446289062 +97.6,516.2763671875 +97.61,516.1679077148438 +97.62,516.0497436523438 +97.63,515.9312133789062 +97.64,515.8012084960938 +97.65,515.6747436523438 +97.66,515.5480346679688 +97.67,515.4022827148438 +97.68,515.254638671875 +97.69,515.10693359375 +97.7,514.9423217773438 +97.71,514.7740478515625 +97.72,514.60595703125 +97.73,514.4380493164062 +97.74,514.2684326171875 +97.75,514.0819702148438 +97.76,513.899658203125 +97.77,513.7213745117188 +97.78,513.54150390625 +97.79,513.3656005859375 +97.8,513.1918334960938 +97.81,513.016357421875 +97.82,512.8523559570312 +97.83,512.6921997070312 +97.84,512.5396118164062 +97.85,512.392578125 +97.86,512.2510986328125 +97.87,512.1151123046875 +97.88,511.993896484375 +97.89,511.88165283203125 +97.9,511.78021240234375 +97.91,511.6894226074219 +97.92,511.61669921875 +97.93,511.56182861328125 +97.94,511.4945373535156 +97.95,511.4299621582031 +97.96,511.36810302734375 +97.97,511.2938232421875 +97.98,511.2147521972656 +97.99,511.13287353515625 +98,511.0312194824219 +98.01,510.911865234375 +98.02,510.7618713378906 +98.03,510.54766845703125 +98.04,510.3150939941406 +98.05,510.0417785644531 +98.06,509.72247314453125 +98.07,509.37091064453125 +98.08,508.99298095703125 +98.09,508.5101623535156 +98.1,508.0287170410156 +98.11,507.5448913574219 +98.12,507.0475158691406 +98.13,506.5310974121094 +98.14,506.0090026855469 +98.15,505.46636962890625 +98.16,504.9315490722656 +98.17,504.35003662109375 +98.18,503.77679443359375 +98.19,503.2117614746094 +98.2,502.6455383300781 +98.21,502.0875244140625 +98.22,501.54144287109375 +98.23,501.0090637207031 +98.24,500.48468017578125 +98.25,499.9925537109375 +98.26,499.5080261230469 +98.27,499.0404357910156 +98.28,498.5971374511719 +98.29,498.1685791015625 +98.3,497.7489013671875 +98.31,497.3418273925781 +98.32,496.9416198730469 +98.33,496.55010986328125 +98.34,496.17095947265625 +98.35,495.8040466308594 +98.36,495.4436340332031 +98.37,495.0896911621094 +98.38,494.74212646484375 +98.39,494.40087890625 +98.4,494.0509033203125 +98.41,493.7073059082031 +98.42,493.3587646484375 +98.43,493.0109558105469 +98.44,492.6676025390625 +98.45,492.3175048828125 +98.46,491.95501708984375 +98.47,491.5878601074219 +98.48,491.2235107421875 +98.49,490.8583068847656 +98.5,490.49969482421875 +98.51,490.1307067871094 +98.52,489.76275634765625 +98.53,489.3864440917969 +98.54,489.01690673828125 +98.55,488.6484375 +98.56,488.2829284667969 +98.57,487.8940734863281 +98.58,487.4990234375 +98.59,487.1091003417969 +98.6,486.7204895019531 +98.61,486.3388366699219 +98.62,485.96221923828125 +98.63,485.5736999511719 +98.64,485.1846618652344 +98.65,484.7969665527344 +98.66,484.4031677246094 +98.67,484.0032958984375 +98.68,483.5993347167969 +98.69,483.2025451660156 +98.7,482.805419921875 +98.71,482.41351318359375 +98.72,482.0268859863281 +98.73,481.6435241699219 +98.74,481.26153564453125 +98.75,480.87908935546875 +98.76,480.5036926269531 +98.77,480.13525390625 +98.78,479.77001953125 +98.79,479.40972900390625 +98.8,479.054443359375 +98.81,478.7077941894531 +98.82,478.3697509765625 +98.83,478.038330078125 +98.84,477.72283935546875 +98.85,477.4211730957031 +98.86,477.1276550292969 +98.87,476.8440246582031 +98.88,476.57586669921875 +98.89,476.33984375 +98.9,476.11309814453125 +98.91,475.8992919921875 +98.92,475.7001647949219 +98.93,475.5324401855469 +98.94,475.3732604980469 +98.95,475.23193359375 +98.96,475.0989074707031 +98.97,474.97589111328125 +98.98,474.8572082519531 +98.99,474.7783203125 +99,474.7164001464844 +99.01,474.671142578125 +99.02,474.6593017578125 +99.03,474.6861572265625 +99.04,474.7475280761719 +99.05,474.81109619140625 +99.06,474.8768005371094 +99.07,474.9464416503906 +99.08,475.01812744140625 +99.09,475.0936279296875 +99.1,475.16729736328125 +99.11,475.2428283691406 +99.12,475.2957763671875 +99.13,475.3432312011719 +99.14,475.3889465332031 +99.15,475.3841552734375 +99.16,475.38189697265625 +99.17,475.3652648925781 +99.18,475.34368896484375 +99.19,475.3247375488281 +99.2,475.302734375 +99.21,475.27581787109375 +99.22,475.2458801269531 +99.23,475.21856689453125 +99.24,475.1938171386719 +99.25,475.1640625 +99.26,475.13128662109375 +99.27,475.10107421875 +99.28,475.0752258300781 +99.29,475.05181884765625 +99.3,475.03082275390625 +99.31,474.99908447265625 +99.32,474.9566345214844 +99.33,474.916748046875 +99.34,474.87933349609375 +99.35,474.8443908691406 +99.36,474.79876708984375 +99.37,474.7556457519531 +99.38,474.7149963378906 +99.39,474.6767578125 +99.4,474.6408996582031 +99.41,474.6073913574219 +99.42,474.5762023925781 +99.43,474.5472717285156 +99.44,474.514892578125 +99.45,474.46978759765625 +99.46,474.41766357421875 +99.47,474.3548583984375 +99.48,474.29266357421875 +99.49,474.2160949707031 +99.5,474.13653564453125 +99.51,474.0127868652344 +99.52,473.8789978027344 +99.53,473.694091796875 +99.54,473.49603271484375 +99.55,473.2756042480469 +99.56,473.02557373046875 +99.57,472.7518615722656 +99.58,472.48095703125 +99.59,472.2015686035156 +99.6,471.8651428222656 +99.61,471.5284118652344 +99.62,471.1652526855469 +99.63,470.7497253417969 +99.64,470.33856201171875 +99.65,469.9279479980469 +99.66,469.52349853515625 +99.67,469.05419921875 +99.68,468.5823669433594 +99.69,468.0931396484375 +99.7,467.6109924316406 +99.71,467.13592529296875 +99.72,466.69219970703125 +99.73,466.25518798828125 +99.74,465.8267517089844 +99.75,465.4049377441406 +99.76,464.9934387207031 +99.77,464.5902404785156 +99.78,464.20660400390625 +99.79,463.84600830078125 +99.8,463.5213317871094 +99.81,463.2042236328125 +99.82,462.8964538574219 +99.83,462.6053771972656 +99.84,462.31964111328125 +99.85,462.0391845703125 +99.86,461.76397705078125 +99.87,461.49395751953125 +99.88,461.225341796875 +99.89,460.9543762207031 +99.9,460.65679931640625 +99.91,460.3459167480469 +99.92,460.0368957519531 +99.93,459.72222900390625 +99.94,459.4019775390625 +99.95,459.0724792480469 +99.96,458.7300720214844 +99.97,458.39361572265625 +99.98,458.0481262207031 +99.99,457.70489501953125 +100,457.37139892578125 +100.01,457.0400695800781 +100.02,456.7146301269531 +100.03,456.3931884765625 +100.04,456.0775451660156 +100.05,455.76763916015625 +100.06,455.46905517578125 +100.07,455.1797790527344 +100.08,454.8960266113281 +100.09,454.6177062988281 +100.1,454.3447570800781 +100.11,454.0771789550781 +100.12,453.8186340332031 +100.13,453.5652770996094 +100.14,453.3282775878906 +100.15,453.10003662109375 +100.16,452.8766784667969 +100.17,452.65814208984375 +100.18,452.44622802734375 +100.19,452.2390441894531 +100.2,452.05517578125 +100.21,451.8851013183594 +100.22,451.72119140625 +100.23,451.5670471191406 +100.24,451.4356384277344 +100.25,451.30810546875 +100.26,451.184326171875 +100.27,451.0643005371094 +100.28,450.94793701171875 +100.29,450.8333435058594 +100.3,450.6868591308594 +100.31,450.53680419921875 +100.32,450.3738708496094 +100.33,450.1645202636719 +100.34,449.9186096191406 +100.35,449.56927490234375 +100.36,449.2240905761719 +100.37,448.8363037109375 +100.38,448.4269104003906 +100.39,448.0129699707031 +100.4,447.6057434082031 +100.41,447.1865234375 +100.42,446.7685241699219 +100.43,446.3572998046875 +100.44,445.9528503417969 +100.45,445.55511474609375 +100.46,445.17523193359375 +100.47,444.81488037109375 +100.48,444.5168151855469 +100.49,444.2449951171875 +100.5,443.97857666015625 +100.51,443.7212219238281 +100.52,443.48406982421875 +100.53,443.251953125 +100.54,443.017333984375 +100.55,442.7951965332031 +100.56,442.57977294921875 +100.57,442.3673095703125 +100.58,442.15216064453125 +100.59,441.9418029785156 +100.6,441.7361755371094 +100.61,441.5352478027344 +100.62,441.3407897949219 +100.63,441.15087890625 +100.64,440.9654846191406 +100.65,440.7845458984375 +100.66,440.6173095703125 +100.67,440.4618225097656 +100.68,440.3310241699219 +100.69,440.2040710449219 +100.7,440.082763671875 +100.71,439.9988098144531 +100.72,439.9368591308594 +100.73,439.89111328125 +100.74,439.8501281738281 +100.75,439.8194580078125 +100.76,439.7914733886719 +100.77,439.77734375 +100.78,439.76568603515625 +100.79,439.7621154785156 +100.8,439.7608947753906 +100.81,439.75634765625 +100.82,439.75970458984375 +100.83,439.7558898925781 +100.84,439.7450256347656 +100.85,439.7364501953125 +100.86,439.7226257324219 +100.87,439.7054748535156 +100.88,439.6888122558594 +100.89,439.6744079589844 +100.9,439.6640625 +100.91,439.654052734375 +100.92,439.64617919921875 +100.93,439.6404113769531 +100.94,439.6366882324219 +100.95,439.6275329589844 +100.96,439.53448486328125 +100.97,439.43878173828125 +100.98,439.3421936035156 +100.99,439.2410888671875 +101,439.1429748535156 +101.01,439.0477600097656 +101.02,438.9554138183594 +101.03,438.87335205078125 +101.04,438.79400634765625 +101.05,438.7099304199219 +101.06,438.6285400390625 +101.07,438.5442199707031 +101.08,438.4383544921875 +101.09,438.2999267578125 +101.1,438.15728759765625 +101.11,437.988037109375 +101.12,437.8037414550781 +101.13,437.6156921386719 +101.14,437.42572021484375 +101.15,437.230224609375 +101.16,437.0329895019531 +101.17,436.8302307128906 +101.18,436.6202087402344 +101.19,436.3936767578125 +101.2,436.1712951660156 +101.21,435.9530334472656 +101.22,435.7388610839844 +101.23,435.52874755859375 +101.24,435.3300476074219 +101.25,435.1483459472656 +101.26,434.9703674316406 +101.27,434.79791259765625 +101.28,434.62908935546875 +101.29,434.478759765625 +101.3,434.3318176269531 +101.31,434.1900939941406 +101.32,434.08154296875 +101.33,433.9759521484375 +101.34,433.8956298828125 +101.35,433.82354736328125 +101.36,433.76708984375 +101.37,433.7260437011719 +101.38,433.68719482421875 +101.39,433.6505126953125 +101.4,433.615966796875 +101.41,433.58349609375 +101.42,433.5531005859375 +101.43,433.51165771484375 +101.44,433.47235107421875 +101.45,433.4239196777344 +101.46,433.3776550292969 +101.47,433.3241882324219 +101.48,433.24493408203125 +101.49,433.15692138671875 +101.5,433.0434265136719 +101.51,432.9271545410156 +101.52,432.79876708984375 +101.53,432.6695556640625 +101.54,432.53021240234375 +101.55,432.36968994140625 +101.56,432.2030944824219 +101.57,432.019287109375 +101.58,431.8315124511719 +101.59,431.6156005859375 +101.6,431.3774108886719 +101.61,431.1339416503906 +101.62,430.8611145019531 +101.63,430.55169677734375 +101.64,430.24151611328125 +101.65,429.9288024902344 +101.66,429.61907958984375 +101.67,429.310546875 +101.68,429.0032043457031 +101.69,428.69512939453125 +101.7,428.3882141113281 +101.71,428.076904296875 +101.72,427.7388916015625 +101.73,427.4043273925781 +101.74,427.0694274902344 +101.75,426.736083984375 +101.76,426.398681640625 +101.77,426.0647277832031 +101.78,425.7286682128906 +101.79,425.39794921875 +101.8,425.0799560546875 +101.81,424.76715087890625 +101.82,424.470703125 +101.83,424.17926025390625 +101.84,423.8946228027344 +101.85,423.6148681640625 +101.86,423.3399658203125 +101.87,423.0736083984375 +101.88,422.8082580566406 +101.89,422.5513916015625 +101.9,422.29541015625 +101.91,422.0366516113281 +101.92,421.7788391113281 +101.93,421.5257263183594 +101.94,421.2772521972656 +101.95,421.0613098144531 +101.96,420.8496398925781 +101.97,420.6589660644531 +101.98,420.4815979003906 +101.99,420.32672119140625 +102,420.1922607421875 +102.01,420.07611083984375 +102.02,419.9911193847656 +102.03,419.9239501953125 +102.04,419.8594665527344 +102.05,419.8106994628906 +102.06,419.764404296875 +102.07,419.7242431640625 +102.08,419.6976013183594 +102.09,419.6620178222656 +102.1,419.6286926269531 +102.11,419.59759521484375 +102.12,419.5686950683594 +102.13,419.54754638671875 +102.14,419.5284423828125 +102.15,419.5113525390625 +102.16,419.4962463378906 +102.17,419.48309326171875 +102.18,419.4811706542969 +102.19,419.4959716796875 +102.2,419.5123291015625 +102.21,419.5302429199219 +102.22,419.5403137207031 +102.23,419.55194091796875 +102.24,419.56512451171875 +102.25,419.5164489746094 +102.26,419.438232421875 +102.27,419.3624267578125 +102.28,419.2740783691406 +102.29,419.1864013671875 +102.3,419.09375 +102.31,419.00360107421875 +102.32,418.9159851074219 +102.33,418.8159484863281 +102.34,418.6998596191406 +102.35,418.5790710449219 +102.36,418.4610900878906 +102.37,418.3458557128906 +102.38,418.2408447265625 +102.39,418.1422119140625 +102.4,418.0479736328125 +102.41,417.95623779296875 +102.42,417.8688049316406 +102.43,417.7838134765625 +102.44,417.7012023925781 +102.45,417.6209411621094 +102.46,417.5393371582031 +102.47,417.447021484375 +102.48,417.34222412109375 +102.49,417.2269592285156 +102.5,417.0826721191406 +102.51,416.9357604980469 +102.52,416.77691650390625 +102.53,416.6156005859375 +102.54,416.4425354003906 +102.55,416.2578430175781 +102.56,416.0765686035156 +102.57,415.9023742675781 +102.58,415.7314453125 +102.59,415.56378173828125 +102.6,415.4104919433594 +102.61,415.2602844238281 +102.62,415.1727600097656 +102.63,415.0876159667969 +102.64,415.0104064941406 +102.65,414.9299011230469 +102.66,414.8517150878906 +102.67,414.7702331542969 +102.68,414.6910400390625 +102.69,414.6141052246094 +102.7,414.37933349609375 +102.71,414.1484069824219 +102.72,413.8952331542969 +102.73,413.64056396484375 +102.74,413.3862609863281 +102.75,413.1360778808594 +102.76,412.8639221191406 +102.77,412.5960998535156 +102.78,412.3325500488281 +102.79,412.0434875488281 +102.8,411.7589416503906 +102.81,411.482666015625 +102.82,411.2070617675781 +102.83,410.93963623046875 +102.84,410.667236328125 +102.85,410.4085998535156 +102.86,410.1449279785156 +102.87,409.8856201171875 +102.88,409.6306457519531 +102.89,409.3743896484375 +102.9,409.09454345703125 +102.91,408.8192443847656 +102.92,408.5447692871094 +102.93,408.2748107910156 +102.94,408.0037841796875 +102.95,407.722412109375 +102.96,407.44195556640625 +102.97,407.1661376953125 +102.98,406.88934326171875 +102.99,406.6152648925781 +103,406.3457946777344 +103.01,406.0827941894531 +103.02,405.82989501953125 +103.03,405.5813903808594 +103.04,405.3372802734375 +103.05,405.1402587890625 +103.06,404.9471435546875 +103.07,404.7652587890625 +103.08,404.5870666503906 +103.09,404.41070556640625 +103.1,404.23797607421875 +103.11,404.06884765625 +103.12,403.9033203125 +103.13,403.7413330078125 +103.14,403.58099365234375 +103.15,403.42413330078125 +103.16,403.250244140625 +103.17,403.0613708496094 +103.18,402.8762512207031 +103.19,402.6948547363281 +103.2,402.51715087890625 +103.21,402.3431091308594 +103.22,402.15594482421875 +103.23,401.9892883300781 +103.24,401.8354797363281 +103.25,401.68505859375 +103.26,401.5435791015625 +103.27,401.4183654785156 +103.28,401.2925720214844 +103.29,401.1977844238281 +103.3,401.077880859375 +103.31,400.96099853515625 +103.32,400.8340759277344 +103.33,400.710205078125 +103.34,400.6024475097656 +103.35,400.4900817871094 +103.36,400.3806457519531 +103.37,400.2740783691406 +103.38,400.164794921875 +103.39,400.0583801269531 +103.4,399.95477294921875 +103.41,399.8093566894531 +103.42,399.66534423828125 +103.43,399.4928894042969 +103.44,399.303466796875 +103.45,399.08984375 +103.46,398.8801574707031 +103.47,398.6613464355469 +103.48,398.446533203125 +103.49,398.2356872558594 +103.5,398.02874755859375 +103.51,397.83868408203125 +103.52,397.70623779296875 +103.53,397.576904296875 +103.54,397.4562072753906 +103.55,397.3385009765625 +103.56,397.2237548828125 +103.57,397.1063537597656 +103.58,396.9546813964844 +103.59,396.77838134765625 +103.6,396.5888977050781 +103.61,396.4031066894531 +103.62,396.220947265625 +103.63,396.0367736816406 +103.64,395.8544006347656 +103.65,395.64776611328125 +103.66,395.4319763183594 +103.67,395.2090148925781 +103.68,394.9621887207031 +103.69,394.70855712890625 +103.7,394.4592590332031 +103.71,394.21429443359375 +103.72,393.9513244628906 +103.73,393.69287109375 +103.74,393.43701171875 +103.75,393.1818542480469 +103.76,392.9181213378906 +103.77,392.6571044921875 +103.78,392.380126953125 +103.79,392.10595703125 +103.8,391.83465576171875 +103.81,391.55682373046875 +103.82,391.2707214355469 +103.83,390.97271728515625 +103.84,390.6536560058594 +103.85,390.33978271484375 +103.86,390.0347900390625 +103.87,389.73114013671875 +103.88,389.4325256347656 +103.89,389.13897705078125 +103.9,388.8503723144531 +103.91,388.56671142578125 +103.92,388.29168701171875 +103.93,388.0215148925781 +103.94,387.76165771484375 +103.95,387.495361328125 +103.96,387.2337951660156 +103.97,386.9379577636719 +103.98,386.6434631347656 +103.99,386.3540344238281 +104,386.06964111328125 +104.01,385.7901916503906 +104.02,385.5194396972656 +104.03,385.2535400390625 +104.04,385.0240478515625 +104.05,384.8175354003906 +104.06,384.6170959472656 +104.07,384.4393615722656 +104.08,384.26556396484375 +104.09,384.0956115722656 +104.1,383.9276123046875 +104.11,383.7633972167969 +104.12,383.6029357910156 +104.13,383.4071960449219 +104.14,383.2137451171875 +104.15,383.0224914550781 +104.16,382.8352355957031 +104.17,382.65380859375 +104.18,382.4781494140625 +104.19,382.3063659667969 +104.2,382.1532897949219 +104.21,382.0409851074219 +104.22,381.9318542480469 +104.23,381.8258972167969 +104.24,381.7230224609375 +104.25,381.5953674316406 +104.26,381.4617614746094 +104.27,381.2739562988281 +104.28,381.0789489746094 +104.29,380.8582763671875 +104.3,380.641845703125 +104.31,380.4184875488281 +104.32,380.1957702636719 +104.33,379.9661865234375 +104.34,379.7298889160156 +104.35,379.492431640625 +104.36,379.2371520996094 +104.37,378.96240234375 +104.38,378.67767333984375 +104.39,378.37750244140625 +104.4,378.0769348144531 +104.41,377.77593994140625 +104.42,377.46893310546875 +104.43,377.1634826660156 +104.44,376.85028076171875 +104.45,376.4793701171875 +104.46,376.1107177734375 +104.47,375.7460632324219 +104.48,375.36138916015625 +104.49,374.9643249511719 +104.5,374.56982421875 +104.51,374.1797180175781 +104.52,373.7865905761719 +104.53,373.39605712890625 +104.54,372.9951477050781 +104.55,372.5968933105469 +104.56,372.20501708984375 +104.57,371.791748046875 +104.58,371.37396240234375 +104.59,370.95166015625 +104.6,370.4897766113281 +104.61,370.0257568359375 +104.62,369.5596923828125 +104.63,369.0804748535156 +104.64,368.5956726074219 +104.65,368.1128234863281 +104.66,367.6208190917969 +104.67,367.108642578125 +104.68,366.6006164550781 +104.69,366.093017578125 +104.7,365.571044921875 +104.71,365.0570983886719 +104.72,364.5381774902344 +104.73,364.02728271484375 +104.74,363.5244140625 +104.75,363.0294494628906 +104.76,362.5423583984375 +104.77,362.0630798339844 +104.78,361.6026611328125 +104.79,361.1553649902344 +104.8,360.72479248046875 +104.81,360.3089294433594 +104.82,359.9002380371094 +104.83,359.4986572265625 +104.84,359.1041564941406 +104.85,358.7203063964844 +104.86,358.3396911621094 +104.87,357.9640808105469 +104.88,357.587890625 +104.89,357.2185363769531 +104.9,356.8448791503906 +104.91,356.4780578613281 +104.92,356.1180114746094 +104.93,355.7591552734375 +104.94,355.406982421875 +104.95,355.06146240234375 +104.96,354.7225036621094 +104.97,354.3734130859375 +104.98,354.04571533203125 +104.99,353.72442626953125 +105,353.4150695800781 +105.01,353.1119079589844 +105.02,352.8149108886719 +105.03,352.5240173339844 +105.04,352.2391662597656 +105.05,351.9547119140625 +105.06,351.6817932128906 +105.07,351.41473388671875 +105.08,351.1534423828125 +105.09,350.9071044921875 +105.1,350.6663818359375 +105.11,350.418212890625 +105.12,350.17559814453125 +105.13,349.93658447265625 +105.14,349.7049255371094 +105.15,349.4786682128906 +105.16,349.2577209472656 +105.17,349.04937744140625 +105.18,348.8461608886719 +105.19,348.6479797363281 +105.2,348.454833984375 +105.21,348.2666015625 +105.22,348.08880615234375 +105.23,347.9250183105469 +105.24,347.7658386230469 +105.25,347.6112060546875 +105.26,347.48138427734375 +105.27,347.4094543457031 +105.28,347.3485107421875 +105.29,347.2910461425781 +105.3,347.2369689941406 +105.31,347.1862487792969 +105.32,347.1480712890625 +105.33,347.11114501953125 +105.34,347.07733154296875 +105.35,347.03912353515625 +105.36,346.9891357421875 +105.37,346.92938232421875 +105.38,346.86358642578125 +105.39,346.7881164550781 +105.4,346.701171875 +105.41,346.6084289550781 +105.42,346.517333984375 +105.43,346.426025390625 +105.44,346.3381652832031 +105.45,346.2536926269531 +105.46,346.1725769042969 +105.47,346.0947570800781 +105.48,346.02203369140625 +105.49,345.9543151855469 +105.5,345.9045104980469 +105.51,345.8650207519531 +105.52,345.8468017578125 +105.53,345.8329772949219 +105.54,345.8216247558594 +105.55,345.8127136230469 +105.56,345.8061828613281 +105.57,345.8019714355469 +105.58,345.77777099609375 +105.59,345.7375183105469 +105.6,345.69805908203125 +105.61,345.64263916015625 +105.62,345.5899353027344 +105.63,345.48443603515625 +105.64,345.38214111328125 +105.65,345.2756042480469 +105.66,345.1649169921875 +105.67,345.0334167480469 +105.68,344.888671875 +105.69,344.7400817871094 +105.7,344.54150390625 +105.71,344.3321838378906 +105.72,344.0661315917969 +105.73,343.7734375 +105.74,343.43414306640625 +105.75,343.0966796875 +105.76,342.7186279296875 +105.77,342.315185546875 +105.78,341.8884582519531 +105.79,341.43505859375 +105.8,340.9811706542969 +105.81,340.5341491699219 +105.82,340.093994140625 +105.83,339.6606140136719 +105.84,339.2339782714844 +105.85,338.84912109375 +105.86,338.5001220703125 +105.87,338.1810302734375 +105.88,337.8823547363281 +105.89,337.6039123535156 +105.9,337.38427734375 +105.91,337.1729431152344 +105.92,336.9790954589844 +105.93,336.81549072265625 +105.94,336.663330078125 +105.95,336.5224609375 +105.96,336.396484375 +105.97,336.2741394042969 +105.98,336.1553649902344 +105.99,336.04754638671875 +106,335.9431457519531 +106.01,335.8291320800781 +106.02,335.7129821777344 +106.03,335.60028076171875 +106.04,335.4909973144531 +106.05,335.3610534667969 +106.06,335.23468017578125 +106.07,335.1081237792969 +106.08,334.9591979980469 +106.09,334.8103332519531 +106.1,334.6448974609375 +106.11,334.47222900390625 +106.12,334.2979431152344 +106.13,334.12396240234375 +106.14,333.95025634765625 +106.15,333.76385498046875 +106.16,333.57598876953125 +106.17,333.3663635253906 +106.18,333.1554870605469 +106.19,332.9397277832031 +106.2,332.7191162109375 +106.21,332.5029296875 +106.22,332.2856140136719 +106.23,332.07269287109375 +106.24,331.8641357421875 +106.25,331.6598815917969 +106.26,331.45989990234375 +106.27,331.2622985839844 +106.28,331.0688781738281 +106.29,330.88330078125 +106.3,330.6980895996094 +106.31,330.5058288574219 +106.32,330.3177185058594 +106.33,330.1300048828125 +106.34,329.94451904296875 +106.35,329.7630920410156 +106.36,329.5875244140625 +106.37,329.4158935546875 +106.38,329.2445068359375 +106.39,329.07696533203125 +106.4,328.9095458984375 +106.41,328.7386169433594 +106.42,328.57159423828125 +106.43,328.4157409667969 +106.44,328.26361083984375 +106.45,328.1151428222656 +106.46,327.9869384765625 +106.47,327.8935241699219 +106.48,327.806884765625 +106.49,327.7398376464844 +106.5,327.68658447265625 +106.51,327.6248474121094 +106.52,327.5768127441406 +106.53,327.5312805175781 +106.54,327.491943359375 +106.55,327.4642028808594 +106.56,327.44427490234375 +106.57,327.433837890625 +106.58,327.432861328125 +106.59,327.4338073730469 +106.6,327.4366455078125 +106.61,327.43572998046875 +106.62,327.4348449707031 +106.63,327.4357604980469 +106.64,327.4384765625 +106.65,327.4411315917969 +106.66,327.44000244140625 +106.67,327.43878173828125 +106.68,327.4393310546875 +106.69,327.43231201171875 +106.7,327.42706298828125 +106.71,327.4124450683594 +106.72,327.3830261230469 +106.73,327.3315734863281 +106.74,327.278564453125 +106.75,327.2240295410156 +106.76,327.17169189453125 +106.77,327.1085205078125 +106.78,327.0291442871094 +106.79,326.9466552734375 +106.8,326.86474609375 +106.81,326.78155517578125 +106.82,326.6989440917969 +106.83,326.6039733886719 +106.84,326.5115051269531 +106.85,326.41595458984375 +106.86,326.3432922363281 +106.87,326.27288818359375 +106.88,326.2047424316406 +106.89,326.1387939453125 +106.9,326.084228515625 +106.91,326.035400390625 +106.92,325.99041748046875 +106.93,325.9750671386719 +106.94,325.9706115722656 +106.95,325.9676513671875 +106.96,325.9661560058594 +106.97,325.96978759765625 +106.98,325.97845458984375 +106.99,325.9847412109375 +107,325.9923095703125 +107.01,325.99749755859375 +107.02,326.00213623046875 +107.03,325.998779296875 +107.04,325.9893798828125 +107.05,325.9666442871094 +107.06,325.92694091796875 +107.07,325.88519287109375 +107.08,325.8304443359375 +107.09,325.7756042480469 +107.1,325.7152404785156 +107.11,325.65673828125 +107.12,325.6000671386719 +107.13,325.53411865234375 +107.14,325.45721435546875 +107.15,325.3582763671875 +107.16,325.2597351074219 +107.17,325.15423583984375 +107.18,325.0362548828125 +107.19,324.87457275390625 +107.2,324.7047424316406 +107.21,324.52862548828125 +107.22,324.3426513671875 +107.23,324.1468811035156 +107.24,323.9359130859375 +107.25,323.6656799316406 +107.26,323.3865966796875 +107.27,323.0968933105469 +107.28,322.8077697753906 +107.29,322.4916076660156 +107.3,322.1449890136719 +107.31,321.77935791015625 +107.32,321.3912048339844 +107.33,321.0066223144531 +107.34,320.61822509765625 +107.35,320.1836853027344 +107.36,319.74957275390625 +107.37,319.3214416503906 +107.38,318.8974304199219 +107.39,318.4793701171875 +107.4,318.0579833984375 +107.41,317.6425476074219 +107.42,317.2330627441406 +107.43,316.8294982910156 +107.44,316.4372863769531 +107.45,316.0545349121094 +107.46,315.6737976074219 +107.47,315.2950744628906 +107.48,314.92572021484375 +107.49,314.57489013671875 +107.5,314.24053955078125 +107.51,313.9207458496094 +107.52,313.6061096191406 +107.53,313.3076477050781 +107.54,313.0159912109375 +107.55,312.753173828125 +107.56,312.4949951171875 +107.57,312.24139404296875 +107.58,312.01263427734375 +107.59,311.7881774902344 +107.6,311.573486328125 +107.61,311.3629455566406 +107.62,311.1565246582031 +107.63,310.95416259765625 +107.64,310.755859375 +107.65,310.5670471191406 +107.66,310.38214111328125 +107.67,310.2139587402344 +107.68,310.05499267578125 +107.69,309.9014587402344 +107.7,309.75146484375 +107.71,309.6031188964844 +107.72,309.4600524902344 +107.73,309.31854248046875 +107.74,309.1785888671875 +107.75,309.0419921875 +107.76,308.8940124511719 +107.77,308.7475891113281 +107.78,308.59722900390625 +107.79,308.439208984375 +107.8,308.28289794921875 +107.81,308.12823486328125 +107.82,307.9623107910156 +107.83,307.7705383300781 +107.84,307.5697021484375 +107.85,307.3544006347656 +107.86,307.10272216796875 +107.87,306.8444519042969 +107.88,306.5907287597656 +107.89,306.29736328125 +107.9,305.9905090332031 +107.91,305.66851806640625 +107.92,305.3388977050781 +107.93,304.970458984375 +107.94,304.5912170410156 +107.95,304.1663513183594 +107.96,303.73870849609375 +107.97,303.2641296386719 +107.98,302.75421142578125 +107.99,302.25164794921875 +108,301.7232971191406 +108.01,301.1970520019531 +108.02,300.6766052246094 +108.03,300.1637268066406 +108.04,299.65838623046875 +108.05,299.1623840332031 +108.06,298.6737976074219 +108.07,298.2182922363281 +108.08,297.8526306152344 +108.09,297.5170593261719 +108.1,297.2130432128906 +108.11,296.93115234375 +108.12,296.70428466796875 +108.13,296.49505615234375 +108.14,296.32904052734375 +108.15,296.1671447753906 +108.16,296.0167236328125 +108.17,295.8904724121094 +108.18,295.8047180175781 +108.19,295.73699951171875 +108.2,295.6833801269531 +108.21,295.6344909667969 +108.22,295.5957946777344 +108.23,295.6094970703125 +108.24,295.6253356933594 +108.25,295.6598815917969 +108.26,295.6963195800781 +108.27,295.71795654296875 +108.28,295.71575927734375 +108.29,295.69915771484375 +108.3,295.6848449707031 +108.31,295.6212158203125 +108.32,295.52166748046875 +108.33,295.42340087890625 +108.34,295.32818603515625 +108.35,295.23602294921875 +108.36,295.1468505859375 +108.37,295.05694580078125 +108.38,294.9442138671875 +108.39,294.83465576171875 +108.4,294.7264099121094 +108.41,294.6175537109375 +108.42,294.493408203125 +108.43,294.35040283203125 +108.44,294.20166015625 +108.45,294.0527038574219 +108.46,293.9072570800781 +108.47,293.7560729980469 +108.48,293.6065673828125 +108.49,293.4623718261719 +108.5,293.32159423828125 +108.51,293.1842041015625 +108.52,293.08880615234375 +108.53,293 +108.54,292.9434509277344 +108.55,292.8599548339844 +108.56,292.7792053222656 +108.57,292.701171875 +108.58,292.6221008300781 +108.59,292.52734375 +108.6,292.420654296875 +108.61,292.24884033203125 +108.62,292.0696105957031 +108.63,291.85906982421875 +108.64,291.5495300292969 +108.65,291.2377014160156 +108.66,290.9125671386719 +108.67,290.57977294921875 +108.68,290.23211669921875 +108.69,289.8659973144531 +108.7,289.4890441894531 +108.71,289.0719909667969 +108.72,288.6318054199219 +108.73,288.1778869628906 +108.74,287.7306823730469 +108.75,287.29010009765625 +108.76,286.8542785644531 +108.77,286.425048828125 +108.78,286.0041809082031 +108.79,285.5934753417969 +108.8,285.235107421875 +108.81,284.88629150390625 +108.82,284.5487060546875 +108.83,284.22418212890625 +108.84,283.9235534667969 +108.85,283.6300964355469 +108.86,283.34375 +108.87,283.07177734375 +108.88,282.826904296875 +108.89,282.5868225097656 +108.9,282.3587951660156 +108.91,282.1372375488281 +108.92,281.9201965332031 +108.93,281.7297058105469 +108.94,281.5434265136719 +108.95,281.361328125 +108.96,281.183349609375 +108.97,280.98553466796875 +108.98,280.7920227050781 +108.99,280.6174621582031 +109,280.42852783203125 +109.01,280.2419128417969 +109.02,280.0502624511719 +109.03,279.8554992675781 +109.04,279.6612854003906 +109.05,279.4749755859375 +109.06,279.29278564453125 +109.07,279.11468505859375 +109.08,278.94061279296875 +109.09,278.7705383300781 +109.1,278.6153869628906 +109.11,278.4659118652344 +109.12,278.329345703125 +109.13,278.2000427246094 +109.14,278.1054992675781 +109.15,278.02880859375 +109.16,277.9605407714844 +109.17,277.9391784667969 +109.18,277.98638916015625 +109.19,278.0517578125 +109.2,278.1240234375 +109.21,278.1976013183594 +109.22,278.2724304199219 +109.23,278.34844970703125 +109.24,278.4201354980469 +109.25,278.4212646484375 +109.26,278.3636474609375 +109.27,278.25518798828125 +109.28,278.10009765625 +109.29,277.9466552734375 +109.3,277.796630859375 +109.31,277.648193359375 +109.32,277.4884033203125 +109.33,277.3321228027344 +109.34,277.17926025390625 +109.35,277.02984619140625 +109.36,276.8985290527344 +109.37,276.7557067871094 +109.38,276.61614990234375 +109.39,276.4798889160156 +109.4,276.34686279296875 +109.41,276.2170104980469 +109.42,276.0885314941406 +109.43,275.9631652832031 +109.44,275.8206481933594 +109.45,275.674072265625 +109.46,275.52532958984375 +109.47,275.3706970214844 +109.48,275.2194519042969 +109.49,275.0715637207031 +109.5,274.9177551269531 +109.51,274.767333984375 +109.52,274.6202392578125 +109.53,274.4709777832031 +109.54,274.3139953613281 +109.55,274.1585693359375 +109.56,274.0065002441406 +109.57,273.83941650390625 +109.58,273.6593322753906 +109.59,273.4791564941406 +109.6,273.2861022949219 +109.61,273.0545654296875 +109.62,272.8235168457031 +109.63,272.5653991699219 +109.64,272.28424072265625 +109.65,271.99676513671875 +109.66,271.7085266113281 +109.67,271.3681945800781 +109.68,271.0130615234375 +109.69,270.65789794921875 +109.7,270.2202453613281 +109.71,269.77801513671875 +109.72,269.3166198730469 +109.73,268.84722900390625 +109.74,268.3351745605469 +109.75,267.83038330078125 +109.76,267.3071594238281 +109.77,266.778564453125 +109.78,266.2392578125 +109.79,265.7076416015625 +109.8,265.18365478515625 +109.81,264.66729736328125 +109.82,264.15850830078125 +109.83,263.6590576171875 +109.84,263.17437744140625 +109.85,262.7024841308594 +109.86,262.25799560546875 +109.87,261.8314514160156 +109.88,261.4135437011719 +109.89,261.0078125 +109.9,260.6289367675781 +109.91,260.28021240234375 +109.92,259.9375305175781 +109.93,259.60089111328125 +109.94,259.27935791015625 +109.95,258.9654541015625 +109.96,258.66461181640625 +109.97,258.369384765625 +109.98,258.08154296875 +109.99,257.7973327636719 +110,257.5185852050781 +110.01,257.25079345703125 +110.02,257.0010986328125 +110.03,256.7730407714844 +110.04,256.54986572265625 +110.05,256.3315124511719 +110.06,256.1179504394531 +110.07,255.9091033935547 +110.08,255.70492553710938 +110.09,255.4998321533203 +110.1,255.2957000732422 +110.11,255.09616088867188 +110.12,254.9011993408203 +110.13,254.7107391357422 +110.14,254.5247344970703 +110.15,254.36517333984375 +110.16,254.20973205566406 +110.17,254.058349609375 +110.18,253.91098022460938 +110.19,253.76759338378906 +110.2,253.6281280517578 +110.21,253.4796905517578 +110.22,253.32237243652344 +110.23,253.16908264160156 +110.24,253.019775390625 +110.25,252.8707275390625 +110.26,252.72560119628906 +110.27,252.58802795410156 +110.28,252.4542236328125 +110.29,252.3296356201172 +110.3,252.20867919921875 +110.31,252.09129333496094 +110.32,251.9737548828125 +110.33,251.8597412109375 +110.34,251.7491912841797 +110.35,251.64207458496094 +110.36,251.538330078125 +110.37,251.42877197265625 +110.38,251.32260131835938 +110.39,251.21791076660156 +110.4,251.10186767578125 +110.41,250.98922729492188 +110.42,250.87449645996094 +110.43,250.76312255859375 +110.44,250.64772033691406 +110.45,250.5485076904297 +110.46,250.45248413085938 +110.47,250.35958862304688 +110.48,250.26800537109375 +110.49,250.16482543945312 +110.5,250.0538330078125 +110.51,249.94786071777344 +110.52,249.84329223632812 +110.53,249.71800231933594 +110.54,249.59060668945312 +110.55,249.44644165039062 +110.56,249.3058319091797 +110.57,249.1668701171875 +110.58,249.007568359375 +110.59,248.85194396972656 +110.6,248.69259643554688 +110.61,248.5369110107422 +110.62,248.38485717773438 +110.63,248.22540283203125 +110.64,248.0531463623047 +110.65,247.87001037597656 +110.66,247.68162536621094 +110.67,247.46237182617188 +110.68,247.2473907470703 +110.69,247.02932739257812 +110.7,246.80999755859375 +110.71,246.58763122558594 +110.72,246.36227416992188 +110.73,246.14125061035156 +110.74,245.9208526611328 +110.75,245.70289611816406 +110.76,245.4580841064453 +110.77,245.18853759765625 +110.78,244.88722229003906 +110.79,244.56907653808594 +110.8,244.22149658203125 +110.81,243.85397338867188 +110.82,243.48501586914062 +110.83,243.0999755859375 +110.84,242.69363403320312 +110.85,242.28262329101562 +110.86,241.87802124023438 +110.87,241.47976684570312 +110.88,241.0878143310547 +110.89,240.70213317871094 +110.9,240.34280395507812 +110.91,239.99488830566406 +110.92,239.66014099121094 +110.93,239.35488891601562 +110.94,239.06060791015625 +110.95,238.7752685546875 +110.96,238.50070190429688 +110.97,238.25320434570312 +110.98,238.01425170898438 +110.99,237.79286193847656 +111,237.5834197998047 +111.01,237.38394165039062 +111.02,237.190673828125 +111.03,237.00901794433594 +111.04,236.84251403808594 +111.05,236.69287109375 +111.06,236.5525360107422 +111.07,236.43238830566406 +111.08,236.33226013183594 +111.09,236.24090576171875 +111.1,236.158203125 +111.11,236.08226013183594 +111.12,236.0093231201172 +111.13,235.93934631347656 +111.14,235.87229919433594 +111.15,235.80810546875 +111.16,235.74673461914062 +111.17,235.6881561279297 +111.18,235.6048583984375 +111.19,235.49708557128906 +111.2,235.39068603515625 +111.21,235.28016662597656 +111.22,235.17286682128906 +111.23,235.05592346191406 +111.24,234.93309020996094 +111.25,234.80259704589844 +111.26,234.6754913330078 +111.27,234.55174255371094 +111.28,234.42213439941406 +111.29,234.29405212402344 +111.3,234.171142578125 +111.31,234.05149841308594 +111.32,233.93325805664062 +111.33,233.81822204589844 +111.34,233.70635986328125 +111.35,233.59400939941406 +111.36,233.48294067382812 +111.37,233.3182373046875 +111.38,233.13156127929688 +111.39,232.9286651611328 +111.4,232.65853881835938 +111.41,232.39129638671875 +111.42,232.10316467285156 +111.43,231.776123046875 +111.44,231.4014434814453 +111.45,231.02346801757812 +111.46,230.60755920410156 +111.47,230.1925048828125 +111.48,229.78378295898438 +111.49,229.38136291503906 +111.5,228.98519897460938 +111.51,228.5989227294922 +111.52,228.22055053710938 +111.53,227.8683319091797 +111.54,227.5237579345703 +111.55,227.186767578125 +111.56,226.8572998046875 +111.57,226.61199951171875 +111.58,226.38238525390625 +111.59,226.15919494628906 +111.6,225.94967651367188 +111.61,225.75003051757812 +111.62,225.5948944091797 +111.63,225.51309204101562 +111.64,225.441650390625 +111.65,225.38775634765625 +111.66,225.3457489013672 +111.67,225.30633544921875 +111.68,225.29876708984375 +111.69,225.3007049560547 +111.7,225.3120574951172 +111.71,225.32720947265625 +111.72,225.34609985351562 +111.73,225.36862182617188 +111.74,225.39292907714844 +111.75,225.42449951171875 +111.76,225.4631805419922 +111.77,225.50340270996094 +111.78,225.5506134033203 +111.79,225.59922790527344 +111.8,225.64920043945312 +111.81,225.67306518554688 +111.82,225.69671630859375 +111.83,225.69815063476562 +111.84,225.69583129882812 +111.85,225.68069458007812 +111.86,225.66744995117188 +111.87,225.65423583984375 +111.88,225.6446990966797 +111.89,225.63511657714844 +111.9,225.6273193359375 +111.91,225.6212615966797 +111.92,225.61691284179688 +111.93,225.61424255371094 +111.94,225.6132049560547 +111.95,225.61378479003906 +111.96,225.61595153808594 +111.97,225.61781311035156 +111.98,225.59744262695312 +111.99,225.5587158203125 +112,225.5090789794922 +112.01,225.44869995117188 +112.02,225.38128662109375 +112.03,225.314208984375 +112.04,225.2346954345703 +112.05,225.15016174316406 +112.06,225.05514526367188 +112.07,224.93148803710938 +112.08,224.7904815673828 +112.09,224.65237426757812 +112.1,224.51715087890625 +112.11,224.37930297851562 +112.12,224.21871948242188 +112.13,224.02655029296875 +112.14,223.8323516845703 +112.15,223.63613891601562 +112.16,223.40504455566406 +112.17,223.16319274902344 +112.18,222.92347717285156 +112.19,222.67132568359375 +112.2,222.42144775390625 +112.21,222.16656494140625 +112.22,221.91213989257812 +112.23,221.63633728027344 +112.24,221.33023071289062 +112.25,221.02699279785156 +112.26,220.72120666503906 +112.27,220.41648864746094 +112.28,220.10011291503906 +112.29,219.75755310058594 +112.3,219.3909912109375 +112.31,219.00070190429688 +112.32,218.59967041015625 +112.33,218.2026824951172 +112.34,217.80606079101562 +112.35,217.40249633789062 +112.36,216.99395751953125 +112.37,216.582275390625 +112.38,216.1766815185547 +112.39,215.74978637695312 +112.4,215.32186889648438 +112.41,214.89663696289062 +112.42,214.4777069091797 +112.43,214.05960083007812 +112.44,213.6477813720703 +112.45,213.24220275878906 +112.46,212.8428192138672 +112.47,212.44960021972656 +112.48,212.0697784423828 +112.49,211.69595336914062 +112.5,211.33360290527344 +112.51,210.97889709472656 +112.52,210.64093017578125 +112.53,210.32684326171875 +112.54,210.04368591308594 +112.55,209.78204345703125 +112.56,209.52528381347656 +112.57,209.27880859375 +112.58,209.04800415039062 +112.59,208.88565063476562 +112.6,208.73631286621094 +112.61,208.59254455566406 +112.62,208.4524383544922 +112.63,208.31593322753906 +112.64,208.18116760253906 +112.65,208.05355834960938 +112.66,207.93125915527344 +112.67,207.81236267089844 +112.68,207.70228576660156 +112.69,207.58999633789062 +112.7,207.48643493652344 +112.71,207.38787841796875 +112.72,207.2924346923828 +112.73,207.20004272460938 +112.74,207.10882568359375 +112.75,206.99688720703125 +112.76,206.88446044921875 +112.77,206.7752227783203 +112.78,206.66912841796875 +112.79,206.56614685058594 +112.8,206.46444702148438 +112.81,206.33839416503906 +112.82,206.2101593017578 +112.83,206.08522033691406 +112.84,205.96353149414062 +112.85,205.84507751464844 +112.86,205.72616577148438 +112.87,205.59764099121094 +112.88,205.47238159179688 +112.89,205.34120178222656 +112.9,205.2133026123047 +112.91,205.07769775390625 +112.92,204.9563446044922 +112.93,204.85455322265625 +112.94,204.72837829589844 +112.95,204.63095092773438 +112.96,204.53640747070312 +112.97,204.43740844726562 +112.98,204.34129333496094 +112.99,204.24256896972656 +113,204.14669799804688 +113.01,204.04635620117188 +113.02,203.94525146484375 +113.03,203.8470001220703 +113.04,203.73696899414062 +113.05,203.61892700195312 +113.06,203.48568725585938 +113.07,203.3227996826172 +113.08,203.15969848632812 +113.09,202.99459838867188 +113.1,202.82936096191406 +113.11,202.66213989257812 +113.12,202.49842834472656 +113.13,202.33819580078125 +113.14,202.15953063964844 +113.15,201.97171020507812 +113.16,201.7729949951172 +113.17,201.57080078125 +113.18,201.36154174804688 +113.19,201.13983154296875 +113.2,200.92042541503906 +113.21,200.69961547851562 +113.22,200.479248046875 +113.23,200.26295471191406 +113.24,200.0488739013672 +113.25,199.82241821289062 +113.26,199.5982666015625 +113.27,199.35638427734375 +113.28,199.10972595214844 +113.29,198.84373474121094 +113.3,198.56231689453125 +113.31,198.28201293945312 +113.32,198.00279235839844 +113.33,197.71560668945312 +113.34,197.42779541015625 +113.35,197.11935424804688 +113.36,196.80691528320312 +113.37,196.49961853027344 +113.38,196.19741821289062 +113.39,195.8984375 +113.4,195.60450744628906 +113.41,195.31739807128906 +113.42,195.04248046875 +113.43,194.77239990234375 +113.44,194.50709533691406 +113.45,194.2483673095703 +113.46,193.9924774169922 +113.47,193.74124145507812 +113.48,193.49461364746094 +113.49,193.25619506835938 +113.5,193.0222625732422 +113.51,192.79824829101562 +113.52,192.58042907714844 +113.53,192.42156982421875 +113.54,192.2845916748047 +113.55,192.1583709716797 +113.56,192.0445556640625 +113.57,191.97952270507812 +113.58,191.92442321777344 +113.59,191.9229278564453 +113.6,191.92344665527344 +113.61,191.93505859375 +113.62,191.95211791992188 +113.63,191.9782257080078 +113.64,192.00779724121094 +113.65,192.04440307617188 +113.66,192.0824737548828 +113.67,192.12197875976562 +113.68,192.1628875732422 +113.69,192.20693969726562 +113.7,192.25228881835938 +113.71,192.2989044189453 +113.72,192.3467559814453 +113.73,192.36483764648438 +113.74,192.3843994140625 +113.75,192.39988708496094 +113.76,192.411376953125 +113.77,192.4243621826172 +113.78,192.42971801757812 +113.79,192.4256591796875 +113.8,192.417724609375 +113.81,192.40232849121094 +113.82,192.37583923339844 +113.83,192.342041015625 +113.84,192.28456115722656 +113.85,192.22735595703125 +113.86,192.1448974609375 +113.87,192.06475830078125 +113.88,191.98507690429688 +113.89,191.87307739257812 +113.9,191.71632385253906 +113.91,191.5553436279297 +113.92,191.3956756591797 +113.93,191.22996520996094 +113.94,191.0601806640625 +113.95,190.87904357910156 +113.96,190.6920928955078 +113.97,190.46852111816406 +113.98,190.24505615234375 +113.99,190.01080322265625 +114,189.77500915527344 +114.01,189.53587341308594 +114.02,189.29888916015625 +114.03,189.06224060058594 +114.04,188.7949676513672 +114.05,188.47195434570312 +114.06,188.1447296142578 +114.07,187.77696228027344 +114.08,187.40187072753906 +114.09,186.9885711669922 +114.1,186.48301696777344 +114.11,185.92979431152344 +114.12,185.37672424316406 +114.13,184.83111572265625 +114.14,184.29290771484375 +114.15,183.76206970214844 +114.16,183.2385711669922 +114.17,182.74964904785156 +114.18,182.33309936523438 +114.19,181.94284057617188 +114.2,181.56036376953125 +114.21,181.20750427246094 +114.22,180.92758178710938 +114.23,180.67625427246094 +114.24,180.43133544921875 +114.25,180.2419891357422 +114.26,180.06027221679688 +114.27,179.88973999023438 +114.28,179.7266387939453 +114.29,179.5672607421875 +114.3,179.40794372558594 +114.31,179.24505615234375 +114.32,179.08224487304688 +114.33,178.9213104248047 +114.34,178.7640380859375 +114.35,178.5503692626953 +114.36,178.3263702392578 +114.37,178.09759521484375 +114.38,177.81686401367188 +114.39,177.53919982910156 +114.4,177.260986328125 +114.41,176.9640655517578 +114.42,176.65769958496094 +114.43,176.3565673828125 +114.44,175.98974609375 +114.45,175.61968994140625 +114.46,175.2392120361328 +114.47,174.82476806640625 +114.48,174.41311645507812 +114.49,174.00424194335938 +114.5,173.5781707763672 +114.51,173.142333984375 +114.52,172.7041473388672 +114.53,172.2690887451172 +114.54,171.8353271484375 +114.55,171.39382934570312 +114.56,170.95556640625 +114.57,170.52415466308594 +114.58,170.09231567382812 +114.59,169.66729736328125 +114.6,169.24905395507812 +114.61,168.83755493164062 +114.62,168.43637084960938 +114.63,168.04177856445312 +114.64,167.65736389160156 +114.65,167.27940368652344 +114.66,166.9078369140625 +114.67,166.54263305664062 +114.68,166.18008422851562 +114.69,165.8201904296875 +114.7,165.4611053466797 +114.71,165.10829162597656 +114.72,164.7598419189453 +114.73,164.4121551513672 +114.74,164.06703186035156 +114.75,163.7280731201172 +114.76,163.3988494873047 +114.77,163.07565307617188 +114.78,162.7584228515625 +114.79,162.4470977783203 +114.8,162.14163208007812 +114.81,161.84194946289062 +114.82,161.55528259277344 +114.83,161.2760772705078 +114.84,161.0005645751953 +114.85,160.7287139892578 +114.86,160.4623260498047 +114.87,160.21043395996094 +114.88,159.9638214111328 +114.89,159.7260284423828 +114.9,159.49334716796875 +114.91,159.26573181152344 +114.92,159.04310607910156 +114.93,158.825439453125 +114.94,158.61265563964844 +114.95,158.3974609375 +114.96,158.17623901367188 +114.97,157.95994567871094 +114.98,157.74302673339844 +114.99,157.52188110351562 +115,157.302001953125 +115.01,157.08700561523438 +115.02,156.8641815185547 +115.03,156.64627075195312 +115.04,156.39141845703125 +115.05,156.13999938964844 +115.06,155.8883056640625 +115.07,155.63272094726562 +115.08,155.38058471679688 +115.09,155.1263885498047 +115.1,154.86654663085938 +115.11,154.60650634765625 +115.12,154.35174560546875 +115.13,154.09133911132812 +115.14,153.82350158691406 +115.15,153.55014038085938 +115.16,153.265869140625 +115.17,152.98533630371094 +115.18,152.7030487060547 +115.19,152.42630004882812 +115.2,152.1459197998047 +115.21,151.86741638183594 +115.22,151.5817413330078 +115.23,151.29800415039062 +115.24,151.01980590820312 +115.25,150.73800659179688 +115.26,150.45811462402344 +115.27,150.16561889648438 +115.28,149.87335205078125 +115.29,149.5721893310547 +115.3,149.273193359375 +115.31,148.97991943359375 +115.32,148.6904754638672 +115.33,148.3975830078125 +115.34,148.101318359375 +115.35,147.8035125732422 +115.36,147.50782775878906 +115.37,147.1960906982422 +115.38,146.89022827148438 +115.39,146.57748413085938 +115.4,146.27066040039062 +115.41,145.9715118408203 +115.42,145.67630004882812 +115.43,145.38504028320312 +115.44,145.09947204589844 +115.45,144.8213348388672 +115.46,144.5506134033203 +115.47,144.28175354003906 +115.48,144.02200317382812 +115.49,143.7603759765625 +115.5,143.50413513183594 +115.51,143.2532501220703 +115.52,143.02032470703125 +115.53,142.8015899658203 +115.54,142.58779907226562 +115.55,142.37890625 +115.56,142.1748504638672 +115.57,141.9755859375 +115.58,141.78103637695312 +115.59,141.59117126464844 +115.6,141.40591430664062 +115.61,141.22520446777344 +115.62,141.04901123046875 +115.63,140.87362670898438 +115.64,140.7026824951172 +115.65,140.5233917236328 +115.66,140.3431396484375 +115.67,140.16192626953125 +115.68,139.9851837158203 +115.69,139.81826782226562 +115.7,139.6502227783203 +115.71,139.48464965820312 +115.72,139.32333374023438 +115.73,139.16622924804688 +115.74,139.0042266845703 +115.75,138.8264617919922 +115.76,138.66940307617188 +115.77,138.51646423339844 +115.78,138.3621368408203 +115.79,138.21189880371094 +115.8,138.0711669921875 +115.81,137.93434143066406 +115.82,137.8013916015625 +115.83,137.68313598632812 +115.84,137.57943725585938 +115.85,137.48825073242188 +115.86,137.40951538085938 +115.87,137.3394012451172 +115.88,137.2669677734375 +115.89,137.21572875976562 +115.9,137.17645263671875 +115.91,137.13992309570312 +115.92,137.10606384277344 +115.93,137.0820770263672 +115.94,137.0442657470703 +115.95,137.0000457763672 +115.96,136.9567108154297 +115.97,136.91603088378906 +115.98,136.87428283691406 +115.99,136.8097381591797 +116,136.71359252929688 +116.01,136.61692810058594 +116.02,136.48171997070312 +116.03,136.34097290039062 +116.04,136.1983184814453 +116.05,135.9886016845703 +116.06,135.7686309814453 +116.07,135.5403594970703 +116.08,135.31112670898438 +116.09,135.0193328857422 +116.1,134.7073211669922 +116.11,134.38612365722656 +116.12,134.07037353515625 +116.13,133.7491912841797 +116.14,133.4334716796875 +116.15,133.1231689453125 +116.16,132.82907104492188 +116.17,132.54197692871094 +116.18,132.2691192626953 +116.19,132.00489807128906 +116.2,131.76007080078125 +116.21,131.52359008789062 +116.22,131.30441284179688 +116.23,131.10421752929688 +116.24,130.94268798828125 +116.25,130.78501892089844 +116.26,130.6311798095703 +116.27,130.49746704101562 +116.28,130.37637329101562 +116.29,130.28408813476562 +116.3,130.1967010498047 +116.31,130.11599731445312 +116.32,130.0527801513672 +116.33,129.99774169921875 +116.34,129.95989990234375 +116.35,129.93179321289062 +116.36,129.9060516357422 +116.37,129.88626098632812 +116.38,129.86871337890625 +116.39,129.8497314453125 +116.4,129.83657836914062 +116.41,129.83642578125 +116.42,129.83822631835938 +116.43,129.83106994628906 +116.44,129.81141662597656 +116.45,129.80653381347656 +116.46,129.8035888671875 +116.47,129.8025360107422 +116.48,129.80332946777344 +116.49,129.79327392578125 +116.5,129.7796630859375 +116.51,129.76437377929688 +116.52,129.73292541503906 +116.53,129.68905639648438 +116.54,129.6093292236328 +116.55,129.5321502685547 +116.56,129.4502410888672 +116.57,129.36732482910156 +116.58,129.2833251953125 +116.59,129.19464111328125 +116.6,129.1085662841797 +116.61,129.02322387695312 +116.62,128.94041442871094 +116.63,128.85470581054688 +116.64,128.7570343017578 +116.65,128.66201782226562 +116.66,128.54791259765625 +116.67,128.4311981201172 +116.68,128.3154754638672 +116.69,128.20079040527344 +116.7,128.0852813720703 +116.71,127.95989990234375 +116.72,127.81929016113281 +116.73,127.67994689941406 +116.74,127.5382308959961 +116.75,127.3796615600586 +116.76,127.22432708740234 +116.77,127.0432357788086 +116.78,126.86198425292969 +116.79,126.67879486083984 +116.8,126.49726104736328 +116.81,126.31195831298828 +116.82,126.1283950805664 +116.83,125.94470977783203 +116.84,125.75008392333984 +116.85,125.55545806884766 +116.86,125.36447143554688 +116.87,125.1698226928711 +116.88,124.96436309814453 +116.89,124.75361633300781 +116.9,124.53218078613281 +116.91,124.30925750732422 +116.92,124.08124542236328 +116.93,123.8518295288086 +116.94,123.62281799316406 +116.95,123.38695526123047 +116.96,123.15521240234375 +116.97,122.91671752929688 +116.98,122.6462173461914 +116.99,122.38021087646484 +117,122.1168212890625 +117.01,121.85429382324219 +117.02,121.59258270263672 +117.03,121.33531951904297 +117.04,121.07704162597656 +117.05,120.82319641113281 +117.06,120.54840087890625 +117.07,120.27643585205078 +117.08,119.98551177978516 +117.09,119.69035339355469 +117.1,119.39647674560547 +117.11,119.1056137084961 +117.12,118.81417846679688 +117.13,118.52581787109375 +117.14,118.22420501708984 +117.15,117.92216491699219 +117.16,117.61965942382812 +117.17,117.30413818359375 +117.18,116.99378204345703 +117.19,116.67767333984375 +117.2,116.36318969726562 +117.21,116.04842376708984 +117.22,115.72982788085938 +117.23,115.40745544433594 +117.24,115.08140563964844 +117.25,114.7607192993164 +117.26,114.44172668457031 +117.27,114.11538696289062 +117.28,113.79267120361328 +117.29,113.4500503540039 +117.3,113.11119079589844 +117.31,112.75626373291016 +117.32,112.38900756835938 +117.33,112.02764892578125 +117.34,111.6631088256836 +117.35,111.29727935791016 +117.36,110.9373779296875 +117.37,110.58336639404297 +117.38,110.23519897460938 +117.39,109.87837219238281 +117.4,109.529296875 +117.41,109.18605041503906 +117.42,108.84859466552734 +117.43,108.51687622070312 +117.44,108.18360137939453 +117.45,107.85242462158203 +117.46,107.51612854003906 +117.47,107.18560791015625 +117.48,106.8608169555664 +117.49,106.54170227050781 +117.5,106.22643280029297 +117.51,105.91676330566406 +117.52,105.60723114013672 +117.53,105.2797622680664 +117.54,104.95804595947266 +117.55,104.6366195678711 +117.56,104.31726837158203 +117.57,104.00361633300781 +117.58,103.69561004638672 +117.59,103.38778686523438 +117.6,103.08015441894531 +117.61,102.77812194824219 +117.62,102.48165130615234 +117.63,102.17807006835938 +117.64,101.87828063964844 +117.65,101.58404541015625 +117.66,101.2953109741211 +117.67,101.01202392578125 +117.68,100.73591613769531 +117.69,100.4795913696289 +117.7,100.2283935546875 +117.71,99.98946380615234 +117.72,99.76268005371094 +117.73,99.54071807861328 +117.74,99.3398208618164 +117.75,99.154296875 +117.76,98.99302673339844 +117.77,98.85391235351562 +117.78,98.7240982055664 +117.79,98.59990692138672 +117.8,98.48663330078125 +117.81,98.38419342041016 +117.82,98.32311248779297 +117.83,98.27772521972656 +117.84,98.23516845703125 +117.85,98.2044448852539 +117.86,98.17993927001953 +117.87,98.16525268554688 +117.88,98.1584243774414 +117.89,98.16838836669922 +117.9,98.1822738647461 +117.91,98.207275390625 +117.92,98.23968505859375 +117.93,98.27392578125 +117.94,98.3099594116211 +117.95,98.34774017333984 +117.96,98.38721466064453 +117.97,98.41205596923828 +117.98,98.42423248291016 +117.99,98.43109893798828 +118,98.4109878540039 +118.01,98.3894271850586 +118.02,98.36825561523438 +118.03,98.34561920166016 +118.04,98.27999114990234 +118.05,98.18083953857422 +118.06,98.082763671875 +118.07,97.96414947509766 +118.08,97.84500122070312 +118.09,97.72895050048828 +118.1,97.61233520507812 +118.11,97.49877166748047 +118.12,97.38822937011719 +118.13,97.26805114746094 +118.14,97.14012145996094 +118.15,97.01534271240234 +118.16,96.88641357421875 +118.17,96.7570571899414 +118.18,96.63085174560547 +118.19,96.50591278076172 +118.2,96.3840560913086 +118.21,96.25443267822266 +118.22,96.12793731689453 +118.23,96.00090026855469 +118.24,95.86796569824219 +118.25,95.70028686523438 +118.26,95.53610229492188 +118.27,95.36271667480469 +118.28,95.1928939819336 +118.29,95.0265884399414 +118.3,94.86376953125 +118.31,94.70439147949219 +118.32,94.55205535888672 +118.33,94.41209411621094 +118.34,94.2843246459961 +118.35,94.17407989501953 +118.36,94.06672668457031 +118.37,93.96222686767578 +118.38,93.84971618652344 +118.39,93.74010467529297 +118.4,93.63697052001953 +118.41,93.54203796386719 +118.42,93.44979095458984 +118.43,93.3602066040039 +118.44,93.27323913574219 +118.45,93.18885803222656 +118.46,93.11422729492188 +118.47,93.04923248291016 +118.48,92.986572265625 +118.49,92.92620849609375 +118.5,92.8626937866211 +118.51,92.80872344970703 +118.52,92.75692749023438 +118.53,92.707275390625 +118.54,92.65248107910156 +118.55,92.59983825683594 +118.56,92.54930877685547 +118.57,92.50086975097656 +118.58,92.45447540283203 +118.59,92.41011047363281 +118.6,92.35511779785156 +118.61,92.30221557617188 +118.62,92.2297134399414 +118.63,92.14143371582031 +118.64,92.04473876953125 +118.65,91.9468994140625 +118.66,91.81365966796875 +118.67,91.68148803710938 +118.68,91.5503158569336 +118.69,91.40936279296875 +118.7,91.26776123046875 +118.71,91.12368774414062 +118.72,90.95729064941406 +118.73,90.76885986328125 +118.74,90.57843780517578 +118.75,90.36802673339844 +118.76,90.1252212524414 +118.77,89.83596801757812 +118.78,89.53496551513672 +118.79,89.21336364746094 +118.8,88.88941955566406 +118.81,88.56133270263672 +118.82,88.22559356689453 +118.83,87.88050842285156 +118.84,87.54060363769531 +118.85,87.1914291381836 +118.86,86.82769775390625 +118.87,86.46939849853516 +118.88,86.11648559570312 +118.89,85.77249908447266 +118.9,85.43379211425781 +118.91,85.10032653808594 +118.92,84.78646850585938 +118.93,84.47763061523438 +118.94,84.188232421875 +118.95,83.90541076660156 +118.96,83.62911987304688 +118.97,83.38273620605469 +118.98,83.1479263305664 +118.99,82.92822265625 +119,82.72887420654297 +119.01,82.5514144897461 +119.02,82.38311004638672 +119.03,82.24178314208984 +119.04,82.10555267333984 +119.05,81.97257995605469 +119.06,81.84645080566406 +119.07,81.72346496582031 +119.08,81.61262512207031 +119.09,81.50653076171875 +119.1,81.40335845947266 +119.11,81.30306243896484 +119.12,81.20381927490234 +119.13,81.10014343261719 +119.14,81.00657653808594 +119.15,80.91033935546875 +119.16,80.8168716430664 +119.17,80.72428894042969 +119.18,80.62543487548828 +119.19,80.51851654052734 +119.2,80.41443634033203 +119.21,80.31317138671875 +119.22,80.23091125488281 +119.23,80.1259994506836 +119.24,80.02388000488281 +119.25,79.92630004882812 +119.26,79.83505249023438 +119.27,79.75719451904297 +119.28,79.68182373046875 +119.29,79.60169982910156 +119.3,79.52406311035156 +119.31,79.44888305664062 +119.32,79.37975311279297 +119.33,79.3129653930664 +119.34,79.25028228759766 +119.35,79.18985748291016 +119.36,79.12987518310547 +119.37,79.07210540771484 +119.38,79.01651763916016 +119.39,78.94320678710938 +119.4,78.86322784423828 +119.41,78.77843475341797 +119.42,78.68708038330078 +119.43,78.59278869628906 +119.44,78.49738311767578 +119.45,78.40448760986328 +119.46,78.32311248779297 +119.47,78.2332763671875 +119.48,78.14045715332031 +119.49,78.04468536376953 +119.5,77.9405746459961 +119.51,77.826416015625 +119.52,77.70415496826172 +119.53,77.57560729980469 +119.54,77.4390869140625 +119.55,77.30183410644531 +119.56,77.14766693115234 +119.57,76.99114990234375 +119.58,76.82875061035156 +119.59,76.667724609375 +119.6,76.50080871582031 +119.61,76.31370544433594 +119.62,76.11199951171875 +119.63,75.91029357910156 +119.64,75.70854949951172 +119.65,75.51038360595703 +119.66,75.31220245361328 +119.67,75.10675811767578 +119.68,74.90132904052734 +119.69,74.67794799804688 +119.7,74.45120239257812 +119.71,74.22655487060547 +119.72,74.00220489501953 +119.73,73.7726821899414 +119.74,73.5470962524414 +119.75,73.32183074951172 +119.76,73.09326934814453 +119.77,72.85247039794922 +119.78,72.60135650634766 +119.79,72.34539031982422 +119.8,72.08832550048828 +119.81,71.83369445800781 +119.82,71.58516693115234 +119.83,71.3390121459961 +119.84,71.09169006347656 +119.85,70.85394287109375 +119.86,70.62215423583984 +119.87,70.39443969726562 +119.88,70.17254638671875 +119.89,69.95641326904297 +119.9,69.75682067871094 +119.91,69.56098937988281 +119.92,69.3724594116211 +119.93,69.19120788574219 +119.94,69.01710510253906 +119.95,68.85733795166016 +119.96,68.70272064208984 +119.97,68.55140686035156 +119.98,68.40335845947266 +119.99,68.258544921875 +120,68.12591552734375 +120.01,68.0053939819336 +120.02,67.89138793945312 +120.03,67.78025817871094 +120.04,67.67374420166016 +120.05,67.57000732421875 +120.06,67.4690170288086 +120.07,67.37073516845703 +120.08,67.28054809570312 +120.09,67.18753051757812 +120.1,67.09711456298828 +120.11,67.00748443603516 +120.12,66.91863250732422 +120.13,66.826904296875 +120.14,66.73233032226562 +120.15,66.64034271240234 +120.16,66.5509033203125 +120.17,66.46398162841797 +120.18,66.37596893310547 +120.19,66.28324890136719 +120.2,66.18765258789062 +120.21,66.09461212158203 +120.22,65.96089172363281 +120.23,65.7923355102539 +120.24,65.61808776855469 +120.25,65.44540405273438 +120.26,65.26886749267578 +120.27,65.09030151367188 +120.28,64.89175415039062 +120.29,64.69503784179688 +120.3,64.47134399414062 +120.31,64.24795532226562 +120.32,64.02305603027344 +120.33,63.79848861694336 +120.34,63.57421112060547 +120.35,63.35385513305664 +120.36,63.137386322021484 +120.37,62.92477035522461 +120.38,62.71240234375 +120.39,62.50385665893555 +120.4,62.268516540527344 +120.41,62.035457611083984 +120.42,61.79747009277344 +120.43,61.563594818115234 +120.44,61.33380126953125 +120.45,61.099063873291016 +120.46,60.864803314208984 +120.47,60.6346435546875 +120.48,60.40855407714844 +120.49,60.16852569580078 +120.5,59.93267822265625 +120.51,59.70996856689453 +120.52,59.491275787353516 +120.53,59.27656555175781 +120.54,59.05681610107422 +120.55,58.85005569458008 +120.56,58.638187408447266 +120.57,58.43024826049805 +120.58,58.235191345214844 +120.59,58.04389953613281 +120.6,57.859962463378906 +120.61,57.679683685302734 +120.62,57.49760818481445 +120.63,57.3245849609375 +120.64,57.14969253540039 +120.65,56.9837760925293 +120.66,56.82133102416992 +120.67,56.65690994262695 +120.68,56.49415588378906 +120.69,56.32222366333008 +120.7,56.152034759521484 +120.71,55.96018600463867 +120.72,55.772071838378906 +120.73,55.585872650146484 +120.74,55.3997802734375 +120.75,55.20296859741211 +120.76,55.009952545166016 +120.77,54.8206901550293 +120.78,54.63514709472656 +120.79,54.437103271484375 +120.8,54.23751449584961 +120.81,54.04175567626953 +120.82,53.849796295166016 +120.83,53.65260696411133 +120.84,53.455604553222656 +120.85,53.244441986083984 +120.86,53.0372428894043 +120.87,52.821414947509766 +120.88,52.60603332519531 +120.89,52.3946647644043 +120.9,52.187278747558594 +120.91,51.98383712768555 +120.92,51.7843017578125 +120.93,51.59940719604492 +120.94,51.41822814941406 +120.95,51.24073028564453 +120.96,51.068660736083984 +120.97,50.90196228027344 +120.98,50.73879623413086 +120.99,50.58632278442383 +121,50.44264221191406 +121.01,50.309391021728516 +121.02,50.20091247558594 +121.03,50.10249328613281 +121.04,50.006874084472656 +121.05,49.91402053833008 +121.06,49.83651351928711 +121.07,49.77412414550781 +121.08,49.71413040161133 +121.09,49.656494140625 +121.1,49.60118103027344 +121.11,49.533809661865234 +121.12,49.42390823364258 +121.13,49.30606460571289 +121.14,49.191123962402344 +121.15,49.0754280090332 +121.16,48.89792251586914 +121.17,48.70416259765625 +121.18,48.48355484008789 +121.19,48.26154708862305 +121.2,48.03095626831055 +121.21,47.800811767578125 +121.22,47.55141067504883 +121.23,47.2955207824707 +121.24,47.04398727416992 +121.25,46.7932014465332 +121.26,46.54673385620117 +121.27,46.3045539855957 +121.28,46.066619873046875 +121.29,45.820343017578125 +121.3,45.59092712402344 +121.31,45.36565017700195 +121.32,45.144474029541016 +121.33,44.920162200927734 +121.34,44.699951171875 +121.35,44.47844696044922 +121.36,44.24846649169922 +121.37,44.01903533935547 +121.38,43.79377746582031 +121.39,43.53498077392578 +121.4,43.26990509033203 +121.41,43.0093879699707 +121.42,42.751609802246094 +121.43,42.49654769897461 +121.44,42.2459716796875 +121.45,41.99983596801758 +121.46,41.75811004638672 +121.47,41.535099029541016 +121.48,41.323463439941406 +121.49,41.119468688964844 +121.5,40.919464111328125 +121.51,40.72340774536133 +121.52,40.53845977783203 +121.53,40.3573112487793 +121.54,40.18349075317383 +121.55,40.01692199707031 +121.56,39.85574722290039 +121.57,39.70353317260742 +121.58,39.554771423339844 +121.59,39.41120910644531 +121.6,39.28177261352539 +121.61,39.155540466308594 +121.62,39.0450325012207 +121.63,38.939308166503906 +121.64,38.85270690917969 +121.65,38.76884078979492 +121.66,38.68767166137695 +121.67,38.609161376953125 +121.68,38.53327178955078 +121.69,38.459964752197266 +121.7,38.38920211791992 +121.71,38.28684616088867 +121.72,38.18552780151367 +121.73,38.056488037109375 +121.74,37.917972564697266 +121.75,37.7538948059082 +121.76,37.57533645629883 +121.77,37.37525177001953 +121.78,37.164634704589844 +121.79,36.954368591308594 +121.8,36.6923828125 +121.81,36.42230224609375 +121.82,36.10839080810547 +121.83,35.752891540527344 +121.84,35.40285873413086 +121.85,35.02241897583008 +121.86,34.62624740600586 +121.87,34.221675872802734 +121.88,33.81060028076172 +121.89,33.39311218261719 +121.9,32.98185729980469 +121.91,32.57679748535156 +121.92,32.17789077758789 +121.93,31.810209274291992 +121.94,31.453697204589844 +121.95,31.11004066467285 +121.96,30.773818969726562 +121.97,30.444917678833008 +121.98,30.125059127807617 +121.99,29.8123779296875 +122,29.52651596069336 +122.01,29.247501373291016 +122.02,28.98961639404297 +122.03,28.788537979125977 +122.04,28.595237731933594 +122.05,28.4150447845459 +122.06,28.25497055053711 +122.07,28.12373161315918 +122.08,27.997806549072266 +122.09,27.8770751953125 +122.1,27.775819778442383 +122.11,27.69919204711914 +122.12,27.63620376586914 +122.13,27.581344604492188 +122.14,27.55600357055664 +122.15,27.51498794555664 +122.16,27.494382858276367 +122.17,27.475988388061523 +122.18,27.45976448059082 +122.19,27.44745635986328 +122.2,27.4407901763916 +122.21,27.437904357910156 +122.22,27.435165405273438 +122.23,27.43433952331543 +122.24,27.435388565063477 +122.25,27.43827247619629 +122.26,27.442955017089844 +122.27,27.449398040771484 +122.28,27.436073303222656 +122.29,27.41388511657715 +122.3,27.375707626342773 +122.31,27.318174362182617 +122.32,27.252187728881836 +122.33,27.183237075805664 +122.34,27.09872817993164 +122.35,27.01142692565918 +122.36,26.917724609375 +122.37,26.806962966918945 +122.38,26.675683975219727 +122.39,26.531291961669922 +122.4,26.3864803314209 +122.41,26.239463806152344 +122.42,26.09562110900879 +122.43,25.9531307220459 +122.44,25.802993774414062 +122.45,25.645286560058594 +122.46,25.471153259277344 +122.47,25.250289916992188 +122.48,25.03335952758789 +122.49,24.81496810913086 +122.5,24.577220916748047 +122.51,24.343595504760742 +122.52,24.11406135559082 +122.53,23.877809524536133 +122.54,23.645689010620117 +122.55,23.428434371948242 +122.56,23.204357147216797 +122.57,22.984302520751953 +122.58,22.76287841796875 +122.59,22.54546356201172 +122.6,22.306957244873047 +122.61,22.06723403930664 +122.62,21.829938888549805 +122.63,21.59326934814453 +122.64,21.355436325073242 +122.65,21.120033264160156 +122.66,20.87448501586914 +122.67,20.631460189819336 +122.68,20.387367248535156 +122.69,20.138595581054688 +122.7,19.894189834594727 +122.71,19.654109954833984 +122.72,19.418319702148438 +122.73,19.186779022216797 +122.74,18.954092025756836 +122.75,18.722063064575195 +122.76,18.479921340942383 +122.77,18.240331649780273 +122.78,18.00506019592285 +122.79,17.77406883239746 +122.8,17.54374885559082 +122.81,17.317668914794922 +122.82,17.09579086303711 +122.83,16.878074645996094 +122.84,16.65376091003418 +122.85,16.38709259033203 +122.86,16.117916107177734 +122.87,15.846266746520996 +122.88,15.579323768615723 +122.89,15.309846878051758 +122.9,15.045071601867676 +122.91,14.784956932067871 +122.92,14.506186485290527 +122.93,14.221519470214844 +122.94,13.936384201049805 +122.95,13.641810417175293 +122.96,13.352249145507812 +122.97,13.067659378051758 +122.98,12.788000106811523 +122.99,12.513229370117188 +123,12.243304252624512 +123.01,11.97818374633789 +123.02,11.726811408996582 +123.03,11.48006534576416 +123.04,11.237902641296387 +123.05,10.991296768188477 +123.06,10.742137908935547 +123.07,10.501174926757812 +123.08,10.264755249023438 +123.09,10.036407470703125 +123.1,9.812480926513672 +123.11,9.592931747436523 +123.12,9.374144554138184 +123.13,9.159686088562012 +123.14,8.949512481689453 +123.15,8.748991966247559 +123.16,8.552614212036133 +123.17,8.360335350036621 +123.18,8.173898696899414 +123.19,7.982470512390137 +123.2,7.79508638381958 +123.21,7.611703395843506 +123.22,7.439477443695068 +123.23,7.26389217376709 +123.24,7.092179298400879 +123.25,6.917150497436523 +123.26,6.751340389251709 +123.27,6.589292049407959 +123.28,6.432748794555664 +123.29,6.279863357543945 +123.3,6.130593299865723 +123.31,5.983109951019287 +123.32,5.8409624099731445 +123.33,5.702302932739258 +123.34,5.574289798736572 +123.35,5.449606418609619 +123.36,5.328210830688477 +123.37,5.210062026977539 +123.38,5.093277931213379 +123.39,4.979676723480225 +123.4,4.869217395782471 +123.41,4.758286952972412 +123.42,4.639735698699951 +123.43,4.524351596832275 +123.44,4.422812461853027 +123.45,4.313531398773193 +123.46,4.207296848297119 +123.47,4.104069232940674 +123.48,4.014525890350342 +123.49,3.9295854568481445 +123.5,3.856389045715332 +123.51,3.7858192920684814 +123.52,3.7088520526885986 +123.53,3.6345269680023193 +123.54,3.571791172027588 +123.55,3.509742021560669 +123.56,3.451932430267334 +123.57,3.400092840194702 +123.58,3.347003221511841 +123.59,3.299808979034424 +123.6,3.254863977432251 +123.61,3.2121312618255615 +123.62,3.171574831008911 +123.63,3.1331584453582764 +123.64,3.096846580505371 +123.65,3.0626039505004883 +123.66,3.025036334991455 +123.67,2.989523410797119 +123.68,2.9560306072235107 +123.69,2.9173240661621094 +123.7,2.880643844604492 +123.71,2.84059739112854 +123.72,2.802565336227417 +123.73,2.766514539718628 +123.74,2.7288401126861572 +123.75,2.693118095397949 +123.76,2.659316301345825 +123.77,2.6274027824401855 +123.78,2.5973451137542725 +123.79,2.5691120624542236 +123.8,2.5498170852661133 +123.81,2.5357823371887207 +123.82,2.5233676433563232 +123.83,2.51438307762146 +123.84,2.506938934326172 +123.85,2.501004934310913 +123.86,2.496551513671875 +123.87,2.4935495853424072 +123.88,2.4865572452545166 +123.89,2.4792275428771973 +123.9,2.473337173461914 +123.91,2.4634997844696045 +123.92,2.451528310775757 +123.93,2.444577217102051 +123.94,2.437223196029663 +123.95,2.433030605316162 +123.96,2.43552565574646 +123.97,2.439268112182617 +123.98,2.4478044509887695 +123.99,2.459285020828247 +124,2.473719596862793 +124.01,2.494582176208496 +124.02,2.527150869369507 +124.03,2.5623586177825928 +124.04,2.5983757972717285 +124.05,2.633392572402954 +124.06,2.6620445251464844 +124.07,2.6915271282196045 +124.08,2.7218170166015625 +124.09,2.747532367706299 +124.1,2.757934331893921 +124.11,2.7692978382110596 +124.12,2.7780282497406006 +124.13,2.787713050842285 +124.14,2.7983310222625732 +124.15,2.8152194023132324 +124.16,2.836515188217163 +124.17,2.858588218688965 +124.18,2.8867766857147217 +124.19,2.9156458377838135 +124.2,2.9541611671447754 +124.21,2.993224620819092 +124.22,3.0328164100646973 +124.23,3.063932180404663 +124.24,3.084914207458496 +124.25,3.104785203933716 +124.26,3.1235456466674805 +124.27,3.142982006072998 +124.28,3.163076400756836 +124.29,3.1838109493255615 +124.3,3.20695424079895 +124.31,3.232470989227295 +124.32,3.2585391998291016 +124.33,3.30305814743042 +124.34,3.347909927368164 +124.35,3.407367467880249 +124.36,3.4526877403259277 +124.37,3.489306926727295 +124.38,3.535273551940918 +124.39,3.5725083351135254 +124.4,3.5993573665618896 +124.41,3.626634120941162 +124.42,3.6543240547180176 +124.43,3.6931307315826416 +124.44,3.721493721008301 +124.45,3.7609453201293945 +124.46,3.7881393432617188 +124.47,3.8156957626342773 +124.48,3.861517906188965 +124.49,3.9074904918670654 +124.5,3.953601121902466 +124.51,3.987278699874878 +124.52,4.021198749542236 +124.53,4.067907333374023 +124.54,4.116490840911865 +124.55,4.165132522583008 +124.56,4.213820934295654 +124.57,4.262545108795166 +124.58,4.311293601989746 +124.59,4.360056400299072 +124.6,4.408822059631348 +124.61,4.457580089569092 +124.62,4.506320953369141 +124.63,4.553248405456543 +124.64,4.600156784057617 +124.65,4.647037029266357 +124.66,4.697451591491699 +124.67,4.747782230377197 +124.68,4.794448375701904 +124.69,4.839262962341309 +124.7,4.882182598114014 +124.71,4.925058364868164 +124.72,4.962522983551025 +124.73,4.999982833862305 +124.74,5.037430763244629 +124.75,5.07485818862915 +124.76,5.112257957458496 +124.77,5.149622440338135 +124.78,5.186944484710693 +124.79,5.222431659698486 +124.8,5.257880687713623 +124.81,5.293285369873047 +124.82,5.330424785614014 +124.83,5.367488384246826 +124.84,5.4080424308776855 +124.85,5.4484710693359375 +124.86,5.488768577575684 +124.87,5.50749397277832 +124.88,5.547732830047607 +124.89,5.580678462982178 +124.9,5.620688438415527 +124.91,5.6533942222595215 +124.92,5.686009407043457 +124.93,5.716742992401123 +124.94,5.749180316925049 +124.95,5.781512260437012 +124.96,5.831650257110596 +124.97,5.863572120666504 +124.98,5.893589019775391 +124.99,5.923501014709473 +125,5.955090045928955 +125.01,5.979402542114258 +125.02,6.01079797744751 +125.03,6.042053699493408 +125.04,6.080311298370361 +125.05,6.118347644805908 +125.06,6.1561598777771 +125.07,6.19374418258667 +125.08,6.236510753631592 +125.09,6.278986930847168 +125.1,6.321169853210449 +125.11,6.363056182861328 +125.12,6.404643535614014 +125.13,6.445929050445557 +125.14,6.485123634338379 +125.15,6.52402925491333 +125.16,6.5626444816589355 +125.17,6.600966453552246 +125.18,6.638993740081787 +125.19,6.676723480224609 +125.2,6.7141547203063965 +125.21,6.751285076141357 +125.22,6.788112640380859 +125.23,6.824635982513428 +125.24,6.859013557434082 +125.25,6.893103122711182 +125.26,6.926903247833252 +125.27,6.960412979125977 +125.28,6.988272190093994 +125.29,7.015893936157227 +125.3,7.039705276489258 +125.31,7.063313961029053 +125.32,7.076002597808838 +125.33,7.099315643310547 +125.34,7.1224236488342285 +125.35,7.145326614379883 +125.36,7.157305717468262 +125.37,7.172761917114258 +125.38,7.195229530334473 +125.39,7.2103447914123535 +125.4,7.225326061248779 +125.41,7.240172386169434 +125.42,7.254883289337158 +125.43,7.265886306762695 +125.44,7.276790618896484 +125.45,7.291168689727783 +125.46,7.301837921142578 +125.47,7.303476333618164 +125.48,7.3140387535095215 +125.49,7.324501037597656 +125.5,7.349152565002441 +125.51,7.369983673095703 +125.52,7.390604019165039 +125.53,7.411013126373291 +125.54,7.431210517883301 +125.55,7.454769134521484 +125.56,7.478079319000244 +125.57,7.497569561004639 +125.58,7.509704113006592 +125.59,7.521701812744141 +125.6,7.533563613891602 +125.61,7.545289039611816 +125.62,7.567595481872559 +125.63,7.578938961029053 +125.64,7.5883612632751465 +125.65,7.597667217254639 +125.66,7.6068572998046875 +125.67,7.633793354034424 +125.68,7.660429954528809 +125.69,7.686767101287842 +125.7,7.689585208892822 +125.71,7.692345142364502 +125.72,7.7093377113342285 +125.73,7.735056400299072 +125.74,7.760478973388672 +125.75,7.789233207702637 +125.76,7.817654609680176 +125.77,7.8457441329956055 diff --git a/docs/conf.py b/docs/conf.py index e535082e7..4779e5d5f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ author = "RocketPy Team" # The full version, including alpha/beta/rc tags -release = "1.12.0" +release = "1.13.0" # -- General configuration --------------------------------------------------- @@ -67,6 +67,18 @@ "allow_errors": True, # Continue building even if cells raise errors } +# When DOCS_SKIP_EXECUTE=1, ``jupyter-execute`` directives are rendered as +# static (non-executing) code blocks instead of running live code. This makes +# structural doc builds fast and deterministic (no simulations, no network), +# which is what the GitHub Actions docs check relies on. Read the Docs leaves +# this unset, so it still executes the cells and renders their live outputs. +skip_jupyter_execute = os.environ.get("DOCS_SKIP_EXECUTE") == "1" + +# Some notebook cells use IPython shell escapes (e.g. ``!pip install rocketpy``) +# which the Pygments Python lexer cannot tokenize. These are purely cosmetic +# syntax-highlighting failures, so don't let them fail a warnings-as-errors build. +suppress_warnings = ["misc.highlighting_failure"] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -150,3 +162,46 @@ html_file_suffix = ".html" htmlhelp_basename = "rocketpy" + + +def setup(app): + """Sphinx entry point for optional build-time customizations.""" + if skip_jupyter_execute: + from docutils.parsers.rst import directives + from sphinx.directives.code import CodeBlock + + class StaticJupyterExecute(CodeBlock): + """Render ``jupyter-execute`` as a non-executing Python code block. + + Accepts (and ignores) the ``jupyter-execute`` directive options so + existing pages parse unchanged, but produces a plain highlighted + code block instead of a live-executed cell. + """ + + required_arguments = 0 + optional_arguments = 1 + option_spec = dict(CodeBlock.option_spec) + option_spec.update( + { + "hide-code": directives.flag, + "hide-output": directives.flag, + "code-below": directives.flag, + "stderr": directives.flag, + "raises": directives.unchanged, + } + ) + + def run(self): + for key in ( + "hide-code", + "hide-output", + "code-below", + "stderr", + "raises", + ): + self.options.pop(key, None) + if not self.arguments: + self.arguments = ["python3"] + return super().run() + + app.add_directive("jupyter-execute", StaticJupyterExecute, override=True) diff --git a/docs/development/first_pr.rst b/docs/development/first_pr.rst index 5f3eb876c..c0b07e7f1 100644 --- a/docs/development/first_pr.rst +++ b/docs/development/first_pr.rst @@ -96,14 +96,13 @@ The CHANGELOG file ------------------ We keep track of the changes in the ``CHANGELOG.md`` file. -When you open a PR, you should add a new entry to the "Unreleased" section of the file. -This entry should simply be the title of your PR. +When you open a PR, you should see the "Unreleased" section of the file. +An entry will simply contain the title of your PR if merged. .. note:: - In the future we would like to automate the CHANGELOG update, but for now \ - it is a manual process, unfortunately. - + The CHANGELOG is auto-updated once a PR is merged based on the associated labels, \ + which are assigned by the maintainers. The review process ------------------ diff --git a/docs/development/pro_tips.rst b/docs/development/pro_tips.rst index 465ff7982..9ff491c4b 100644 --- a/docs/development/pro_tips.rst +++ b/docs/development/pro_tips.rst @@ -53,13 +53,25 @@ productive. Code assistance --------------- -Artificial Intelligence (AI) assistance has becoming more and more common in +Artificial Intelligence (AI) assistance has become more and more common in software development. Some editors have AI assistance built-in. -Famous options are GitHub Copilot, JetBrains AI and TabNine. - -At this repo, the use of AI tools is welcome, we don't have any restrictions -against it. +Famous options are Google Antigravity, GitHub Copilot, Claude Code, +JetBrains AI, and TabNine. + +In this repository, the use of AI tools is welcome; we don't have any +restrictions against it. To help AI tools perform better and follow our +standards, we provide pre-configured instructions and skill files within +the repository: + +* **GitHub Copilot**: Uses ``.github/copilot-instructions.md`` (general + codebase rules). +* **Google Antigravity**: Uses the ``.agents/`` folder, containing general + workspace rules (``.agents/AGENTS.md``) and contextual workflows + (``.agents/skills/``) for simulation safety, test authoring, + documentation, and code review. +* **Claude / Claude Code**: Permissions configured via + ``.claude/settings.json``. A few possible applications of AI tools are: @@ -69,11 +81,9 @@ A few possible applications of AI tools are: .. tip:: - As of today, November 2024, GitHub Copilot still provides free access for \ - university email addresses. We can't guarantee this will still be the case \ - when you are reading this, so check the GitHub Copilot website for more \ - information. - + Using these pre-configured rules ensures that your AI assistant adheres + to RocketPy's style guides (snake_case, NumPy docstrings, 88-char line + limits) and testing conventions (AAA structure). If you are against the use of AI tools, do not worry, you can still contribute to the project without using them. diff --git a/docs/development/style_guide.rst b/docs/development/style_guide.rst index 510a49560..15a80e5e4 100644 --- a/docs/development/style_guide.rst +++ b/docs/development/style_guide.rst @@ -162,15 +162,16 @@ Standard acronyms to start the commit message with are:: Pull Requests ^^^^^^^^^^^^^ -When opening a Pull Request, the name of the PR should be clear and concise. -Similarly to the commit messages, the PR name should start with an acronym indicating the type of PR -and then a brief description of the changes. +When opening a Pull Request, the title should be clear and concise. +It should contain only a brief desctiption of the changes without the acronym (e.g. ENH:, BUG:). +The maintainers will label your PR accordingly, which will add a prefix via a workflow to indicate +type of the PR in the CHANGELOG file. Here is an example of a good PR name: -- ``BUG: fix the Frequency Response plot of the Flight class`` +- ``fix the Frequency Response plot of the Flight class`` -The PR description explain the changes and motivation behind them. There is a template \ +The PR description explains the changes and motivation behind them. There is a template \ available when opening a PR that can be used to guide you through the process of both \ describing the changes and making sure all the necessary steps were taken. Of course, \ you can always modify the template or add more information if you think it is necessary. diff --git a/docs/development/testing.rst b/docs/development/testing.rst index fd521167b..e84fde2a2 100644 --- a/docs/development/testing.rst +++ b/docs/development/testing.rst @@ -257,8 +257,7 @@ Consider the following integration test: """ # TODO:: this should be added to the set_atmospheric_model() method as a # "file" option, instead of receiving the URL as a string. - URL = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779" - # give it at least 5 times to try to download the file + URL = "https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05+00:00:00&id=83779&type=TEXT:LIST" example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=URL) assert example_plain_env.all_info() is None @@ -267,7 +266,7 @@ Consider the following integration test: abs(example_plain_env.barometric_height(example_plain_env.pressure(0)) - 722.0) < 1e-8 ) - assert abs(example_plain_env.wind_velocity_x(0) - -2.9005178894925043) < 1e-8 + assert abs(example_plain_env.wind_velocity_x(0) - -2.9130471244363165) < 1e-8 assert abs(example_plain_env.temperature(100) - 291.75) < 1e-8 This test contains two fundamental traits which defines it as an integration test: diff --git a/docs/examples/index.rst b/docs/examples/index.rst index e2919563f..6cc4356f4 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -32,6 +32,7 @@ apogee of some rockets. "Lince (2023)": (3284.12, 3587), "Defiance (2024)": (9238.01, 9308.32), "Hedy (2025)": (5406.92, 5231.53), + "Valkyrie (2025)": (2247.84, 2098.02), } max_apogee = 10000 @@ -104,5 +105,6 @@ In the next sections you will find the simulations of the rockets listed above. lince_flight_sim.ipynb defiance_flight_sim.ipynb hedy_flight_sim.ipynb + valkyrie_flight_sim.ipynb diff --git a/docs/examples/valkyrie_flight_sim.ipynb b/docs/examples/valkyrie_flight_sim.ipynb new file mode 100644 index 000000000..5bb47894e --- /dev/null +++ b/docs/examples/valkyrie_flight_sim.ipynb @@ -0,0 +1,565 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "94a916eb", + "metadata": {}, + "source": [ + "# Valkyrie - Bisky Team - 2025\n", + "\n", + "Valkyrie has been an essential rocket to validate key technologies in recovery and avionics. It marked a significant step forward towards more efficient, precise, and safe rockets.\n", + "\n", + "Permission to use flight data given by Iñigo Martínez, 2026\n", + "\n", + "* Launch date: `July 26th, 2025`\n", + "\n", + "* Simulated Apogee: 2247.84 m\n", + "\n", + "* Recorded Apogee: 2098.02 m\n", + "\n", + "* Relative Error: `6.66%` " + ] + }, + { + "cell_type": "code", + "execution_count": 71, + "id": "17ea162f", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "id": "a4df1b07", + "metadata": {}, + "source": [ + "## Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0ed041a", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from rocketpy import Environment, Flight, Function, Rocket, SolidMotor\n", + "from rocketpy.simulation.flight_data_importer import FlightDataImporter\n", + "\n", + "plt.style.use(\"seaborn-v0_8-colorblind\")\n", + "\n", + "with open(\"../../data/rockets/valkyrie/VLK.json\", \"r\", encoding=\"utf-8\") as f:\n", + " rocket_data = json.load(f)[\"VLK\"]\n", + "\n", + "with open(\"../../data/rockets/valkyrie/Dima.json\", \"r\", encoding=\"utf-8\") as f:\n", + " launch_data = json.load(f)" + ] + }, + { + "cell_type": "markdown", + "id": "a8893b9b", + "metadata": {}, + "source": [ + "## Environment" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "2be900a9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Gravity Details\n", + "\n", + "Acceleration of gravity at surface level: 9.8026 m/s²\n", + "Acceleration of gravity at 3.000 km (ASL): 9.7952 m/s²\n", + "\n", + "\n", + "Launch Site Details\n", + "\n", + "Launch Date: 2026-07-26 10:00:00 UTC | 2026-07-26 12:00:00 Europe/Madrid\n", + "Launch Site Latitude: 43.07777°\n", + "Launch Site Longitude: -2.68445°\n", + "Reference Datum: SIRGAS2000\n", + "Launch Site UTM coordinates: 37203.68 W 4785157.78 N\n", + "Launch Site UTM zone: 31T\n", + "Launch Site Surface Elevation: 605.0 m\n", + "\n", + "\n", + "Atmospheric Model Details\n", + "\n", + "Atmospheric Model Type: windy\n", + "windy Maximum Height: 3.000 km\n", + "\n", + "Surface Atmospheric Conditions\n", + "\n", + "Surface Wind Speed: 1.20 m/s\n", + "Surface Wind Direction: 129.36°\n", + "Surface Wind Heading: 309.36°\n", + "Surface Pressure: 946.36 hPa\n", + "Surface Temperature: 297.58 K\n", + "Surface Air Density: 1.108 kg/m³\n", + "Surface Speed of Sound: 345.82 m/s\n", + "\n", + "\n", + "Earth Model Details\n", + "\n", + "Earth Radius at Launch site: 6368.21 km\n", + "Semi-major Axis: 6378.14 km\n", + "Semi-minor Axis: 6356.75 km\n", + "Flattening: 0.0034\n", + "\n", + "\n", + "Atmospheric Model Plots\n", + "\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "env_cfg = launch_data[\"Environment\"]\n", + "\n", + "env = Environment(\n", + " date=[2026, 7, 26, 12],\n", + " latitude=env_cfg[\"latitude\"][\"value\"],\n", + " longitude=env_cfg[\"longitude\"][\"value\"],\n", + " elevation=env_cfg[\"elevation\"][\"value\"],\n", + " timezone=env_cfg[\"timezone\"],\n", + ")\n", + "\n", + "env.set_atmospheric_model(type=\"windy\", file=\"ICON\")\n", + "env.max_expected_height = env_cfg[\"max_expected_height\"][\"value\"]\n", + "env.info()" + ] + }, + { + "cell_type": "markdown", + "id": "29b84c37", + "metadata": {}, + "source": [ + "## Motor" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "95eb3add", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Nozzle Details\n", + "Nozzle Radius: 0.045 m\n", + "Nozzle Throat Radius: 0.023 m\n", + "\n", + "Grain Details\n", + "Number of Grains: 5\n", + "Grain Spacing: 0.001 m\n", + "Grain Density: 1170 kg/m3\n", + "Grain Outer Radius: 0.025 m\n", + "Grain Inner Radius: 0.01 m\n", + "Grain Height: 0.0966 m\n", + "Grain Volume: 0.000 m3\n", + "Grain Mass: 0.186 kg\n", + "\n", + "Motor Details\n", + "Total Burning Time: 3.5 s\n", + "Total Propellant Mass: 0.932 kg\n", + "Structural Mass Ratio: 0.409\n", + "Average Propellant Exhaust Velocity: 2142.979 m/s\n", + "Average Thrust: 570.679 N\n", + "Maximum Thrust: 1353.5 N at 0.011 s after ignition.\n", + "Total Impulse: 1997.377 Ns\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "c:\\Users\\emide\\anaconda3\\envs\\bisky_main\\Lib\\site-packages\\rocketpy\\motors\\motor.py:990: UserWarning: burn_time argument (0, 3.5) is out of thrust source time range. Using thrust_source boundary times instead: (0, 3.451) s.\n", + "If you want to change the burn out time of the curve please use the 'reshape_thrust_curve' argument.\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "motor_cfg = rocket_data[\"motor\"]\n", + "\n", + "motor = SolidMotor(\n", + " thrust_source=\"../../data/motors/cesaroni/Cesaroni_1997K650-21A.eng\",\n", + " burn_time=motor_cfg[\"motor_burn_time\"][\"value\"],\n", + " grain_number=int(motor_cfg[\"grain_number\"][\"value\"]),\n", + " grain_density=motor_cfg[\"grain_density\"][\"value\"],\n", + " grain_initial_inner_radius=motor_cfg[\"grain_initial_inner_radius\"][\"value\"],\n", + " grain_outer_radius=motor_cfg[\"grain_outer_radius\"][\"value\"],\n", + " grain_initial_height=motor_cfg[\"grain_initial_height\"][\"value\"],\n", + " grain_separation=motor_cfg[\"grain_separation\"][\"value\"],\n", + " grains_center_of_mass_position=motor_cfg[\"grains_center_of_mass_position\"][\"value\"],\n", + " nozzle_radius=motor_cfg[\"nozzle_radius\"][\"value\"],\n", + " throat_radius=motor_cfg[\"throat_radius\"][\"value\"],\n", + " dry_mass=motor_cfg[\"motor_dry_mass\"][\"value\"],\n", + " dry_inertia=(\n", + " motor_cfg[\"motor_inertia_11\"][\"value\"],\n", + " motor_cfg[\"motor_inertia_11\"][\"value\"],\n", + " motor_cfg[\"motor_inertia_33\"][\"value\"],\n", + " ),\n", + " center_of_dry_mass_position=motor_cfg[\"motor_dry_mass_position\"][\"value\"],\n", + " nozzle_position=motor_cfg[\"nozzle_position\"][\"value\"],\n", + " coordinate_system_orientation=motor_cfg[\"motor_coordinate_system_orientation\"],\n", + ")\n", + "\n", + "motor.info()" + ] + }, + { + "cell_type": "markdown", + "id": "2e07459a", + "metadata": {}, + "source": [ + "## Rocket and Aerodynamic surfaces" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "065222ad", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Inertia Details\n", + "\n", + "Rocket Mass: 5.650 kg (without motor)\n", + "Rocket Dry Mass: 6.295 kg (with unloaded motor)\n", + "Rocket Loaded Mass: 7.227 kg\n", + "Rocket Structural Mass Ratio: 0.871\n", + "Rocket Inertia (with unloaded motor) 11: 2.047 kg*m2\n", + "Rocket Inertia (with unloaded motor) 22: 2.047 kg*m2\n", + "Rocket Inertia (with unloaded motor) 33: 0.019 kg*m2\n", + "Rocket Inertia (with unloaded motor) 12: 0.000 kg*m2\n", + "Rocket Inertia (with unloaded motor) 13: 0.000 kg*m2\n", + "Rocket Inertia (with unloaded motor) 23: 0.000 kg*m2\n", + "\n", + "Geometrical Parameters\n", + "\n", + "Rocket Maximum Radius: 0.055 m\n", + "Rocket Frontal Area: 0.009503 m2\n", + "\n", + "Rocket Distances\n", + "Rocket Center of Dry Mass - Center of Mass without Motor: 0.061 m\n", + "Rocket Center of Dry Mass - Nozzle Exit: 0.783 m\n", + "Rocket Center of Dry Mass - Center of Propellant Mass: 0.537 m\n", + "Rocket Center of Mass - Rocket Loaded Center of Mass: 0.069 m\n", + "\n", + "\n", + "Aerodynamics Lift Coefficient Derivatives\n", + "\n", + "Nose Cone Lift Coefficient Derivative: 2.000/rad\n", + "Fins Lift Coefficient Derivative: 4.927/rad\n", + "Tail Lift Coefficient Derivative: -1.405/rad\n", + "\n", + "Center of Pressure\n", + "\n", + "Nose Cone Center of Pressure position: 1.773 m\n", + "Fins Center of Pressure position: 0.061 m\n", + "Tail Center of Pressure position: -0.068 m\n", + "\n", + "Stability\n", + "\n", + "Center of Mass position (time=0): 0.714 m\n", + "Center of Pressure position (time=0): 0.714 m\n", + "Initial Static Margin (mach=0, time=0): -0.001 c\n", + "Final Static Margin (mach=0, time=burn_out): 0.628 c\n", + "Rocket Center of Mass (time=0) - Center of Pressure (mach=0): 0.000 m\n", + "\n", + "\n", + "Parachute Details\n", + "\n", + "Parachute Drogue with a cd_s of 0.2589 m2\n", + "Ejection signal trigger: At Apogee\n", + "Ejection system refresh rate: 105.000 Hz\n", + "Time between ejection signal is triggered and the parachute is fully opened: 1.7 s\n", + "\n", + "\n", + "Parachute Details\n", + "\n", + "Parachute Main with a cd_s of 5.7865 m2\n", + "Ejection signal trigger: main_trigger\n", + "Ejection system refresh rate: 105.000 Hz\n", + "Time between ejection signal is triggered and the parachute is fully opened: 1.7 s\n", + "\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAw0AAADDCAYAAADA6/25AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAXdlJREFUeJztnQeUU1XXhg9NilIVsIAK2BU7oigWFBUFKwqi2Asq+okfKIigYgF7w4IFRCk27IIFxYYKYsWC2BBQKUqTIlL817P9T75MSDLJTJI5ybzPWlkzSW5u7jnn5t7dd4UFCxb844QQQgghhBAiARUTvSGEEEIIIYQQUhqEEEIIIYQQxSJPgxBCCCGEECIpUhqEEEIIIYQQSZHSIIQQQgghhEiKlAYhhBBCCCFEUqQ0CCGEEEIIIZIipUEIIYQQQgiRFCkNQgghhBBCiKRIaRAiRUaNGuXq1asXedSvX99tv/327qyzznI//PBD1r/3008/zcj+brvtNvfyyy+nvH30mHlsvvnm7rDDDnNjxozJyPEUIsuXL3eDBg1y7733Xon38dtvv9k+pk6dus57vM5aCCGEELlCSoMQaTJ48GD36quvumeffdadffbZ7pVXXnFHHHGEW7RoUV7M5e233+7Gjh2b1meOOuooGzNjRen4888/3TnnnOOefvrprB1nPrNixQp30003lUppmDNnju0jntLQtWtXWw8hhBAiV1TO2TcJUSDgXdhtt93s//3228+tWbPGLL9Y708++WRXiDRo0MC1aNHC/t9rr71cy5Yt3S677OIeeeQR17Fjx5wL5NWqVXMVKlRw5ZXNNtvMHkIIIUSukKdBiFLiFYj58+cXeX3cuHHu0EMPNeGOkJ5jjz3WTZ48eZ3PT58+3TwW2267rdt4441d8+bN3fnnn+9WrlyZ1Ap90EEHuT333DMSGrVkyRLXr18/t+uuu7qGDRu6HXfc0fXp08ctW7Ys8jlCWng+evToSLhRhw4d0h5z48aN3UYbbbTOmFM5Bn8cl112mSkdKCOMe++9914n5MmHZr355puue/fubuutt7b59HPzzDPP2Bw3atTIjun44493X3zxRZF9zJgxw0LIdthhB/se5vmYY45Zx4Kfyr4uvPBCe+/HH390J554ov2/0047uSuvvDJyTDNnzrTjBDwFfp75LPBZ/mftGAtzdNJJJ7mvv/468j14KA4++GD7n3H7faCcJgpPWrt2rbvrrrtMoWOc22yzjZ1Hv/zyS5HtWO9WrVq5Tz75xDxkHAPn8B133GH7EEIIIeIhT4MQpeTnn3+2v82aNYu8RtjOueeea4L9gw8+aALl3XffbWE+zz33nAnI8OWXX5rghgCIcN20aVM3d+5cUzj+/vtvV7Vq1XW+D+GyU6dObtNNN7UQlQ033NBi6BEGf/31V9ejRw8TRKdNm+YGDhzovvnmGwulwjLP9gjMeEh69uxp+6tZs2baY0Y5WLhwoQm+nlSPwcMY3333Xde7d2+3/vrru4cffthCnipXruyOPvroIt930UUXmUB/33332fdUqVLFwqSuv/5616VLFxsL88UcH3nkke7111932223nX2WucIbdPXVV5tC8Mcff5jytnjx4sj+U90XrFq1yrY75ZRTTPh///333S233OJq1aplihDK0lNPPeVOOOEE24ZQIkDJ8gof692/f397jXl8/PHHXdu2bd1bb71lCsfOO+9sYXAoDP/9739t7MCaJ4Lthg8fbnPI9rNmzXI33HCDKSDsl/PEM2/ePHfeeefZ8XPMeMkGDBhgykbnzp3TPh+EEEIUPlIahEgTBNDVq1e7v/76y02aNMndeuutZrlt166dvY+19qqrrjLLNsJjxYr/OvQQCvfYYw8TXskNACzUlSpVcuPHj48IlYDAGQ+Ev9NOO82Ukfvvv9/CdOCBBx5wX331lQm43vNxwAEHuE022cSdfvrptn++H6s+x8N3+XCjVPjnn39szPzFko7AW716dRM4Pakeg2fBggXujTfesNAnPz/M47XXXruO0sB+yMXwzJ4926ztCMje+g4HHnigjQsL/9ChQ+07vvvuOxOe8Qx4or0rqe7Lg0KBooPy5Y/ts88+My8J84Gih6fFC/mx88wYeUSfTwj5vIbnBeUFBYQwOGjSpEmxa4W3CoUBj8qNN94YeR2vFfOKssW5Fj33TzzxhJ2PfqwoFyi7UhqEEELEQ+FJQqQJAh6CLiFHCPe1a9d2I0eONAs5IKRS+QYh1SsMsMEGG5iwOmXKFLOW85g4caIJn9EKQyKwRmM1x3I9bNiwiMIAeBAQMhESEe79o02bNmbd53tKA14AxowVHQEWBeChhx6KCMclOYb9998/ojAAyhMhXITvxAupiYZwJfbNfER/F3OC8O2/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P+yyyd9//+1uueUWt/vuu7tatWq5jTfe2O2///7ukUcecatWrcrY9wwYMMDtsccerix59NFH3XvvvefeeecdN2vWLFe7du11thk+fLgpFM2bN1/nvaeeesre22qrrVx5QiVXyxlLly51hYLGEiZalzDRuoRJIa1LeeXHH380BaFOnTru2muvdTvttJNVgvruu+9M4N50001dhw4d4n4WYRzhszjiCbWZVhiOOOII98UXX7irr77atWrVyhSHSZMmudtuu83tuuuu9giJVOcu0Zqh4LFWyVh//fXdvHnz3AcffOD22WefyOusa3msfCZPQzmjRo0arlDQWMJE6xImWpcwKaR1CYk1K1e6pbNn299sc9FFF1k33Q8//NCdcMIJbvvttzfr9HHHHedeeOEF1759+8i2CLl4H3gPReCGG26wUpfnnHOO23rrrV3NmjXdjjvu6O66666k4UkHH3ywu+SSS1zv3r2tr0WjRo3Mgh8Nz5s2bWqCLwIu2yeC73v33Xfdq6++6i644AJTEPjsSSed5N5//307Nt8MDG/ENttsY8eKV2LMmDGR/bz99ts2xjfffNOaFqJ4tG7d2n377bcR6z2KFcoJ2/HgNVi8eLHr1q2bKVmEDbVt29aaH8Z6KIYNG2bfz7g4nng888wzFvrHNngD8NREzx3PGS/fz/NEsK6dO3c2JcEze/ZsGyevx3oNWVe8TCiQe++9t3vjjTeKbHPffffZ+eG9UZ06dYq8xzwy78xrw4YN3WGHHeaWLVvmQkKehnIGmnWhoLGEidYlTLQuYVJI6xIKcydNchN79HCrly1zlddf3+17++2uYZa6bv/xxx/u9ddfd9ddd50JqKmUkEX4ZXuEb5pp0VQLoX/UqFHWaAur9vnnn+822WQTU0IS8dhjj5kiMHHiRFNYzjrrLPMQHHLIISaA3nnnnW7kyJFuhx12sPApBPVE8N0Iz7vttts673nhHvr37++ee+45N3jwYBPGEbxPO+00V79+fQtl8vTr18/dfPPNNp4LL7zQlCJCgcgj+Oqrr9xrr73mXnnlFdsW5Qnh/6ijjjJlAUWL1x588EETnL/++utIsz8E86effto98cQTCRuRffzxx6bscKzMH/OJYsc+OFZCi6644go7Dv6nT0EyzjjjDNemTRtTNFDyCW3iuFDWYr2Ghx9+uLvmmmtctWrVbH2O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S7pjCfn3ku5YQv69pDuWkH8vQggRD/VpEEIIIYQQQiRFOQ1CCCGEEEKIpEhpEEIIIYQQQiRFSoMQQgghhBAiKVIahBBCCCGEEEmR0iCEEEIIIYRIipQGIYQQQgghRFKkNAghhBBCCCGSIqVBCCGEEEIIkRQpDUKIrDBz5kxXr149N3Xq1KTbdejQwfXp0ycnq3D99de7Sy65pFT7+Prrr92OO+7oli1blrHjEkIIIUJHHaGFKMdceOGFbvTo0fZ/5cqV3Wabbebat2/vevfu7dZff/1S7XvNmjXu999/dxtuuKHt+7333nNHHXWU++mnn1zt2rUj2y1cuNDer1mzpssm8+bNc3vuuacdx+abb16qfZ166qlu5513dj179szY8QkhhBAhI0+DEOWcgw8+2H3zzTfuk08+cX379nVDhw51/fv3L/V+K1Wq5Bo2bGgKQTLq1q2bdYUBRowY4Vq0aFFqhQG6dOnihg0bZoqREEIIUR6Q0iBEOadq1aom3Ddq1Mh17NjRHmPHjrX3Vq5caV6HbbbZxm2yySauXbt2plx4Fi1a5M4991y39dZbu0033dQs+SNHjlwnPIn/8TJAkyZN7HW8HPHCk9jn+eefb9vh+TjhhBPcDz/8EHl/1KhRbsstt3RvvPGGa9mypWvcuLEd85w5c5KO85lnnrHjj4bvvvzyy+37+b5tt93WPfLIIxZ6xPGhYOy+++7u9ddfL/K5Nm3auAULFriJEyeWYuaFEEKI/EFKgxCiCNWrV3erVq2y/6+66ir34osvunvuucdNmDDBNW3a1AR0QorghhtucN9++6178skn3YcffuhuueUWC0eKBeF/+PDh9v/kyZPNszFw4MC4M4+w/umnn5py8Oqrr7p//vnHderUKXJMsGLFCjd48GB3//33u5deesnNnj07qXcERYTv3HXXXdd5j/Asjnn8+PHunHPOsZCjM844w+211142ZhQElJjly5dHPrPeeutZXsMHH3ygs0cIIUS5QEqDECLCxx9/7J5++mm3//77m7WdEJxrrrnGtW3b1m233XbujjvuMKXiscces+0R1ps3b+522203s8ofeOCB7vDDD48bqkQYEtSvX988G7Vq1VpnOzwK48aNc3feeafbZ5993E477eQeeOAB99tvv7mXX345sh0KxG233Wbfu8suu5iw/8477yRcyVmzZpnysfHGG6/zHt+BotCsWTPXo0cPGx9KxGmnnWav9erVy7wKX331VZHP4VnBgyKEEEKUB5IHGwshCh6s+YT4rF692oTxI444wt14441uxowZ9pwQIE+VKlUsXGf69On2HIv86aef7r744gt30EEH2Wejt08X9ksOBGFOHkKZttpqq8h3Qo0aNSycyIMSMn/+/IT7xTMB1apVW+c9PAaxys32228fea1Bgwb2l6TuaNiX368QQghR6MjTIEQ5Z7/99nNvv/22hQ1h0X/00UfNG4BlHipUqFBke173r+GB+Pzzz123bt0sp+DYY491/fr1K/Gx+O+M93r0ccQmV/Neos+CD5kiTCmWePtCOYp+DmvXri2yHSFaG220UTEjEkIIIQoDKQ1ClHMorUquAt6GaGEZSz6x++QqePA8kG9AYrQHwZlqQkOGDLEcB5+7EIvfd7KKQyQi4/GYMmVK5DVCgwhbiv7OdGEsVGgi/yJTkCNBaJYQQghRHpDSIIRIqEwQfkQyNEnC06ZNs8ZohOR07drVtkFJoNLSjz/+aEI0oU6JhHuUEqz2bEOoz9KlS9fZhhwCQpz4HpSVL7/80p133nlWuYnXS3yhq1jRHXDAAUUUoNJALgNeGXI4hBBCiPKAlAYhREJQGChLSvUgchZQDkiUrlOnjr2PJ2LAgAGudevW1hSOnICHH3447r5IHKZ8K9vjUaDUaTyoikSVo86dO7vDDjvMwo6eeOKJIl6QkkBiM2VXY8OMSsKYMWNsPlCEhBBCiPKAOkILIcoFKB/kYKAAHX/88SXeD70rSNR+8MEH3d57753RYxRCCCFCRZ4GIUS5gNAoSsaSM1EaKN/63//+VwqDEEKIcoU8DUIIIYQQQoikyNMghBBCCCGESIqUBiGEEEIIIURSpDQIIYQQQgghkiKlQQghhBBCCJEUKQ1CCCGEEEKIpEhpEEIIIYQQQiRFSoMQQgghhBAiKVIahBBCCCGEEEmR0iCEEEIIIYRwyfg/C/OKVZNsD8UAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "rocket_cfg = rocket_data[\"rocket\"]\n", + "nose_cfg = rocket_data[\"nose\"]\n", + "fins_cfg = rocket_data[\"fins\"]\n", + "tail_cfg = rocket_data[\"tail\"]\n", + "rail_cfg = rocket_data[\"rail_buttons\"]\n", + "\n", + "vlk = Rocket(\n", + " radius=rocket_cfg[\"rocket_radius\"][\"value\"],\n", + " mass=rocket_cfg[\"rocket_mass\"][\"value\"],\n", + " inertia=(\n", + " rocket_cfg[\"rocket_inertia_11\"][\"value\"],\n", + " rocket_cfg[\"rocket_inertia_11\"][\"value\"],\n", + " rocket_cfg[\"rocket_inertia_33\"][\"value\"],\n", + " ),\n", + " center_of_mass_without_motor=rocket_cfg[\"rocket_center_of_mass_without_motor\"][\n", + " \"value\"\n", + " ],\n", + " power_off_drag=motor_cfg[\"power_off_drag\"][\"value\"],\n", + " power_on_drag=motor_cfg[\"power_on_drag\"][\"value\"],\n", + " coordinate_system_orientation=rocket_cfg[\"rocket_coordinate_system_orientation\"],\n", + ")\n", + "\n", + "vlk.set_rail_buttons(\n", + " rail_cfg[\"upper_button_position\"][\"value\"],\n", + " rail_cfg[\"lower_button_position\"][\"value\"],\n", + " rail_cfg[\"rail_angular_position\"][\"value\"],\n", + ")\n", + "\n", + "vlk.add_motor(motor, position=rocket_cfg[\"motor_position\"][\"value\"])\n", + "\n", + "vlk.add_nose(\n", + " length=nose_cfg[\"nose_length\"][\"value\"],\n", + " kind=nose_cfg[\"nose_kind\"],\n", + " position=nose_cfg[\"nose_position\"][\"value\"],\n", + ")\n", + "\n", + "vlk.add_trapezoidal_fins(\n", + " n=int(fins_cfg[\"fin_n\"][\"value\"]),\n", + " span=fins_cfg[\"fin_span\"][\"value\"],\n", + " root_chord=fins_cfg[\"fin_root_chord\"][\"value\"],\n", + " tip_chord=fins_cfg[\"fin_tip_chord\"][\"value\"],\n", + " sweep_length=fins_cfg[\"fin_sweep_length\"][\"value\"],\n", + " position=fins_cfg[\"fin_position\"][\"value\"],\n", + " cant_angle=fins_cfg[\"fin_cant_angle\"][\"value\"],\n", + ")\n", + "\n", + "vlk.add_tail(\n", + " top_radius=tail_cfg[\"top_radius\"][\"value\"],\n", + " bottom_radius=tail_cfg[\"bottom_radius\"][\"value\"],\n", + " length=tail_cfg[\"length\"][\"value\"],\n", + " position=tail_cfg[\"position\"][\"value\"],\n", + ")\n", + "\n", + "main_cfg = rocket_data[\"main_chute\"]\n", + "drogue_cfg = rocket_data[\"drogue_chute\"]\n", + "\n", + "\n", + "def main_trigger(p, h, y):\n", + " return y[5] < 0 and h < 500\n", + "\n", + "\n", + "vlk.add_parachute(\n", + " name=\"Drogue\",\n", + " cd_s=drogue_cfg[\"cd_s_drogue\"][\"value\"],\n", + " sampling_rate=drogue_cfg[\"drogue_sampling_rate\"][\"value\"],\n", + " lag=drogue_cfg[\"drogue_lag\"][\"value\"],\n", + " trigger=\"apogee\",\n", + " noise=tuple(drogue_cfg[\"drogue_noise\"]),\n", + ")\n", + "\n", + "vlk.add_parachute(\n", + " name=\"Main\",\n", + " cd_s=main_cfg[\"cd_s_main\"][\"value\"],\n", + " sampling_rate=main_cfg[\"main_sampling_rate\"][\"value\"],\n", + " lag=main_cfg[\"main_lag\"][\"value\"],\n", + " trigger=main_trigger,\n", + " noise=tuple(main_cfg[\"main_noise\"]),\n", + ")\n", + "\n", + "vlk.info()\n", + "vlk.draw()" + ] + }, + { + "cell_type": "markdown", + "id": "87c67151", + "metadata": {}, + "source": [ + "## Flight Simulation Data" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "6dd42298", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Apogee State\n", + "\n", + "Apogee Time: 20.884 s\n", + "Apogee Altitude: 2852.849 m (ASL) | 2247.849 m (AGL)\n", + "Apogee Freestream Speed: 6.868 m/s\n", + "Apogee X position: 148.646 m\n", + "Apogee Y position: 170.475 m\n", + "Apogee latitude: 43.0793048°\n", + "Apogee longitude: -2.6826150°\n" + ] + } + ], + "source": [ + "flight_cfg = launch_data[\"Flight\"]\n", + "launch_cfg = launch_data[\"launch\"]\n", + "\n", + "flight = Flight(\n", + " rocket=vlk,\n", + " environment=env,\n", + " rail_length=5,\n", + " inclination=87,\n", + " heading=30,\n", + " max_time=2000,\n", + ")\n", + "\n", + "flight.prints.apogee_conditions()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "bfd25766", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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ICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRAEQRAEQbAgIlkQBEEQBEEQLIhIFgRBEARBEAQLIpIFQRAEQRAEwYKIZEEQBEEQBEGwICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRAEQRAEQbAgIlkQBEEQBEEQLIhIFgRBEARBEAQLIpIFQRAEQRAEwYKIZEEQBEEQBEGwICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRAEQRAEQbAgIlkQBEEQBEEQLIhIFgRBEARBEAQLIpIFQRAEQRAEwYKIZEEQBEEQBEGwICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRAEQRAEQbAgIlkQBEEQBEEQLIhIFgRBEARBEAQLIpIFQRAEQRAEwYKIZEEQBEEQBEGwICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRAEQRAEQbAgIlkQBEEQBEEQLIhIFgRBEARBEAQLIpIFQRAEQRAEwYKIZEEQBEEQBEGwICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRAEQRAEQbAgIlkQBEEQBEEQLIhIFgRBEARBEAQLIpIFQRAEQRAEwYKIZEEQBEEQBEGwICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRAEQRAEQbAgIlkQBEEQBEEQLIhIFgRBEARBEAQLIpIFQRAEQRAEwYKIZEEQBEEQBEGwICJZEARBEARBECyISBYEQRAEQRAECyKSBUEQBEEQBMGCiGRBEARBEARBsCAiWRAEQRAEQRAsiEgWBEEQBEEQBAsikgVBEARBEATBgohkQRCEMMFut1NLS4t6FARBEAJLTIA/XxAEQfACCGEWxa2treqxubmZNm7cSJmZmdS7d2+Kiooim82mDjzHIQiCIPgHEcmCIAgGACHMYpgFMV6zWAYQwyUlJepvPXv2VO/rwpgFsy6aRTgLgiB4h4hkQRCEEAtiPnRBbLUS6/DfAJ+PR/5MEc6CIAi+IyJZEAQhgLDLhDNBzH/XBTHwxPrL5+r/05lw5u/SH8XiLAiCcCgikgVBEPyE7kMMlwhXFmJ+7W9cCWeUyXq+CGdBEIT2iEgWBEHwAuumOvYjZkGsC9Xo6GjH81DhrXC2WpvFx1kQhK6CiGRBEAQX6G4L+sEb60wTxP4Uzrp4FuEsCEJXQkSyIAiCC0Hc2NhIO3bsoD59+rSzrEZi6DV3hXN1dTVVVFQ46kQszoIgRBoikgVB6LLoglj3I8Zz/jvEIl4XFBRQv379KDY2lroazoRzbW0t7d27V8Vr1n2v9QmE7uds/X9BEATTEZEsCEKXEsR6Yg5ngrgzC7GIvEPrggUw1yE/ch3zuSKcBUEIN0QkC4IQ8YJYT87Bf/fGZULSQXdeF65cNVg08zkinAVBMBkRyYIgRKQg1sOugUj1IQ4l7tSjL8JZrpcgCKFERLIgCGEDC1/dh5gFMV7rgkwXxYJZuCucdZcOZ5E1BEEQAomIZEEQwkIQ6y4T7Dahx+6NiYkJmHASQRZ84ayvAnCoPf3vIpwFQQg0IpIFQTBSECPs2p49eygvL8+xOYyFUSAFsWAG+vV1JZwRaePAgQOUn58vFmdBEPyGiGRBEIJOR5vqdD9iPG7ZskWJ5K4Yds10QrGJsSPhXFVVpUL0oa3oyV2AM/9mmWAJguAOIpIFQQiqINZj6gIWNbqQ4f8DImjMxYRrowtfXnHQ2xa3PWf+6iKcBUHoDBHJgiD4DeumOleC2B2hZVLYNZPKInR8XVxtDOR2qp+n+ziLxVkQBCAiWRAEr3C2qY4Fsb7JCkd0dPQhokUQ/ImrtuVOum3r+ZJuWxC6NiKSBUHwWBBbYxHrAiQSBXEk/ZZIxFsLvzvCWRfPIpwFoWshIlkQBAeNLc305vYVtLJ0L+2traT65iZqsbdSfkIq9U3MoCOyetHYjO6OJelACmIRpkIo2osIZ0EQGBHJgtCF0Tc24aiqrqZfL/mIChsOOD0/Ky6Rtp56O0VH2YJaRsE8utJ18VQ4IyRdfHw8JSQktPNvlomfIIQXIpIFoQsKYjw2NTXRa9uX06s7ltMH0y4hG0XRtq1b6aKcIVQdb6O+yRmUGBOr/m93bSVtrC6h/MRUh0DG+3ev/oLO6DWCJmT19Ht5RVCYjynXKJTh6JwJ57Vr11LPnj2pR48eDnckq6uGuxtXBUEIHSKSBSGCBbF1Ux2HVStrqKUbln9Ecwo3q9ef799Kp/YapgbuK/JHUr9+/Vx+x1f7t9ITmxap47J+4+je0ccpS3MgfospmFQWwTycxWfmNtPa0kwNezZRQ8EKatq3kVrrKijn3AcpOj5RnVe75jNq2r+Z4nuPpsS+4yg6OTOEv0QQBCAiWRAiTBBbN9VZLVnrq4rp/IVv0K7aSoq3RdNdw2fS8fmHtfs8dzgsNYcu7DuG3ti5kl7ZsZw+K9xM/5x0Fs3o1j+Av1YQnGOiRbaluoQOLH2HalZ+RPWb55O9sbbd3zNPuYvs0W2Jcip/fI+q5/2z7Q9RNkroP5GSR59IGVMupri8QUb+PkGIdEQkC0IYocd4dSaIGV0U64Pr4tICOmf+61TZ1ED9kzPp1Snn0eiM7l6VBe4Yz008g67oP55uXPohbaouodO+e0WJ7l8Pm+6XQV2EgRCOFn4uT+k7v6XqBf9yvB8Vn0zxvUZTXK+RFJ2aS7bYBMfm14RBU6mlej817l5NzSU7qH7bYnWUvncPJQ6dRd1/+SbFpmS1i+MsCEJgEZEsCGEgiPXkHGw1dpZtrLOBc0HxTiWQa1qaaEp2H3rjyAsOcY/wZuCdktOHvjvmWvr1yk/pX9uX033r5tKu2gp6ZsLpFCkCSASJmdfFtGtUt/Fbisnu4yhP6tTLqKFgFaVMOIeSRp5AcT2GUZStTRRbSZtykTpAc1kB1a79nA4sfY/q1n9JzdXF1BqbTI2NjY7fqfs2i3AWhMAgIlkQDBTEbCHWBTHgwTAmJsZjUZAQHaM23c3qNoDemHoBJcXEdlgWT8FnPXn4aTQpqzf938o5dG7vUR5/hhB+mCBMTaCpaCuV/u8uqlnxAaUddTXZB12i6gbW4d6/W+Tx58Vk9Vafg6OpZAe1VO1X97xyrWqqp/L3/0jpx91MttRu6nwRzoIQGEQkC0KIBLHVhxivdbcJXwSxMw7P6klzZl5Jg1KzlWB2hq/fc2n/cXRqz2GUEZfg0+f4qzxC1yBUVm17SzNVfPkElX9wL9mb6ols0RSVkOLX9hub008d/HmVcx6myi8ep+qFr1HuJU9R8vgz2spysE/Rk/sAa0QNcdUQBPcJXrBTQeiisKtEQ0ODip964MABqqqqourqalq4cCGVlpYqoczJOWJjY9WB565cKFxR2lBLW6pLHa9HZuR1KJABvstXwaEL5E1VJXTRwreotrkpYpb1BTMJ9oSquXwP7X30BCp7+zdKICcOO5p6//5Hyjn3gUOEqj9JGn0SxfUaTa01pbT/uQup+LXryd5Y5xDD6Dd0IYz+Z9u2bbRu3TrVB9XX16tHhIDklSu5xwTBOSKSBSGAghhCmAUxXmOAwt9ZEOM8thb7KoitNLe20lWL36ZZXz9P3+zfRsGm1W6ny77/L320d4MqB8oT7oiYMJNgX5f6HT9SwZ8nU/3mBRSVkEq5lz9H+Td/rHyOA01CvwnU6zfzKeOkOzAzoOp5L9Lu+46kht1rHOdY4zKjz9H9mTlOOt5DH8SHCGdBaI+IZEHwYWDmwccdQcxWYqsgDtQA/8D6b+mbom3U1NpKedoScGf4w5LM2KKi6LHxp6gwc5/s20i3r/gkbEWmuH0IOrHZfSkqOk5ZdHv/7ntKO/LyQ5KKBLLNRMXEUfaZf6L8Wz6h6LTu1LRvPe25/yiqWfmx0/OdJTTRLc7sqmEVznguwlnoyohPsiC4gXVTnbPQazzgcEgndwbJQA2k35fsoofWz1PPnzz8VBqe3rbBJ9hMzulDLxxxNl266D/04ral1DspnW4bepTb/y/itM3qxy46OCBaysrKaP369ZSenq6OpKSkLl9XgW4ruvBF+LYet3yiIlnYnCTQCdZkMGnoLOr9hx9o/4tXUcO2JRTbfYjf021bz7dG0xAfZyGSEZEsCB4KYn1g8UQQd4S/B9S6lib6+Q/vUSvZ6YI+o+ncPu5HmvCnJZk5recwemDsiXTnijl075qvaXRGPh3XfZDb/x+u1mdvwG+tq6tzCGJemUA7S0tLUweeJyYmqnN37typzsHqBP6WkZHhEM7x8fEBL6spBLosrQ01tP/5Syll0vmUOul89V5c/hAjJngQ7Pk3vkdNhZtU0hFG76s8sWy7I5x18SzCWYhkRCQLXRprlAk+9M0s3liIQylKH14/j7bXlFPPxDR6eNzJZAK/GHQEra8sope2L6NrlrxD8465lvokZ1C44e9rheVsXRDjQPtLTU1VordXr17qEaKY293evXvV6+HDh6vXaKsQ0pWVlVRRUUGFhYVUU1NDCQkJSiyzcMbnQEz7k65g6W+pKaN9T5xBDduXKB/kpBHHu0wZHewJBGIv6/7Qteu/prL376Huv3yLYtK7++z+IcJZ6KqISBa6DJ4I4mCFSvL35yOaxGMbF6jnD449kdJiPbcmBsxHeuxJtKKikBKjYyiug4QKkSzCsBrBfut8wO8TrhIQsDk5OTRgwABKSUlR7c9dcC5bj/v0aUtkAZcMfD5Ec3l5Oe3YsUN9Fz5btzZ7+l0mE4i20lJbSfsem00NO5eRLSmT8m98x6VADmR53A1LV/L6jSp2856/zqD8X30YkHvaXeHszB9aXDWEcEFEshDRglhPzFFUVKQECZahrR13qPzq/G1JzklIVlZbZLyb3WNoyMujg9Bzb0+7iNJjEyjWTZFs2rK+u6DdwZqrC2K8jouLU+IUluKePXsGxLoLsEE0OztbHQw2krK1ed++fbRhwwZVtyiDbnHWrdZdmdb6A1T45OltAjklh3re/jnF9Wiz3pvcZqOiYyj/pg9o3xOntQnlB2YSzX6EonqMDfx3uyGc0RcDTBhxHtqdHsfZ+v+CEEpEJAsRKYjZQsx/R6eLWKGw1Jm2ycmfAyrSTP9lzPE+La8GcoDPiU9u97qqqcEra3co6Kg+UV8QoFY/YpzPfsRod3gMtJ9wZ8D9AkdeXp6j3BDuEM0Qz9u3b1flhmjXrc04IO5Nxt9ttrWxjgqfOYfqt35PtqQM6nHLx24LZCaUfUxst4HU89dzad+TZ1HDjh8o5n+/oJbZDxANC3yIOneFM9yG8B5WM/RzrRZnEc5CKBGRLIS1ILZuquvMQuyssw41gSqLt58brLpBcpHfr/6CPtm7kRYe98sOM/SZdK2c+RGz+wTaIAZ7COEePXqoR9MmY1ZYoOCA7zPA/YTfxMIZQgbhDGFdtvo3m2bh92ddV819juo2zKWo+BRllY3vPcaj/zehblQEjtvmqA2Htas+oegPb6cD3btRyuFnhbpo7dJo814P3eLM1mY+V4SzECpEJAvGwqJX9x9mQQyRrIteXRR3hImCxV/uDQj59sD67+i2odNoWm5bCltvCcYAbyc7fbl/K+2uq6I7V3xK/5h0ZkjL0xEsGiGEUY5ly5Yp316IRgjFrKws6tevnxKaPNiHM/gNEMI4rJMCiGZkh8SKDHye2Sq+e/duh39zyPxw/dxG0o+9iVobDlDikBmUMGCSV59hQn9ji0+m7r/8D21+/FyK3vAp1Sx/3wiRDKyrXa5cNVg46wLbmZ+zIPgTEcmCcYKYs9bxc3ab0Dd7YEnY0w4xkP62vuCPMv1t4wL6av9W6pWU5pNIDtYgkxwTR89NPINO+OYlenPXKjq151A6tWfwl4J12P3A6keMtgYRCOA2gQ128PntKsDVAr8Zh+5esnXrViWa9+zZo2I2A2sYOrh3BKtN+TXqjM1GWaf+zuv/D3QyEU99lJuOu4eieo2nbmf9H5mCO3VkFc56X2ndcA1EOAv+RkSyEHJBzBZi7vS44+MOzxtBHC4i2R+/a0t1KX26bxPhk24afKTPnxesOjoiuzfdPGQqPbpxAf1q2Uc0NacvZccntTsnUEIDvxEWYasfMeDwa7AQsx8xyvHNN98o4deVBLIzUBdsSUcdHn744Y6NirwxEAIaCVBQV7qbhsn1V7fxO6pa8DLlXvK00wQh4Yw9ykY07gKKiol1tP/GvesovueI0JXJi4mEM8uzCGchkIhIFgIKi16rD3FHgtiVy4Q/ymMavpbppW1L1ePx3Q+jw1J/imbgDcG2ft01fKYS+OuriuneNV/R44efGpBrxiHRdD9ivJecnKzEXvfu3Wnw4MHqtSkWwHAC9y0mFzh0/2YOQwfxDLcMJEqBr7YumvE/vrqq+NpGEAWi8O8XUGtNGcXm9KOs037vc3lMakd6/SBEXPGr19GBH/6j/K0Th0wPWZn8ZfywPtfdNHis0c+zbgwUVw2hI0QkC37FuqlOj5WpL43pnVSwMNWS7EuZ6lua6bWdK9TzqwZM8EuZgllH8dEx9Nj42XTC3JdUopGL+42jSdltIstb0N70NM44IM6w9M/uAIgn7Kk4M0n0hAOo28zMTHXo/s1sbS4uLqYtW7aoSTOuhS6cgzlZQTa9fU+fowRyfL8JlHHiHRSJOOoTq3gHSsneVE/7nj6bet72OcX3HRf08gSyn3Hl38xjlDPhbI3jLHRtRCQLfhfEGPyw9AohogtiEMpOx1SR7Atz9m2i8sY6lV3v+PxBIS+PN0zJ6UMX9x1LH+/dQPvrqz0qD64noi/oghgCmdM0s5UYIsz0MGaBpH5/JZUu3kpR0TbKPmIgxeek+vyZ3txLuAa5ubnq4M/ABIaFc0FBAa1du1b1F9b4zZjkBKLtlrx5KzXtW0/R6fnU/br/+sXVwkRLskM4xsRS3rWvqTjK9Zvm0d7HT6Wed3zlMs12IMsUDNwVzitXrqQhQ4Y4otOIcO7aiEgW3MLZprqOLMRYxoZINs330KRBS8cX4f7WrlXq8fw+oygafochLo+3/Hn0cfSn0cceEkfZitWPmKNOsB8xJmZ4DOaGMdOp3VNGO/41n1qbWtTritW7aeA1M/wilH2tY/w/xAiO/Px89R76GEx02E1j8+bN6jV8w63xmzkRi7dttnrxG1S94F/YqUd517xMMRltZYg0DnE3iEuk/Ovfpr2PnqiSpex77BTqcefXFJvdJ2RlCgXOhHNJSYlyvdL3zujnOXPTCPXvEAKHiGThEKyb6qyxiPUOg5errctWpllsTbYk+1KmY/IG0t66ajqvz2i/lScUWDfsAfZdR3a4nTt3KkGM5Xr2I+7WrRsNGjRIvY6U1Mr+RiVt+HCFQyCD1oYm2vPBMup/5XQjB3e2IuPQ2wKHoYN43rVrl4qwgWsPsYx2gf9De3G3LTTu30LFr92onmfO/o0K9xZJAlDHWR9jS0yj/Jvepz0PHUtNhRtV+u2e//et22m3I62O9HLpq5/8Hj9iPNTpyNps4m8TPEdEchfHV0EcLmLU1HL52pFeM3CiOvxJKOqIrYcQQR/u2UAbq4rp9Jju6m9wp0D4sd69eyuLcSDSOLuLae3HFTU7S5SrBYhNS2yLN1tdT7UFZXRgWxGlDmzLvmc6uOaISY1DX1VAe8FRXl6uRPOXX355iH9zR0ldWir3UVRcIsX3HU+Zp9xFkY6zOlAJR275mPb8dSY1FW9TGQaTR59EXV0kW8vlylWDx079fBHOkYGI5C6ENcoEH9Z4k77u+DVRjDKmlcu0ugrGoMV+qNbwa5iE7Ytrpf/bvZCiKYqumTmDqjftpP79+7fb/CW4T/nynY7n3Y8biQtMBf9b0va3pTvCRiQ7A+4XWE3AwSH9EMearc28+sD+6bpwxv8mDj6Ket+9GK2eomzRES0AOytPTGYv6n7DO9RSU0pJQ2cFtVwm1RGwxl3uDBHOXQMRyRFKsARxOAg/08vlTZkaWprpo70baHpuf8pNSA55edxN48wH2iGncUbIMDwi9i6u0XsLC+njvRvpTxu/o5uj+vq1LF0JXMcDW/ar57a4GEod1kPF0o5JiafmAw1UtXEfNdc0UExyvNefbxLox+B+gQOpwQHaGSZgLJwLCwup5sABSrCm2W5u9usKhWl146o88b3bu2vZmxspKiawG11Nm0h4KpJ9Ec7828XibD4ikiNIEOuJOVgQ898DJYjDSYya1iH7UqYfy/bQlYvfpm7xybR59m1+neD4AtoexyHmAxY+LHlDCMNtAtY+COSOfEfvHXUcfbZvs4rcMTM7lUKbh8/s9tMZ9fsqqKW2UT1PGZBLtui2+s4Y04dKFmwmarVT5do9lD1pQNjXSUf9DdoYW4+xqbO1oZb2/O0kijnicqpNna7cNHbs2KHaKNqkbm3urI2GU914Wp7GfRuo8OlzKPu8Byl59MkBK08kimRvhbOectsqnE2IDNWVEZEc5oJYT84RCkEcbiKZ68kkvKmruUXb1ONR3fr7/fq6Wx7OsmZN44ylbAhi+Ib27NlTPffESoeEKFcNOJz+sfUH+mfVJjqvdYoPv6brUr2lyPE8RXOryBjVu00kE1HlmgKfRHK4Ufb+H6lx22JqKSug/veuIltCW9hE+DOztRkbRTds2KDuA2sYOl7tcIVp/Z8ngrTqu39SU9EW2v/8ZdTz199QfK9RIS9TsNCTWwUSV8KZRTOfI8I5dIhIDkNBzJvqEE8U8Ubz8vKMyhpkskiOlDJ9W7RdPc7s1j8o5cH1hJCw+hHjfI5EAAsxp3H2Rya+N3euoi1N1fRJ0Ra6LNu3TIJdkbo9ZY7nyQPa4hKDhLx0iu+WRg1FVWoDX2NFDcVl+Ndlx8R7qW7zAqr86kn1PPfSZ8iWkOL4G0IG4kBfyu2d02zj2L59u2rvmOxZ02x3FH/bpP7GE0Gaffb91LB7NdVv/JYKnz6bev3ue4pOzgppmYJFKI0oroTzqlWrlCtR3759RTgHERHJhqDHZORYxCyI8Vq/gXhWyTe0ryldu4pIBqaVy5u6OtDcqNwtAHyS/Q3Ko/sRs/sE2iP7EcPnE48dRQ7wR0i4GwZPofvWzaVHt39PFw+d6Lc40F2Fur0V6tGWEEtxme1FcPrIXlT09Tr1vHLNbsqdFtxEEv7G1T3U2lhHxf+6VmWbS516GSWPOrHT89Gm0dZxYDUEoG/GvcDxm/fu3asiryRa/ZvT0ozrZzwpD5KNdP/FG7T7L0dSc8l2KnrhSup+w7sU5Wfrqml1FExLsrvo4z7Ha+ay6RZnPk930TDJcBbOiEg2QBDrLhMsfPVYi7BeOGvoEo84MsS7p2VaWLyTmu2t1Dcpg/qn+B71gQd/COH9+/crC9r8+fOVAIbLBMJu9evXTwmGYE7IrjtsMn26dRXdMHAq2dSWs9BjYvtxRlN1HTUfqFfPE/MzDuk/MiJMJLuiYs5D1FS0laIzeihfW29A24cQxsHwZBKiubS0lLZt26aSKaGdIAkKfPDZvznUYsWT74fluPsv36Q9f51BtWs+o/KP76OsU38XkHjEke6T7C90V0rAj3qfZN2YD0Q4+4aI5BAKYj1bHTfkjgRxOIk+KVdg6+q74jZXixleuFrwMrLVjxjZEXVXibFjx4Y8Y2JabDw9kDuRDsvpZ+SgFQ5WZBbJVmBZTuyVSXW7y6l+fxXVF1VRQrefkne4g2l9T0dtpHH/Ziqf87B6nnP+wxSddGh9eAtcLSCEcehuSd9++63qy/fs2UPr169Xf7OGoQtmVkhvXBvie4+h3EueoqKXrqHyj/5CCQOOoKQRx/m1TKZhYpkYfUVZR39PhLP/EZHsZzraVOdMEPOjt4gYDf/68ub6/1Da5moxJafzFLI8YOvRJvAccBpnWIhZHKMsWEIuKioKuUBmrP55oRTL4STUIXw7E8kgY2RvJZLZmpxw9PCwrZPO7uvaVZ8SNTdS4ojjKHn8mQEtB+oD7hdg4MCB6jlvcOWNgVu3blWJc3CPWf2bA3XfeXvvpE65hOq3Laam/ZsprvcYI8oUTGutSXhiee9MOLMLp7X+2UVD0m23R0SyHwWxHgNRX/LQG15XiNZgohgFJnZ8wNO6+vvEM2hp+R46Mqd9DGEs81r9iPEep3Hu3r07DR48WL3urC5Mu3b1Lc30yIZ59N7udfT10ddQrJ8TP0QijWVtkyEQn5vq9Jy0ET1p32eriOxEFWt2U7dZw4y9R3wh47ibKL7PWIrJ7BmU3+dsuRuTUhyICQ4wTuhptnfv3q0S7MDFSRfO+J9Q7znJOf8RoigbRUXHdBmRbCK+ls3VxkDWMiKc2yMi2U30QOBWQcx/1wUxCPTNZrIYlXIFrq7gh9wnMU1ZowpKChzCGIMslnB5WRe7oD31IzZxgED9PLN5MRU31NB/C9bQRX39a9GKRBpKDziex2U5j1wRm5JAyf1zqWZbMTWV11DdnnJK6uX/KAbBorO2mzhkOpkE7klkkdQzScK/mUVzcXExbdmyRa1EWtNsu5rk+ltgWZOKIHV1wsDJXn2W6f1NJItkfwjnqE4SoEQqIpKdoPsQwxKnZ6qzZqtjARKKRiJiNPwt7+7GXMUuet2PGAKZ0+2ylRiDaUfhqDzBtAlOQnQM3XDYZPrDmq/o0Q3z6YI+o8kWwZ2yP2g8KJJj0xLJFttxNw+XC4hkdrkIV5HsTEAc+OG/FD/gCIrN7hP0sngzJuDeRUhPHPw5mPiycC4oKFBhPyFOrPGbMTl2VSZfxyh8Rsmbt1LVN89StytfoNQpF/v8eaaJKxPL5Mon2d+4Es72g+JZP1/XQ5EmnLu8SLZuqrPGIl6xYoVaIuvWrVtIBbEzJLpF+AtAZ2VC9i9rGmecAxH8ZVMRNcXa6KxRo2hYdn7ALAumwOW5euBEenTjfNpUXaLScZ/WMzR5+ExsP1aaaxuopa5JPY/L/ikWsDPShuXT3o9tZG9ppcq1u6n78aMoymZWG/Cm7TYVb6P9L11NUVE26vX7Hykury1pSLj9Hrhf4MjPz1fvYZzCBJnjNyOCBl5jT4FubcahJ/DxR7tVIii1TcAX//tXagLiS72aKEhNLJMJ0UCiXAjnNWvWqDY4aFBbe+jI2mxq3XZGlxXJr732mupETjrppEOc2HULMV/kUPuFOUMsyeFfX2wtQmpcFsRYdmU/YkzO0PHgNdrizV8+RysrCmlUXm8aHtUjYGUyCZX5LDaerh04iR7aMI8eXj+PTu0xNCw73GBakUG8C5EcnRBHqYflUdWGfdR8oIFqthdTysBuFO6Uvv1btVkvYdgxFNttYMSEEWMrMo7evXur92DU0f2bd+3apTbsos9ga7PuGugLmSf/muo2fEP1m+ZR0T+RkW/uIe4Y4SxITQxLZ2p9RWl6ietN3yTIRkf9fJwD3WVqHTujy4rkBQsWKOF74okndhp020RhZbL7gMl1FupysRVItxDDjYInYBjMMPDBYuwsjXNTawutr2pbGh+T2WZZ8jcmdcLO4iY/vfl7WlGxj77av5WO7R5+1sGg+yO7EMkgfVRvJZJB2fIdbotkk+5xvSx1G7+jmmXvqs1miIkc7DYd7HpBX4FY5jj01Si2NnPsc2Rsw2Rctzh7mgwoyhZNeVe/RAX3TqKGncuo9N27KefcByJC9AETx9Ngu1t4g90ikjuyOEM0m2hw7IwuK5IRagcWO1cXTM9sZxqhFn3hWK5gW4itaZzR3tgSBCsxNuhgkDvssMNcfubWA2XU2NpCKTFx1MePsV6dld3Ea4YsfFf0H0/PbFmsfJODLZJNHaCsNJbXuty0p5M6JJ+ik+KopbaRqtfvpeaaBopJjg/LOrG3tlDJf+5Uz9OmX0PxPUeErCyhrBssfaN/wQHmzZunwj2i/4Fw3rlzp+qTeF+DLpxdpZWPyexF3S7/BxU+cw5VfvE4JQ49mpJHnRARItnEMoWDlbu1EwGvC2dTtVRndFmRjE0SsOqFq+AzudGZWmeBLBcsNxx2DYMQnuPacBpn+LXjEXFT9c6kvLwtTq07rKssUo9D03IDtnHNxAFCv2Y3Dp5KFU31dPOQI6mrwdZBLK+j7yopKXEaW7epShPJ6UkuP9cWbaPMsX2pZOFmsrfaqWLlLsqZ6nrSZhpouweWvEWNBSvIlphOWafdHeoiGVU32NyHTYFIKQ/QP6GfYjeNwsJCZXHGedY029aVreSxsyn96Ouo8utnqPjln1HiX9aRLcH1qoXpgtRkIWpifXlSb3rgg3Ciy4pkDCyc9zwcN8dx2dz5DcHGVJEM/FEu1LmeoAMHBAx8AOEqgYEIiQQgkF11HJ5MdNZW7lePI9LD32fUW3ompak40ZGOniocIobbGNoUt0FsloHvKd7jdMkQNU0VP4nkWDdEMsgc3yaSQdmyHZQ9ZZCxA3KH93VLE5W9f496nXHi7Y5NZiEpi2ETTmcCC30TW4/79OnTLtY6C2e4Z3C7063NeJ119n3UuHc9pR97o8cCmctkGiYLUZPdLVoNLpuvdGlLMjqEcBZ8ppZNd9436cbxpr44W5Y1jTOWJGFhgSju2bOnU2uLu7hbpnVVbZbk4Wl51FXalEntJ1BwZkQWw3jkEH9sycNKBPuqz58/XwmWcePGOf4PggYpkNetW0fdCmvbOvZYG5VVV1C6Ld1laMD47FRK6ptDtTtL1Ma/2l2llNy3LdVyuBDV2kwpk86nmqVvU/rR14esHCbdP4y7fTGMR9nZ2epg9Da2b98+2rBhQ9tmWoShO/ERisrMpKja2kNWyfxVpmBiYpnCxcptM7RsvtJlRTI6A3dFsokuDSaXLVxFMosVqx8x/o/9iAcMGOBI4+yvMrnLhoOb9oZ3MUuys2u2qaqEHtk4n3okpNIfRh0T0rJ4uxKhi2K8B+scRDE2b3LsW1ftA+fgyMvLc1ig1y/5CCUle2IMrV+/Xm0OxQYttjbjcLbKkTW+nxLJoGzpdpci2TgxGJdE2WfeS1mn/d7v2eG8waS+z5drZW1j+CxOs41j+/btqj3HN1ZQWmIspfUb7bA4dzY5M218MLVM4VC2VrEkR+7GvXB2tzDN6udMJJuEtYPB9bemcWaxAiEM3z08eroD3NMyuVtPi477JW2sKqZBqT9ZeUJZnlCyvaac3ti5ktJi4unWodMoNdY/kxZ/oyeCYVHMcW19yYzY4fchPnJL28Q5NS+TRk2f2i6TGyIdbNq0yWEJ1IVz2vAeFD0njlrqGqlq7R5qOn6UysrXGaYM2nqbDbVANtHdwp/lweegveLAKhqo2fAt7X/2V2RPzaOas1+gvXv3qnYP67LVv1lv56bVkclC1PSy2dywJJta/s4I/XTbcJ9kU621JgsaE0UyLGywEqPjhi8nxApeQwBjGRvhk7D7219ixRPcrSdkngtU6DdT6ahTPa77IDosNZs2V5fSqzuWq/BwoSqLDvt06qIY/QfaGAQC2hgeXWVI85amqjrHc2Tb6yiTGyekgHDmhBQoU1bPRLJtaVQb+Mp+3E55M0OTtMUTWg6UUvz7N1HUkb8gciNKTFck0AIrHnHLo2PIXrSRem5/j0af9WeHEQLtrLS0lLZt26buD14xQZ+M/tibNNtdUYiabK1t9aBspv6GjuiyIhkDR7hbkk0tW6hFMi8HWv2IOQMQlg1hJYZwsUYGCDamdRgmTryclQfRPa4/bDLdvOxjenbzYpVoJCbIPnH6sjOLAQz6HB0Afp1wz3FnA6e/aDpQ73gem9omkp1dY7R9HPB1Vv/X1KTKX55ZTPVbKgmtsnDhRtoWW04ZWZkOa7M7LiDBpnzOwxS9axHZ68vJPvO8kJfPREtyoMVfTFoedbv0GSp89jyq+OwRShp1IiUeNo1ycnLUwWVg/2YcABlt2Z1N3xgYqnZmskg22e/XbnDZfKXLimRPfJJNEw2mly2YIpk7Xj3aBJ4Dq/UOIbNw4LVJuFNPr+9YQYtLC+j0nsPpmO7BzSBmKhf0GUP3rPmadtZW0OeFm+nkHkMCfp1geUVsa25r3M4wsCMzItqZq01ygQRZ85iYlHiP+kMWNDs3VFL1hn0U3WSn7i0pVGe3O/xO8dtYzCAOeKhX2Zqr9lPVN39ve3HUTcYKnK5A8rjTKHXqZVS98BUqevFq6v37H8iWmOb4O64N3C9wdO/eXbWpadOmKYsyC+etW7eqVQ20R91Nw1mow64okk0tW6vBVm5f6bIi2ZPoFnpqRZMw1RUkkCJZX85mYYz3OI0zOt/Bgwc7XcIz0fLu7kTni8It9M7utTQoJTvgItmkOuqs402KiaXL+o2jxzctpOe3/uBXkaxnR2RLMdrZ7t271cCNJA1IAGPSUjFo1izJMS78iTsie9JAJZKBfUs5DZ8+o136Y0wUUCeYcKJvXLhwoUPM4DGQPvxWKj9/nOxNddSSN4KiB04nE+iKlmQm5/yHqW7jt9RcupNK/nMHdbv8uQ7Lw30yu7zxqgbalB6GDvccJmQ4jwUz2hn+x9+ucbjvTbSIcoxhk9qUjliSu7BINjUWcVewJKOztKZxRmcJSwQEcWZmpkebnkysL3c7vS0HStUj/HBNKE8w6eyaXTngcHpi00KVphoZCQem/JSa15tEHXoyGNz7HNUEky+EVxsxYoQapE3FW0uyTnK/HIrPTaWG4mqqLSijun0VlJifcUj6Y/gyo57y8/OVmNm1a5fy98d5etxmPHobHrEzWqqLqXJumxW5ccJVlGhg2zWFYAksWI67XfUC7X34OKpe8C9KHncGJY8+yWl5gLMyoS9H346D4c2n7N8MizPGZQhl3drs66TVtPHB2aTCRFrFkty1LckmWmtNFX3elk2PAsAHx4rVhQo6RW+Xs02sL3fKhL9vqW4TyYNSAx+71rQ66owBKVl0ef/x1Dc5gzLjnPvgdjT50kUxJ0zoLKqJiROIQFiS8TthTd778Qr1uuT7LdT7zAlOz8MSOEQyDoC+kq3NOAoKCpwmPMFrX+uz4ovHyd5YS/F9x9OBvuZkYDTR4hfMMsEXOeOE28jeWEeJQ6b7xdrubPMpDCZsbUY7W7t2rWNiq7tqeLJJ1lSLqImrE+FQb/6gy7pbQHyFu0+yie4D7tYbRIkuiHHgfPYjRgYojgLgz9BFJuLqGu6tq6baliaKibJRv+SMgJbFtDpypzxPHH6q24k62E1HT9SBMFacqCPcaSeSk70Pi5cxujft/3ottdQ1UeWa3ZR39HD3UlzbbA4xzDhLeMLZ3nTh7MnkFxEtKg/6Imee8hsqjYoyru12ZbLO/FOn18PXcQufjUksDn2CxpNfHBy1BeEWrf7NHd3rJk5wwkEkt4olOfKIhOgWJgt4vWzsy6gfqHtYkyBO4N+JTU9YKgvkbNTE+nKn09tc3ZbgoV9yJsXaAh+ezrQ68qQ81kQd7EvMYafg9+jt7nlTByhn7hbRCbFki/G+rdjiYihr4gAq/m4jUaudShdvpfzjR/klGQUGVL5GnMXN3YQnjvIlpFHOhX+j2lWfUNKYU8i+ciWZgolCK9hl0r/L3tpKzaU7KDZ3QLvyWM/zFd09Cgl59LGHhTNcgjBpw1ijC2eMQzzWm3btAK9mm1g2Ty3Jpv6Gjgh/04kPIhk3kKubQtwtPINn86hX+I1h8OOwWBzmBx1YKCx3Jopk4KpMwfJHDrcOTHfRKausoE8KN9GP1fvpxszhjgEQKxKB2OBjuiXZW1cLneyJA6hkwWayt7RS+dId1G36UCW+fYWtyDhwfQAmzbwhEAlPNm7cqN63JjzhTJdRMbGUNvVSdZjWdsOxjwkUzZWFtP8fl1DT/i3U+55lFJ2cFVTLqNWPXt9/wG0NyXXggoW2xmMEQjsGcwOqK8QnOXR0aZHsjiXZVGFlQtnYL8yaxhmCBH9DHbN/ZyjDYumYdi3duYZwtwimSDatjpxFNmHrECfqaEmKo7+WrqImeyv9evhsGpXVg7oaLY3N1NrU4tOmPR0I7Ywxfah82Q5qbWxWqapzjxzs+Ls/2wn6B6wo4eDPxmSbhTMvnesZ3FSWwLQ0I30hTRFXTKgspLakDLXBsqWqkEreup3yrnrRUR4QijJhomVta5hss6UZzxcsWNDOJYgndTxJCzamu1vYxSe562bcM93dIpibCq1+xBDE+H7e8ISlbDxiIEMnwwLZFEwUgO50er8feTTdMuRIamw1MxRhIGBrDtocluN58OJEHbAM9e/fv92S/FnVW+itXavohW0/0Pis0wNWrkjetGclZ+ogJZIBXC6yJw8iW/RPojSQ6do54QkvnWOSVFGynyqfO5uq+x9DW/OnUwu1LbHD4IH/waQ91AlPTG0jIRHJsQnU7Yp/0J4HZtGB7/9NKRPOoeTRJxsl+lAGuF/g4FBzI0eObOcSVFhYqPoj7n/0NNvBWBE12d3Cbnh4Ol/pspZkT5KJdMXoFuzbqYtiCBZ0JBi4sMt44MCBHfoNhqsgDQXu1FNqbHAsGKG6bnoKWz1RB8qDgQnL8q42d10zYIISyf8rWEP3jTmB0mMDk/o5LMK/+bBpTyc+O5VSh+aruMnN1fVUubqAMsf2pVD12fGb51DU3pWU1lBOIy7+I9U1NCkRs2XLFiorK6PvvvvOkfBEtwAG293GtL4mlCImYcARlH7cr6jy879R8Ws3UMIflhJFJxpdT85cgngli4Xzjh07HFFx9LYWiAybJltq7W6GpzNND7hLlxXJnoSAM/Xi+svKjUmAszTOWFrijRCwEnviR2xivUmZzMBZog624KCt6Rs5V69erbLAceinzpiU3YuGpeXS+qpi+t+uNXT1wEPDlkUyLbU/ieToJP+5N+VMOcyRXKRk0RblghGSlMGtLVTx+WPqefqxN5EtJo6ScSQnU1FRkYqri36KQ9CVl5errG7o5zmeLouZQPqbmmZVM8Fqm3Xa76l25UfUtH8zlf73Tko9/4mQl8nTa4dJGlLN42D0NNuwNsOXHv2bNQwdVld9jd9sWl2Fg5XbH4hIDnN3C0/LxiGxrG4T+CwWxAMGDFCPvvhfiSB1v546A/GRb13+MY3JyKc/jT6OgoG/27s7iTrw2FHaWXfLg7q8tN84+s2qz+mVHcv8LpJNHwRa6n7aYxGd6D+RnNwnmxJ7ZVHd7jJqKKqiA1v2U+ph3SnY1Kz4kJqKtpAtKZPSjrzCaRuxbtTi/o7jNu/cuVO1Qz3hCQuZSAgBaCq2uMQ2t4sHj6bqha9SzMjZFBVl3kqPpxn3rJFb2E2MhTOnc+eQk7pw9mSfjski2W54ohNf6bK9AgZkdzfuhbO7BS9j62mc4UrhKnFCMMoWCsKtTBuqimlu0XaqbPrJShhIfG0DrhJ1IKbp0KFDA2bJu7DvGPrD6i9pefk+WlVRSKMzgi/mQgViGjMxfrQkg9yph9Gu/yx2WJODLZJxj1R89qh6nj7r52RLSDnkHGftCe/BioeD4+mijaIv7CzhCQ5vs7eZ1seYYEkGCQOnqBWAunVfkS21G0WVtW1INgl/xG9GO8KB+Ot6e2PhzCEP9U2o7N/ckVuQqemyPbUk45xQt0NP6bIiGbM43BBowJ1ZEEy3JOsCnm9G3UqMzp+XsbFMZN3sFMiymVZv4VgmDv82KCXwkS0Yd+tIX5VgUWxN1MGTMG+tdJ52qNnxSTS751DaV1dNdS2u3ak8xbT2o9NcGxhLMkgdkk9xWcnUWFZDNduLqXZPWVDron7LAmrYvoSiYuIpbdYvffosCJFAJzwxSQiYIpJB1hn3UNSZf6Ka+kaK2rWITCMQFltn7Q0uQCya4Uu/bdu2dvHcrZkpxZIcOrq0SAawcnU2gJsorPTwaxDJGzZscPgRw0LOy9gQKPDF62gZO5CYWG+mlqkzfkpHnRXy8mASpodfc5aog6ObhHJAfn7SWRQXhKQrRrtb+NmSHGWLopyph9Hej9pSVRfP20Q0Pito15mtyKlTL6WYtLalbR1f7+uOEp5wCDq2/ulJKDpKeGKaoDGpz0O0C2C3t62M2ZsaKCpIm5LdratgbPLEmIy9Fjj4e3X/ZkzU1q9fr/7GmWfRJk2I3mJFfJIjXCS72rxngrsF30C6lRjPOfQKRH6/fv0cfsQm3EDhKEhDhYmWZJ6E6W4TPAkLdqIOT9tRVxTIgfRJZrBhr+jbDSrKRfXGfRQzIIkoOTh1nXHi7URR0ZR+3M0dnuPv7G3sQ8pwwhMcesIT3ddUtxaahkn9X2tzI2Wuf4t2zf0V9b57MdkSUskEQjXB0d2CsEeDy8Kua2hvcJNE9Ba9D+a2FwpDWDhE3vAHXdaSzI3KlUgOhbuFNWkC+xHDisEbnQYPHqzEyaJFi1QoNpM6QJNFcriV6SdLcmBFMqdvLS4uVhbjefPmORJ1oM2FahLmy3eVNtTSvOIddEav4dQV0N0t/O2TDJDmGtbkws9Wq9et60qIJh5q1Q0EiYOmqiOUdJbwBAcnPME9gj4cIcJMSHhikrsFY29uoJTtX1BzzX4qe+8PlHNB20pBqDFpFUCPFQ7rMVYypk6d2i4M3e7dux2RgfTJWjCzjLa2trpdZ6bUrSd0WZHMkRtCbUnmjU66IEajx4wSnSt2aUOgYFnP2ujhKmIq4ShIQ0FnnUZlUz0VNdSo5wP9aEnmHdi62wQHyufNSmPHjg2K73qgKGuso2Ef/43qW5tp1Uk3Ub/kTOoqluSoGBvZYgPTtWeN70fF8zZSS20jte6qJBpmRr2G4r7uKOEJlsoRt7mkpIS2bt3qSHms+zYHc8ncRJFMsUlUNvFGypv7O6r85llKmXS+iqccakwSyc7KBQ2AUIc49BUOdtMoLS1VbQ5GDw57yOLZ242orhBLcoTCgtNVhAt/WpI5/aUuiCGQ4S7hbjgsHW7wEPHBDpYfjoIUhFOZ9tcdoNz4ZMJVTvPBb89Zog58J8fy1EP+oX0uWbLEqEyJ3lyzrLhEmpLTh74p2kZv7lxF/zd8hs/lMHHw1IFwDZSrBWOLi1FZ94q+XkeEy7KhjCiA2qbym7+rsG/pR19HsbkDjL8+vCcEjxMmTHD0+WxthoCBqxzuNT2SRmeRDSIR1EtD/uGUMuViOrDodSp65ZfU+3ffU1RMXMjLZUI78qRcWOFAHHmOJa+7yuGAtXnt2rWOsJu6qwYma4EsWyTQZS3JuKhoXK5SU/si9qxpnFmc8BI2fDrZKd+bRsb/Y6LwM1Ekm1qmjhiclkNbT72dappdhyq0JobhDrKzRB0dWYlNqyNvuaDv6DaRvGsV/XrY9IjuyFWknrrAi2SQPXEAlSzYTK0NTUQ7q6ixspbi0pP8/j32lmaqmPMwNZfvprjeozsVySa1Wb0sespjDgnGrk2dJTzB4a8NsEZakg+WJ+fcB6l2zefUtHcdVXz2CGWecldIy2Sq4PMkBBzKj/4eB4c95AROPC7orkG6m4a7BrpwCU/nD7qsSHY3VrK77hbc8ekHPhtL1uj83BEnniIi2fP6MmkwdbdMyCrmaiLGnZ+niTqclcckfCnPqT2H0a3LPqZtB8poSdluOiK7bUncF0xrP4y9qYXsLa0B80fWiU6IpexJA5TbBazJJQs3U4+TxgQkeQgEsi0lh1ImnhdWbbezsjhLeMKWP054gkyT+gYtXxKemCiS2Y81OiWbcs57iIpeuILKPr6fksefRXH5Q0JWLlNFsq/l0scEdg1izcJjB8cL1yO4pKenK/3SmWYRn2Q/XiiTwO9AJ+Tuxj39t+tpdfnAkhoswux7Foyd/9xwTRy4TRWkJuJuPXFYKmuiDu7U/JWow7Tr5m15UmLilFCGJRkuF/4QyabSHODIFlayJw+k4oWbiFrsVL5sB3U7agjFpPg3g1rl10+rx7TpVztCh0Uizix/HG6RhbMvCU9Mu58ZLjf8kQ8sfoPqNn5LDQUrRSQHSXtZJ2t6dlSOqLFp0yaHT70unPUxpsv4JP/jH/+g448/Xm0ScwYq77nnnqObbrqJulrWPYA4mSyMIVQgfnlmBisxHj1JM+kPxJLseX2ZNmB01PGhnOfP/zfVNDbQ9TkjqVuTzZGogzssXxN1eFKecHa5gEh+u2AN/XXMCRQfHRORdRPo8G9WYpLiyTYok1o3lpG9uZVKvt9C3Y8d6bfPh1iq3zyfyBZN6TOudXm+Sfe1PwSNsw1anF6b4+h6mvDEpPar1xEecy95iuzNjRTbbWBIy+WJVTSYBMulAe4X1ggutbW1DuGMVQ5ereR2x0bESMUxYlx33XXqB//73/+mY4899pALgoq6+eab6Re/+EXQxWCgcGZJduZHDPbu3asEiSkJE4Bu2TYNkwWpSSsiXE/WRB04vi3aTnX2FroxZ2TQ250pdeRrO5rRrT/lJ6RScUMNrSjfR0fkRKY1ubX+p70V0fHB8aKzDc2m1s3lRK12KvthO+UeOdhvAr3y62fUY8r4sygms82XN1wIVL+HlUq4T3EcXXcTnkBwm3Avd9a/xGSZcV+a0u+ZFL85+aBPPYwy1hVNjqiBCdzcuXMPSbPtTwNOqIjRZxBHH300nXnmmXT33XfT9ddfr9wFdEHJojJSRDI6jwULFtDnn3+ulhYuueQSx/I1fjt2i8Kyvnz5chUSy8QLbqIYNbVcpohk3QcRcYnRwehB4tWu47xsqtv/NdkoimaNHO+TBdQTTBwgfCE6ykb/mnyOijOdE59MkUoLNtEdxJYQnMQCUYmxRP3SiLZVUmtjM5Uu3krdZg7z+XNbasrpwJK31PP0Y64Ly7YbjLK4m/CELX14Dss0zucQqKGisz64bstC5Y+ec879ISmXia4DoR6zOmt3hYWFKq32kCFDHO5BiBEOLcXZWFk0m5xspyNiuPIhfh966CG64IIL6KqrrqIffviBHnvsMYeTNyoG1i5XPrymgnKvWbNGhbfiA35eTzzxhBLAEydOpOHDhytxrIth/GZTrbWmilGTywWCXS59gwQ/ol1xkHhM1iZNmtQuUceC4p3qsU9yRtAEsqmdsq9MzulDkQDHt0bbwcQKB4dwUpEmDhIdHxu8NjI0i+zbq/BCieTsKYN8//7WZkqb+QtqLFhB8QMmu10WUwhlWZwlPCkqKqIVK1YoAQ2hjDaEFSndt9nV5qxg9S/NlYW099ETiZobVdzklPFnBK1MnZUr1JjqBqKHoM3OzlYHo6fZxoQNETVmzZpF4YZj9IUwRDias846i0aNGkXnnHOOcrt44YUXaNq0aWoAx03krg+vSeB3YakAHQjEMATJn/70J7rjjjvoz3/+M5188slh6fcbqoyA4SqSg3EtO0vUwclh+vfv70jUwZ2INV5lKNJRA9M6Yn+Xp76lmRJ8mHQEs01bd59zCEkO8/ftt9864u0mFzYH3ZKsvislnlJH96aKlbuopb6Jyn7YRrnTfItOEJ2aSznn/jWs264pZeHlcvQ1GNfZYMQWP6xkIfGJNeEJDn/E0PX0PopJ704Zx99CFZ88QCVv3EKJQ2dSdFLwrI+mimRTLdydCXi0Hxx5eXmO/szE1Xi3LclsKQaHHXYY/fjjj3TZZZfRKaecQg8//LCyMGO24MqSfP/999M777xDGzZsULNVpFF84IEHlCmewXfec889arMgBOwRRxxBTz/9NI0YMcJxDkz1t99+O73xxhtqafqYY46hZ555RvlmMvhfbCT84IMP1OvTTjuNnnzyyUNM+lhiWrlypQrBpje0P/7xj27FSeYym4iJYtTUcgWi8+MBRxfFnKgDh56ooyOc1dNP6ah/2nkcTEy6dv4oy9rK/XTb8k+oxW6nL2ZdRaaB36hbXnBgozAGGSxV5uTkqPTzEDwLFy5UfRpED4udqg07iaXxtl3bKTWxXp2DvtDTuKeeknvUYKpYtastHNyiLZQ9aaBKOtJVMU1oWcuD9oD2hIP/zrHVg5XwpLM6QqzkmqXvUNP+zVT2zu/Upr6ueu1ML5cnAt7U8ntkSdatxHgNgfrggw/SLbfcouI2Qri6EpWwbsCfGRZbnPvb3/5WRc3ATlx08ACf+eijj9LLL79MgwcPVtbc4447Ti0FsR80Ngl++OGH9OabbyoT/m233UazZ8+mpUuXOm7Uiy66SGWTmTNnjnp97bXX0qWXXqr+zwq+x9s4yYFOTR1pYtTUcvk64dETdbAo1hN1wIfd01jYHXUcbEn2ZzpqX8oT7mTHJdH3JQXUSnbaeqCMBqaEZvLB6MH9eeMVJlwc4gt7ITjRUEegj+YlzsJtdVRCler9rLwcqq6vp/Xr16uNXHrYMAhnX0MEWonPTqX0Eb2ocs1ulfWvbOkOypkyyKvPqvjqKYrLH0aJQ2dRlAeWM9P6GtPo7Hrjb2gjOPSEJzxZC0TCk85EH8L95V76DO19+Diq+u6fKkRc4uCjqCuLUVPLZboriF9FstWVAj8aluU777yTxo0bRzfeeKNbH8iClXnppZeUfxTE7fTp09XFhq8zxDNcO8C//vUvZZJHZI2f//zn6saEm8err76qXD7Aa6+9pvyjv/zySzrhhBPUAIDv+v7775UlGjz//PM0ZcoUJbZ1y3VHwP3CHR9rU10aTBWjkSKS9UQdHHkCn8Eh2NCu8eiLpa6jekqKjqOM2ISgu1swplw7f3W+3RNTaWbeAPp6/1Z6p2AN3TFselDLwisOuuuEvgGGo5d4a6nTN+51792T+ue3raahT4fIgRDnsGEcXkwPG+bN9+ptJPeoIUokAyQXyZrYn2wxnn1mc1URlf7vLqKWJup19xKK7z3ao/83ZaA25d7xpTz6BIw/AwYBntD5mvDEleiDKE476mqqmvcCFb96PfX+/Q8UFRvfZcWoyVnt7Aa7gvgDR2vGpjVr1Ap0nBDKsPJ+8cUX9Le//c1jPyXcUIADVmNGit2QsC4zWNaZMWOGWkaESIagxqCinwOf4pEjR6pzIJIXLVqkbkgWyGDy5MnqPZzjjkh2N06yiYKPMdXKbXKdOSsXh7XRRTFnH+LMdWhT/rbCdVSeF45om0AGuw5NHCD8VQfn9B6hRPLbBWu9EsnuYo0tigOv0XbQPwWiLbU2aCHgNJ9k9OkwQLBfINo52jYLZ+xCR1/L/qgsnj3t5xO6pVHasB5UtX4vNR+op/JlO1VWPk+oXviqEsjx/SZ6LJBN62tMuo/8Ifz0hCccCozDVrJw5oQnsDbrwtlZwhN3ypR19l+oZuXH1LR/E1X/8BalTb2MuqpINrVcnpSNV+XDjRieAcyfP9/pCRDKqARYceEi4Qn4v1tvvVVt/IPABRDIgDttBq8xO+Vz0LnrgdT5HP5/PPIOXh28x+e4wp2MeyYLUZOt3CaKZD2uNKwielxiPVEHjkAk6uisTN7+PVCYdu38wewew+hXUR/RuqoiWldZRMPTD+0/vEHPjsYH2hivOMAFx9cVB5dlqNdCwHUSJxn9BYsXq4UQwpn9UTn6AYtmCB9XbRHWZIhkULJgE2Ue3o9s0e5ZmOwQ79/9Uz1Pm3ENhTOm3TuBElidJTzRVy64zenCGbgqEzbs5V78BLXWV1HK5IupK4tRU8tlupXbH8R4Ki48qYwbbriBVq1a5VSAezOztJ7j7HxPGpO7ItlUIWqqGDVtYqEn6gDYlAqfO/jghTpBjGnXz7SO2J/lyYhLoGO7D6JP921SGfiGpx/t1ec422CHvgSDvzV6SbDwNgScMwsh7g1rrF2g+zVzjFT9+iTmZ1DqYXlUvXk/NVXVqYgXWeOdZ3C1Urf+K2ou2U62xHRKmXCu2+W3/hZTMKkswexfOkt4goMTnsAIhjqC9RltCveLszpLHnda0MrO5TXp2pleLtPL5g88MpN50unDhxlRJ5AkQY9IwTcPrL2cpx4gliNbl3EO+9Lps1Scg2gZfA46cCsIaWO1Uvvqk2yS4DNdZIW6XHqiDhbGLGJ4YMcmTuzs9tdObV+x1tPzW3+gpzd/T5f0HUu3DwvOhhWT8Wc7Orv3SCWS39m9ln43YpbLzl3fYAdxDMsYT7DQnvr06eNIzhDKgYItyVGx0RTlpvW2I7B6Yo1+gDpgFw1kH8U9hnsKoggWQ/TTmGTmTh+qRDIonr+JMsf2cWsDHluRU6dcTLb4JApnTLT6hao8ut993759HXs9EHYOmdogmhEJC1h9m60RgVpqK6mpcCMlDJjUpa4dl8uU8crbOjNRp7iD39eSUREQyO+++65KUwirig5eQ+DCxxkbAgEEMaJiIFQcOPzww1UHjHPOO+889R5uJiQDQWQMgA16GLiQFARxj8HixYvVeyykXSGW5MARrImFNVEHDrzHfnFWEYM2ib+Z0uE461w2VBXTtgNldKC5IWTlCdcOzRUn9xhCp/QYQqf1HKYiXURT+/rHpFn3s+QNdlhpwCPczjDpNy3eZ8tBn+RAJBJBm8A9gwP3E8CEYe3atUrw7Nq1S/XN6E8hluPzU6l1XzU1lddQxerdlDmm82QuzVX7qWblR+p52nTvXC0itb1GovBDX4xJJtrO+PHjHZMwtjY7S3iS0rCfqp8/j+ytLdTn3pUUnZzVpTahmXYNw6HO/EWHPT0aMDo9T388wr8hSsX777+vOlX2D4ZQ4eVshHe77777VDxmHHiO5T6EdONzr776ahX2DbtrsYSJmMmIC8rRLoYNG0Ynnngi/exnP6PnnnvOEQIOYeLc2bTnqU+yqZ2wqVbuQNzQ7iTqQOiszrJHmXgtreXZVFWiHpFKuavj73aUEhNHb0y9oMMNdjw4ow/CihRWHXjjESbkGNxNE8ig9aAlOTohOGXDPYc+G3UzdOhQh0sTrM2V/esodl+1Oq/gixW0P66WMrPafJudxQtvLiug2G6DlKtFXI/hXpfJFBFhmqAxrb+z1pE+CeMMvxiXeaKK1eHNpaXU0x5LcdWFtO2F6yn1vL8FJOGJadfO9HJ1aXcLJBBBJjpYez25QM8++6x6nDlz5iGh4K644gr1HGHlsFx33XXXOZKJfP75544YyQCRNDAYwZLMyUQQV1m3AL7++usqmQhHwUAykaeecj/wONwt3I1uYaIQNVX0+atc/kjU4QyT6stZPW2oLlaPQ9O6haQ8ptWRv8oCIQf/SF0Uc3pwiGK0Jzxao/yYjr3VTq2NbZZkW5BSUqvv1caFdpu4Bgygbbu/o9pdpWSraaHmHeW0ubREWQshrHXfZkw6EvpNoN73rKDWmjKKBEy6dxjTRIyrOoIBC3HncfD55fnPUvlTs8m25l3a1WsGrUjq50i246+EJ6aKUZM3x9kNdgUJqEhGEg+4Rngqkt3pIPBZyHaHoyPQ+JE9D0dHwHKI+MneEgnuFqaWzVOR3FGiDrbqeZOowx/lCjTW+6qssY721x9Qz4ektvmECt6B1TBdEEMgs296bWIMLbA30om9h9HYrLYNa+FKa6O2aS+IKalBR+NCtxlDacerC9Tz+B21dOQvjm63IRArjFhWx/+32xCY4F3qWtPEjUllMa1uvCkTzs0afSw1T7uCque/TN1XPkfj7lpAVTU/RWbhhCccVcabhCcm1pXJ5eKx253IPWEbAq6jP8CaAr+zSCYSQsCZJvrcLZc7iTrw3N9WPdNuUt1yi+cbq9qsyL2T0ik1CMHzOyuPCXgyuPEGOz7Qf/EGO/gR4xGTb3zmz5a8S2/tWkXl9qawF8nsj+wq/FswSe6fS4m9Mqludzk1FFVR9YZ9Ko6ybh1En1q29ls6kJhKlTU1KnsqrhnHbGbhzNcsXDDl3jFZYHlbpuyz72uLnbxvPR345inKPulOpwlPcOgJT/QQdJ0lPDGxrkwuV5d2t0A4IE+XscMNCDD4JIarEDW5bHq53EnUoft+BqtcJnaC2LQHhqTmhrw8puCsLHraXG5TgAUWIud0Fuf6rF4jlEh+d/daum/M8RQdZQu7enGWSMQWZ4ZIRlvudtRQ2vnGIvW6aN5GSh2a3/7+bqqn6ufPx8k07DcLKG7MGEecXVgGWeSgn9ZjNvMmSpOvi0miwbS68UX0YcNezjl/paKXrqbyj+6jlAnnUGzuALcSnuDQE57owpnHHlPFqMlC1G5onfmLDnvU//73vw4Tuqm+ML4SCe4Wplm5UU+wEsOqB2GMmMR4zqGAgpmoIxxEsrVzSYqJpfGZPWh8CK2bpnV4uoWIJ1poU+yPCMskNgB7Msk6pvtAlfa7sP4ALSzeRUd1cy+er4mwPzKIjguuu0VnpByWRwn5GVS/r0IdB7bsp9TD2kKAgprl76lEETE5/RxCxxpnFyIH1xuiGSHDkOwE77G4YeFsUrs1qX9hTKkbfwirlMkXUfWi1yg6PY+i4lN8SniC1QtO1Y42hbEU78NAGMjkP5EUQcLuRtlwjon3hTt0qFL8vWvURMTdwr+boVjAYOLBbhI9e/ZsF9nEBEy6Wa3uDef3Ga2OrlxHvPKAtgRhhEkXYvHyBjtEMHEWR9UT4mzRdGrPofTqjhUqZnJYi+SmFsfzqDhzNtC0WZOH0K7/LFavi77bQCmD8hxtvnrhK+oxdeqlHcZShnjB3hMceoQbtjZzuDB8JlJso610lAo5mJjS15ls6fO2TPi//Bvfoygv3dE6S3hSUlKiYjgjpCHaELv9dJbwpCtfQ9Ot3P7AjLW5EIFB1t3oFiYJq1BZudmip7tN6Ik69Ox1SPSCQ08YYwKmXkuTyhTsDg/3oHWDHVt2MNmCOEbIR3/voD6953Alkj/eu4EeGXcy2QxNEe6JJTmY7hbutFm4WMR3S1N+yfBPrtleTCkDulFTyXaq2zBXuVqkTrnE7e/ENYBYwcFJqtB+FixYoPohToWMtqILHBzB2oFv0r1sYnn8IfqsAhnxk6Ns3l1ffZVz/fr1NHnyZFU29EWcJdBZwhMcwYqEY7JIthts5fYHXVokY7kfvo3h7m4RqLJxog5dFFsTdaBjcbaxxlQxalq59HprbkV6CzvFetnZhwNsCdRFMSZesNqgTcEVR1952Lx5c8BCDM3MG0DpsfHK5WJJaQFNzuk86UV4uFsEt0t3NXDj77lHDaHdb/+gXhd9t1GJ5OqFr6rXiUOPptjstkxs3gKhgr4cq1ZwvWHLIGcIRLITCGn0W+yewRsCA4Vpgsa08vhL9DWV7qLS/9xBMdl9Kee8B30uk25Aw+ZxHNask+izeAUD/s+6cO4sRn+khoBr7SqWZPYZMfVCBAJJS/0T1sQKnKgDnQU6AU7UAQuOO4LFNDFqerlQpmVle+ikb1+mo3L70fvTL42IOtIzIlpjXXPCDjzvyP8vkNcLLhcn5g+hD/aso83VpREhkpGW2jTSh/ekornrqbH0ANXuLKEDO4qVTylIPfIyv30PD9R6KmSA9gM/VBbNCBW2atUq1bfpfs3+EjimWf1MK49fRXLhRqpZ/j6RLVq57cT3GuVTmYCzNqAnPHF898GEJzhUwpPNm5Vg5LbHwtkfkzETryHjrm4M2xBwXPnvvPMOLV++nH7+859TTk6OsuREOp6IZBOFlS8b9zj9rjPx4kuiDtPrzLSbVPdJRhKRZnury2V/U2ExoluJYX3hiRb6lYEDB/oc69qf3DvqWHps/CmUHBNeCUR0WhtbQmZJdocoW5s1ec97S9Xr/Z//SFS6S2XYSx57ml++o7O+BvcYxjMcHPWAo6PoAgefYd0QaNLmrUgSWP4aG5JGHEfJ48+immXvUMnrN1KPO77u0L/d3TK5W1fOEp6wvzwObDLFigZEsh5Jw5uEJyZewy5nScaGh0ceeYSefvppmjVrFl188cU0YcIENbBhUItEIiW6hauycaIO3W0CVmN/J+qwlstETBPvej2tryxSj0PTcsOijtCu9NjEGBhwP3Fs4r59+zpiE/tCIK9XfmKqMWXxiyXZoI17OhkjeylrclNFLdXtq6c+N/5A0S07yRbnP2OMJ30O3DMQX1ePsYu2zBsC4ZuKPhJtWY/ZjOV1V99jsqAxBX+uWuec/yDVrv2M6rd+T9WLXqW0Iy/3uky+bii0+svrk7GysrJ2CU904exqY7vJ7hb2CPdAiOELc9FFF6lj/vz59Jvf/IbOPfdctVnm2GOPVWmf8Rw+X5EUOxkiOdzTUjsT8LwRShfFwUjUYbIYNb1cbZbkEvV8WIhFsqs04frqg760jf7B17SwoaSyqZ7SY8Mvqk9rk7kb95ioaBvlThtMez9aoV6XrzlAfS+cTaagL6f37t3bYThyFipM92sOh/Zuomj3Z5liMntR1qm/o9L/3UWl//sNJY+ZTdEp2V6VCfizrpxNxvSEJ4jIwplAeTLG/akeItXEaxgOAt4fHNKjTps2jUaMGKF8BWFZvvPOO+lXv/qVSvZwwgknOARzJACRGO4b97gzR5D0UCfqCAcxCkwql35N1lXuV49DDBDJeppwPmBZ440qaFdDhgxxy7LmC8Fos7DgX7XkbWpoaaGlJ1zf4XcaO0iF0N3CkzrJGNtXbdxrrqqj6k2FVLevghLz22Icm3hPwyCEcRAH3xPoX9m3GQKHrYIsnHGY1L+YKrD8Xab0o2+gqoWvUtPedVT27u8p99KnvSoTCHR/5irhCZLoYEznhCc4cI6p2A1sX/6kwx4VHQKiF7z55puqEp599lm64YYb6Mwzz6S33347ImYPEMnhZEnmRB1WX2KUDzdRqBN1hININq1c3LkU19eoKAt4NSK9bVAOJtxRo03hOfYn6Bvs4I6Dx1D4aAb6evVMSlcb9xpbW2h9VTENT2/b0R4umL5xj7FF2yieFlEzjVWvi+dtpD7nHeG3zw/0QI3xjkWL1SoI4cw+qLhHYF1GVA3eEBhKEWFSfxcoYRUVE0u5Fz9Bex86lmrXf0Wt9QfIlpDicZnUZwX5WrlKeAIDGNrZihUr2k3GQtUfd1mfZB3McnBRwPfff69iUM6bN48mTpxIp59+uno/EioFDcwdS3KoRHJHiTo4XBaWt9EB47zhw4eTSZgmRk0uF8q0pqrNitw/JYtSgrCJzNkGO17yQ3mw+gALWrhPhN0hLTaejs4bSHP2baL396wLP5EcIncLT2mpKaOWTU8S5T5MFJ1JVev3Un1RFSV0S/P5s0NxTzuzCmI8gVsG+mvEiUeoMKD7NQdb3Jho6QtEmRIPm0Z5P/83JY08kWzxSV6VCZhQV9aEJ998840yVHBGQMQD59CZoU54Yo90n2TrGxBcqGTMWm6++WZ66623VKQDWJHPP/98VRmRYEX2xCc5GO4WnSXqYGseJ+rQfeAgdnhCYxKmWN+tmNABOiMlOo7O7j2Sunto/fDEdQKdK4tirEjwpiT4YOIRy8uoH+xLMCUCRbAmNaf3HKZE8od7NtBdw2dSOGF6dAvmwI9vU1RLLSVEL6V6OtZhTe599kSKFLCCxxvdR48erdouZ3PDsXfvXtVf60vpEM6BzkhqYr8XiDKlHH5WRE0mdLjNYEM0QB9uQsKT1q5iScYMeMmSJcq94rPPPnNULCzImMEwkSKQPfFJDoTg6yxRB4RwZ4k6TLeMAimXZ3U1PiOfpvUY6Je618P7oQPlDXY82XLHJcfENhVITuoxhGKibLSmcj9tPVBGA1Pa0iCHX3QLg0Xy4jfUY9akw2j/mjhqqW2kyrW7qdvMoRSf7VmUEWeYMlDrYos3THOfbl1Kh0sGUiDDGGJdSvfXOGui+Au0sEIGvqp5L1Ly2FMpJr2722UyVds4u4bWhCccbYiNIXrCEz2Shr8Tntg9sCSb1g49ipP8xRdf0CmnnELTp0+ne++9l8455xyHxRLWZVQCzjO1Efnik+yqE/HVkqwn6mDxoifqQOfoSaIOf5atq4lkYGK5vC0Tr0DorhNoWxzeDy4Tnm7cDMeOzFey4hJVEpdvirbRB7vX0S1Dp1G4wCIZ8Yjh92siSENdv2WhSkOdNvlcakk5QPu/XkdkhzV5E/U64/CIuaddlcW6lM77AeDXjGPbtm2OPSa6cPY2spRJdROsfqb4tRuoev5Lqs3lXf1SWNeTuwKeDSE4rJGunCU80YWzL2E6Ww2eXPg1BNz48eNp8eLFyu/YiukhbkxLJqJb8thajIbE6Zz79++vHv0RTs9UMSrlcp8Wsivr5cikJJeJRHQ/dT7wHrctuEbh0dclNlPaVDAF++m9hrWJ5D0bwlMkB9mK7EkbObD4LfWYOGQmxWT2pKxJTVSycDO11DdRxaoC6jZjKMVlRk48fk/arXXjFhtVeEMghA0shGwRZOHsif+paRPfQFu306ZfQ9ULXlarF2nTrqDEITPcKpOpYs/b+sI44EnCExbOnoQ2tBu4UuFPHL0qx+X78ccflcjDgeV/+L3gOR7xGhWan59Pp556KnWVZCKduVt0lqgDDQ3JWCBc0KEF4gYUMRr+9VXQUkdnznuReiSm0vqTb2nX4bDfGR+8e54tAPBP8/fymWkdXrCu1yk9htLi0t00u8eQsKgXq09yKDbtuVMnyi/3oKtFyuSL1GN0fCxlHzGQir7dgBOoeP4m6nnquICXJRzaK34HVn5wYHO2NQUy+5/iPOuGQGcuVCaKmEDf0wn9Dqe06T+jqm//QcX//hX1vnsJRbnYEG1iPfm7bK4SnpSWlirhjPc44UnGQeHckd98l/FJxka9qVOnqhkFKgiDLg7MJnDgOSyfmNEiwUhXE8l8U1sTdUC0AF7mgP92oBN1WMsmG+Q8qy/T2N58QD32S85sl8EOB/wXOYMdOjXOYBfo32HaRCIY5CWk0HMTz6Bwg6NbRBuabY9aWyhtxrVUs/RtShnXFh0JQCSXLNqiLOEVK3Yqa3Jsmv8y8IUSf9+f1hTI6PN5QyCszUh2gr5CFzYQzugrTLyXg2G1zTrjHjqw7F1q2reBKr58kjJPvM1lmUwcHwItRF0lPNm+ffshCU/09NruXEsT26DHIhnuFpidQgij0nSBjNc4uBGFOgZvsNwtdEd4NJKFCxe2S9Thjb9nV7CMAimXa3gGv62pbaKVdaCFli1b5thgB5/FjqxDgcSkgcKksoBQ3mu8VAr3GvRDONBfO9wtYs3sl6OiYyjjmOvVoROdGEdZEwdQyYJNZG+1U+n3W6j78aO8+g6TBE4wyqJnuuRoB2gPnOgECSlWr16txjiIG5yP99G3mOBSEIz7KDo5k3LOuZ+KXrqGyj/6C6VMPJdis/uERRtyVlfBum7uJjxpbGxUBhyAcIeYwAU6uVQocPSquJkg+JwBh++lS5fS0UcfTZGELpLRENEIeHkbzyGM0TAxG8cjMoyhkzEhgDcjYjQ86gvf6Sw2MdrWjpY2S/KMgcNp+tApRnQyJk28glkWfNfKikL6ZO9GumnI1KDErO4I3VrIO9bxHovluXPnUmJcAmUfrB57dFvbDlb78cd1yZ48UIlje0srlS3dQblHDVHiWfAc9CVwhcTBwgZtBsvn6GswhuM9dtVi3+ZgrXrqBKudpky+mKqwgW/zAir9z53U/ZdvhrxM4Ri/OdqJ3zxPylatWqVcgDZt2qTO00PQ6THBTaxbd2hneoCJfe3atcq8jgrgGMKLFi2iF198kf72t78psXjjjTca26DcBb915cqVSiQjQQpm3ePGjaP/+7//c4TJYj8cTBIQqoeXI0xColuYKbpY4OiiGG2NN9ghmglvsNv+zlz1PxPy+hpxT5lQhlBy2ff/pR015TQqI49O7TksaN+r+wbyRB2DDgsabPiF5QYrWng9atQoKttXTEXzvlf/X3mgir7++mvHYIYj1JbDht2rqWHHUkoed7qy7FmJTUmgzLF9qWzpdmURL/1hO3Wb7twnvDO66qSuM9B2srKyqKysTAnokSNHOjZtQdxwiDBOSMGiORgro8HSD/iOnIueoJLXrqfMk+80okyewu6UJpUtKipKaSNe5ZwwYYJqb2xtRv/FCU/YXRBjni9RNEIukjHTRMKQV155xbFrll0t0HhQIQ8//DANHTo0LEUyhO7nn3+uMgji4BSPAIPNL3/5S+WTjYsZLn6/4WBJNq2dBKq+2Fdd32DHs2ocSNgBgWzdMbyvrpqq7E1koyijMr2Z0qaC3XbwfSfnD6ZntiymT/ZuCqhIxqoVDyh45JUFCBW42mDlqrPlS/TNGWnpVHTwdW5+Hg06fMghocRYALEI8qf7jqvrg1i1Vd88S/Wb51O3K//p9JycIw+jsmXbVTi40sVbKGfKQLJ54TpiWj9joiXSumkL/RavVEDUIFsgT8z0DYH+jnAVzHEhvucI6nHnNy6/z7SxKlTuFt6WzWZJ2673cThMrFt3cPREmG1+9NFHykoxefLkdidhmWbWrFkqhzjj6oJ999139NBDD6n/hSn+3XffpTPO+GlTTEcV9uCDD9Idd9yhns+cOZO+/fbbdn9H1j8kPGEwGNx00030wQcfqNennXYaPfnkk+0uFEDA9ieeeEL9tttuu009QvhjMIL1GAIm3ISoyQKer69pHY8/riUvd+uimFOEWlchXP321RWF6nFAUgYlRpvhxmPS9QLBvvcQ5QIiGRn4WuytFB1l87le9LBeLIp5Uyb6Kl5Z8DQspL1Jz7b3k7iB1ZnbKYtmzvbG8Xf58EcoSqdla22hmqXvqufJE87p8DyEfksf0Ysq1+xWCUbKl++k7En+SawTCkwcKzpqu1jJsiakwASffZuxggohjfGRJ1m8IdAXgj0u6N/VUltB0UkZIS9TOLlbeGvlRt+CvVvwVzax/B6JZPwAdNoQj5yaGhcHM0gsA3JHiiVjd3xy0TmPGTOGrrzySjr77LMP+TuEs86nn35KV1999SHn/uxnP1PJTRgID52LLrpI7eydM2eOen3ttdfSpZdeSh9++GG78yDyEQdaB50A/6ZwdGkwWcDrIjnc64uzI7Ig5rjXvJMcnYC3vuqIaHFhSn/q370t1JMpmHbdgsnknN6UEZtApY21tKR0N03J6XizjzvuNiyMOUEExDCsxP7YlNmqieSomPbWPt1yiJUMwH6EODhGKqzVumj21+YbWI9bqgrJlpRJScM638+Sc+RgJZJBycItlHV4f4ryIDGKae3VJEHgifjTNwTy/+obAuGKCTdFXvFg4expGMpQCFJ7SzOVfXAPVX7zd+r120UUlzfokDKZbK01qU2FQ9n8haOHRkP/+c9/rp5bl1aGDRtGf/jDH9Rzd4XASSedpI6O4GxDzPvvv6+ELGIK66DDtp7LrF+/XoljuE8cccQR6r3nn3+epkyZovytMBB1Bv8WVyLZVGstEJHseX25u8GO/auwDM7ZEeGXPnDgQGU19keHOjgth65IP4yG9h1KpmBShxeKssTaoun4/MPoP7tWqw18ukjuSIzxRIqXFjkVOG9eCUQ8a1We5p9Ess0ikt3Z3MXxdyGCsNyOPSmcIpkPb8t94Mf/qcfkcae5jFGb2D2dUgbl0YEt+6mpslalq84Y3Scs261pYssXQcq+pzg40gH7zqPNcBY3fAdnj+XVDFdaIejXyxZNDbtWkL2+mkrevIXyb/qgXRlMHeNN9Em2hqZzp2wmlt8jkYyOEK4OsADjJuAD1g8cF1xwgToPSy8IFTd69Gi/FQLhQz7++GP617/+dcjfXn/9dXrttdeUtQ6iG2KdXSOwoRA3JgtkAEs43oPbiCuRzLt6w9mSbGrZwsWSzGH+dNcJDm3D4oZjEwerTCZgUnlCUZaT84e0ieR9G+lPo4875O/sy6n7E/NEKpihIdtZkmOjfY6/q6dIRmKBLVu2qPrX/ZrdsYDDandg2XvqeUonrhY6udMGK5EMkFwkfVTvsB1YIxlncXU5VCraDVaJ4VrErkTcdvQVilBYkvF9uRc+Rrv+OI7q1n5BtSs+VBO4cHC3cFeIdvUJYSCI0R2sYYGFGEBHyZuuWERiAIC7Am6Aq666SmXm8xcQxxC+Z511Vrv3L774YuVbB0syfIrvuusutdTzxRdfqL8XFhY6fKl08B7+5gpOksJRPMJJxJheNlNFMkQxOnAsNbO1WF9iRIYrT1Jy+kJNcyPNL95JzS0NRtWTiZ1xsDm2+yCKjbJRcX0N7a+rplR7tOoj4de7Y8cOhw86BABcGfAYip3brR5akr0J9QQBxC4acG1DPeAegREFf0f/aQ0jVrfxW2qtLiZbcjYlDp3l1ncn9cmmpN5ZVFtQRg3F1VS9qZDShrRZvF1h0v1jmtgKdHnw2Ri/cbBbj75hC22GNwSypRmaIhTXLLbbQMo4/haq+OQBKvnP7ZQ44jiyxSUaLfhMa09dKdteO5GM2eEJJ5ygOjtOIqInE+GNcGjk11/fPii8ryC8HASxdZCBPzKD8DWHHXaYCjWChAtIfgKcXSBPGhV+Lzr7cHa3MLFsJohk3iylW4nxmpPjuBNBIJCsLN9H5y74N+VGJ9CSoSPJJEwRHaG4Lup+qmugN0acSrmNUbR5aVuoSAygaCu8umBCvHR700/3vs0LS7InAqhPnz7tfFRhZYa1GWHnMGHQXTQadq8iirJRyvgzVDIRd78Lvsm73mwLaVeycLPbIpn/3wRMuXdCKbJ4wxYOvqd41YU3rcLgBR9n3UUjGBPNzJPupAOLXqfm0l1UMedhyjrtbqPFqMlC1O7BxMLU3+AKR+8FMXz//fd3eCI6QwALAjbj+Yt58+Yp/+G33nrL5bkQxhiY4AOF5xA5cNWwAj8pvjldgc9zZUk21aUhHCzJwYSXinVRzBvsIGqQMrykpERdz44S5wSTFRVtm1cPi0sz6hqa1pkFum6s2aTwHOQd9Cfu36fNnxiDOlapcnJyyBR0S7J1414g0H1U0c+iXhBOjJfaEQEJq35xcWMp6/IPKTo1meKrqtR57rSr1MHdKT4nlRpKqql2VynV7imjpJ5ZFG6YdA+ZIP7Q5+orFIh+hf1HeF/fRIp2Zd0Q6O+y2+KTKfvcB2j/Py6mis8eodSpl1BsTn8j6skZplq4TRfw/uKQKT6W1jiRCCyseERnCEvv22+/rV5PmjTJbxXzwgsv0OGHH64iYbgCm0pg0eFNJ3APgRBasmSJKhOASwjeQ8xjV+A3QCS7s3HPJBETDmXj9hFIK7ceZ5Yz2HF+eQTR5+QLegeD802xvCOzm4kiGZhWnkDEtOa2g8EZK0oQxPDNxYqV7k+sXM8MEz7OQsDZYkMzkFrDiHGmN4ifkvJy2nwwqpBuae4o9i7qOHvKINr74XL1umTRFupzTlvf3lXbqz8wse3CaowJp74hkCdbMH7BeAZ0v2Z/xflOPvwsShw6k+q3LaGGglXGi2QTy2W6gPcXMVar7u9+9zsVG5F9hng5Hz6+yEyH5/i7q4sGwYLlOAbLKkjgAfGCZTsAi81///tfeuSRRw75f8wssWnv5JNPVjcSfJoQ3xhZ8Y488khH1I0TTzxRuWU899xzjhBws2fPdrlpzxNLsqkuDV3Jyo3659jELG4gknmDHXzheINdZ23TpEkF3C3AYXGHJrAJJSZ1yL6Whd0D9E12cLnhmNbwQedlXmff9fjGBfTCth/p7hFHU/uAUV3TkuwOUc31qp/HwdeAY+/i4Ni7HA3Bmh45Y3RvKvp6HTXXNFDVuj3UWF6jYimHS7s1TdSYVp6OygTxi7GeV2q43bBw5jjfsC7rotmdePRON/Fd+gxFRcdSTFZvY+vJdGttq8FlC4hIRiY9ZNS74oor1KDBPskQI3gPSTrcbZDY2IeQbsytt96qHi+//HJ6+eWX1XMkBUHDvPDCCw/5f3SYX331FT3++ONKcEMEnXLKKSq6hW6BgJBGMpHjjz/ekUzkqaeecr8CYmLcim5h6k1kkujzZ9lwTayxifF57DoB6wOee2pVMKW+6lqaaGN1sXo+OD7DiDLpmFYed+GNZrr/I6cDx4CK8H2cDtwdShtqaUdNBX1WuJkGxbZZm8y1JAdXJDurC3tzI+38v8EU12sk5f3sVYpJ6+a4b3HAnxv/B7HDopnTI2PC60inPa4Plc3ffDAL31bKP7HzaEomXReTymLquOVOmfR2w4Y1nvDi2LlzJ61evVrdy2xt5pCL7lg3Y3MHGF9PJperS1qS0VkhMx6WqXUwyFxyySUqBJu7gwuy5bnqLGD1xeEMiGJrtj1nwFqBEHHegt/jjrsFz5qCEfUgHEWfL2XjQVP3JcagiQkZOjws41qXwH3BhPpaW1lELXY75cI/LjreiDIxpnXIndWN7ofO1mLgr2glJ+QPpsc2LaQvC7fQL3r1I6MtyUEWyc7aiopqUVNKTfs2UnRKTof/gw2QOHB9dNcpiGaInwN1ldTdRhTVSlS6dDslHt6L0nMyXa4SmYJJZTERb4UfjHfYi8S5E/SQhXoqds4qycLZVVZJtNuWTcspqtcMMg2ThWirm5Zkk8Y3r0QyN1h0Ws4iPeBviEWMZUp3RXI44IlPsqkXmoWoibPNjkQyOjHORsYH3oO1D8IGGzo8sfb5o0yhcrUYk5FvZAdoQh0Ba5vGvaoLYgyQuIcDkegFTMruRemx8VTWWEcbGsqpBzlPbGSEJdkAd4uag7GRk8edSlEeXANrNASMQ7saf6Ta1fuImltp3UeLqLZ3/CF+zXLvuMbEsQH4o0zOQhZCp3CGQGzyx8oStI3uooFVC/5+COS9j5xAFJdEtgveJNMw9fqZLuD9KpL5AnAECwgWDEaYJeBARSBxRyTibnQLk4SDTiiDs7srSGEl0gUxBDILG3RYgcpG1lmZQg0yuv095gzKQozOvdVGlIkxqR2hH0L7wSoXBj2sMGDAY5cb7EvwxifRk+x7x+QNond2r6Xva4voaBpGJtEaQncLK/bWFqpZ8aF6njzuDJ8+C25UPWeOpM0QyVgxLIui4acfThVVbRsCEasaQpr9mnH/uArl2VXvIRPHhkCVCZ+JSTIORF3Rs0riQJ4HJEPDeQ4XjazhFN//CGrYvpgSvn+aaFqb66YpmOz32+pB2Uz9DR65W6DhYnPd559/rlKUonFxwHhEuHjllVccOd0jBYg1d+IkAxM375km4HWfUEy2Vq1a1S6DnauNUoHGlBu1d1I6XdS3LaLLsn3LjLl+TCjKg+/UN2figEBmaxHcwPQNXsHiuO4skg8NNxlqTNq4V79lIbVUF5EtKZMSB0/3+fPis1IobVgPqlq/l5oPNJB9VxX1G9uP+vXr185iiAMgyyom27q1ORQJXkwTpab1LcGuI2tWSYzj+oZAJDux9zuPem9fQjGbPqOChe9RzrgTQjZGhZO11m5w2QIikv/zn//Q1VdfrQYi+IBiMMKMHo/wGTVppu4v8NvciW5hamcT6rKhTXCMWV7+BvAJQ9mw4QIWP3+E7YkkS7KOCR1xKMqjD1bcfvAe+xMj1COEEGJboz8KFRDJYFNDJRU11lJbwCozsDcHNpmIJ9Qsf189Jo85haJi/JNoJWfKICWSORxcxpg+jhS9usUQRp3Jkyc7Ep0gmhLiWmOVQRfNwUgVbiKm/eZQTiT0DKtYxQT19ZNp+/55FLfuPap5/ze0rjKO4hLa2g67aQRrtdP0SVe4WLn9RQz/UFz8Bx54gK677jp68MEHqavgrk+yqWHggimSOZyW7joBqzFm3BxjFgk72N+LLTumCGRTRPKOmnL6ZO9GmpDVS/m8glCXyUogyoMJldWfGFZiHoScxbVGewt13XRLSKHTew6jqAN11NT6k+XWNHeLYFuS9euiVgHYH3n8mX77jqTe2ZTYK4vqdpdRQ1EVHdhWRKkD85yWA37NaEu8qUuPu4tl9vXr17fzYVVRNNLS/C58TBM1ppXHxDKpaF6zbiX71q8ptnwbTYrbTvYxF6q2AzdUhKTFyii797CrRjBWtUwWovauZknGUifHIO4quGNJNkVcBVsks6VPF8UcTgudBZY98djRzmFT6yzUZZpXtIP+b+VnND23H30043Lj6slfHbKe7AWPPKFiIRPKlOCe8uqU85QrWm58CpmEvTm0yUQc166lmTJP/S3VrvqUEocf49fvgDW54L9L1PPSRVsOEcmHlKWDuLvoz5xFQuBJmj+TVZiEaYIUmNTfMfaEdGqeej3FfnM/VXz4J+oz+ULKGjiwnSsYT7o4bCFWJvQNgYFYqTDx+nkq4NnQGPYb95BVDwlFkAEPS52cUAQHOhN9R2hX8kk2OWmHP/2lOROZvsEOlhdemkJYPghkd8NpmSb+TCnTyoqfIltwmUzD0zpiH1FdFMMKjD6DN2e6E4pJCB9Lsg7cK9KmXakOf5M2tAfFZiZTU3kNHdhaRPX7Kykhz/O9MejD2QKIVQsWPiyaOVkFhw/jw9M2G+r+JRwwUfgprTP6bEoqXUkpky5QvvUMyoq+DAdvCMR4yaIZ7j5IeMYrYyycO8osGSnWWrvBZfMX7abMWCq/5ZZbaO7cucqijM6BhTI27t1///2OoN5dKbqFKeKqsxmaN6KGN0nxgQGCM5HBjxiPvkQOMLHOTCgTp6Mek5lvTJl03LnemJTBMqz7E3N8UrQbWInx6KtVzqS6aSE7/VC1j+rSEmhQajaZZkmOio7MwSrKFkU5kwfRvk9XqtclizZTrzMmOP7ubfvQhQ8MAIB9mnFgiR2GAqx26KLZndUPkwSgqYLUyDJFx1D+De94lY6dVyq4T+TMkugTdeHs6WZSk90tWg0uW0DiJH/wwQfKUojnsChzxj1cVHQasBR1RZ9kYKpPsrtCgjfY6Rns8HtY1AwePFg9R30Es1zBJtRlarG30uqDInnsQUsyMK2erOXRN2jiwHO2zPEGmFBtbAkWjxatpA+rdtJNTVPoz6OPN8qSjEQioRqsGvdvodo1cyh57GkUmx0YI0rm2D5U9M06aqlvosrVuynv6BEUm5bY7hx//H6MdVhFxaGHD2Nr4dq1a1UfqYtma7s3TQCaWB5gUpk6soq21lVRVHyKWzG/9ZUK/jyedKEN8WZSdjljFw1X/aZp188bS7Jp45vHIpl/JNJEdzUwGwxndwtnwo9jE7OY4Q12+K2cdAEJO6ybpAJdLhMIdWezpbqUaluaKDk6lgamZhlRJisoD+6JoqKidv7EWFlC+0HCB0yqulqkgAlJ3ZRI/rxwizki+WB0i1AmEjmw5E0q//DPVLf+a7etcJ5ii4uhrIkDqHjeRrK32ql0yVbqfuxICnb4MD3DGzZ0bdmyRfVxul+zaX2eqeUxre+witHqJW9R6Vt3UNZZf6K0Iy/3+PPwWViJxYGVWX3zMtoPVueR7ATfa90QqBurTHZpaO0qlmRn2dCwG5iXmrCcgE4iEivDE3cLUy3JANeqrKzMYSnWYxPDRQaPwY4ZaqJIBqEs04qD/sijMrpTdFR761Oo4LTg7DaBdoQOnDelYCmaY1sHG5P6nIlJuRRNUbShqph21lRQ3+Q2i5EJGfdCGf7NEfptvG8JRFyRNWkAlSzcTPaWVipbuoO6TR+qxHMw7x1nGd4wgWQXDcTchfUQrmxYeeVzQ52p1qT7yNQyWUVyS8U+Ffe79J3fUfK40yk6yff7He5nMFLh4O9ktzWOwoJ2w3s50HZg8DIVu8ECPmAi+b///S/9+c9/Vrs38eNxUadMmUL33nsvTZ06lSINdF7uuFuYZEnm1Lx6SmdcL176RsIOuE74umEgEkVyqMu0qrzN1WJ0RveQDRbsT6yHY0ObYt85dNBZWVlqc5MJmNKGUqPjaFxqHv1YXUifF26mnw2caEwyEbhbhGSjU/kuaty9msgWTcmjTwno98WmJFD6qF5UsWIXtdY3UcXqAso6/Kc2GqrkRFguxwFjBOpk8eLFaoKJfpnTIuO17qIRyCyRpi/Xm2pJ5lC4TPrR11HV/JeoqXAjlX/wJ8q54JGAth/2i+dVYBwFBQWqf8Z5y5YtcwhnE8b3LmVJ5pvo9ddfp9tvv53OPPNM9RwXBDObu+++m6655hr697//TWPHjjXupovkjXscNUAXxXjNqXkRSgtWi1GjRjl8oUwh1ILUxDLdMWw6ndZrWFs66iCViZeIdX9iwJMqa9SSNWvWGNEBm8j0jF5KJH+2zwyR7LAkh8rdYtNX6gEZ9qJTAr+ZMXvSQCWSQemSbZQ5vh+ZBO5ltjY7i4IA0YP7C8YZq19zoMZU0/pgU0WytZ6iYuKUMN732GyqnPt3Sp12JcX3CryLD9za4NKGA2CihXEf7QTtCCnZ2aihJzsJReQgu93u1lgR9iHgMBvAD4UV+dRTT6VnnnnGUQHYkPPhhx+qsHBLly5VIhmDbqTEknQ3BFyw3C1Y0OiiWN9ghwgkeK4v32FDgImEWpCaWKaMuAQ6IrvNYqDjzzJxKD+2EsMVh/3R4TaF7HWd+ROHuo50TOpYUZajMnrTowU/0rzi7VTf0kwJ0aHrB5Ult6WtT4oKQYxk0Lr5a/WYPPbUoHxfYn4GJfXOotqCtuQiNTtKKKH3T6G6TMB671ijIKCP1/1SN23apN7XRbM/Qofp5THpPjJZJFtdB5KGH0vJ486gmuXvUcmbt1CP2z4PSbnh6sYre+wexxsCOQoLZ5dkv+ZATrwYaJNI0YJuxUmGdRLLrIxewZgV48ZV/xRBlYIOjC1roXC3wNKKLmiwNAfhjrrmJW9XG+xMcgUxVWwxpnXMvtYT76DW4xPzSgM6SrjesD+xJ7/dpOtmUlkGJ2VS94QUKqw/QItKdtGsvAFGpKSOCoHlP6qugqhgqXqeNCawrhY6WZMGKpEMypZspR69Jxh3b3dWFohf9O081rL7E/s1c+gw3szFwscXv2aT6sZkkeysTNnnPaCit9Rvmkc1P/6PUiaeG1KXBjxH/44D/bvugon2s3//fuV+CfTQc4FIlGPvKj7JfAEuvPBCeu655+j999+nk046SVU6BteHH35YCTWY/wsLC9VAjN2asGiGO8G0JOP/9djEqEeIZN5gh2Vv3mDnSQdiohg1tVyhLBMiWzy/9Qcan9WDzu8zul2Z3IU3enD7wcFZENEJDhw4ULWhUG8U8hemDaQoz2PjZ6tU1eMOxrkOFfYWLdteTPAHKlvJRuWLHJc/kmKz+wbte9OH9aDC1ARqrq6nqo37KLfSrNCknvYvEBkYS3Fg5Va3FOrZ3TBOWP2a3S2PSfdRuIlktO2Mk+5QfskNO5cHXSS7c/2gY/TskvgfWJfZzYcT5fA4waLZV9/4Vjd8kjkhXbjSblqBSly9ejWdf/75KhkAZim4QbGcP2nSJHr00UeVlQozFWzkO+200yjcCWRaaogXa2xiwBnsEIsTHaM/Ei6YGHlDRHJ7lpbvoWe3LKapOX3aiWTQUdviaDNsKcbBIYMCtUnTxOtmEif3GEIm0N6SHHyR3NxrEkXf8j3lpQT3u/FbsWGvaO56IjtR+dIdxokuX8rizFLIm7kgeHbu3KnGafig6qK5o4y4IpLdo7N6yjj+VkoaeQIl9PspiU2w8MZai9/BEy9OAMcrjji4DUH/6NZmnO/Jd9m7iiWZwY0In2NsBuOKhv8iZincgHDjYtBmp/Jwx1/RLXj2r/sSY/aPmRrEDPzRXPmCRpqoMbVcoSrTxqoS9TgktW2276yeeNmMRTEmVrr7DeJbow1FesekY1IbCmVZOLEFVr7U8nxpm8sBiAqBJRnYElIprkfws7BmHd6vLWZyS2vbRr6R0RHdRqybufR4u7y8jn7E6tdsYj9h0v3srki2xSWGRCD7U4hihRrajvWdHvMbx7Zt2xxZU3Xf5vhONgSaNgELuLvFpZdeqo6uBKy43mTcYwufLorRcfEGO4iZYC17mypGTSxXKMu0qbpNJA9Oa0tMwLN7TK7QWZWUlKiJFUcugUvTsGHDghouytTrZhpz92+j/xasphPzB9OpPYcF5Dv0UFA4uG0A9DXr1qwljidxoLZGtZ9A+B06w976k6tHKIhBOLgRPaliVQG11jVRUtutZQyBvl+t8XYxNmE8YsGDCAhoI+hHuH/Buf7MqNpV3C2sNBVvo5pVn1DGMTcEpVyB2hznLOY3PAV4QyCHL0w6uL+FhbO+YmENmxeJHFLzqChUEtInwo8FPi7wc4SvFG4wiENUUKRUjLsZ99jHh8Ox4DkaLs+2QpmWVzbuhYlIPmhJzm4gld4WHRFcfdCOMMvv16+fzxt0Ig3TBlLmu+Id9OqOFVTX0uw3kawvh/IGTE4qgA283Dbmz5+vng8dPoC2/vi1I905QotBWGOizhvDrNm7/EXJv39FKevmUeuxtxH1uYxCATbwQSSDlP2u+/BgEQrrGsYAdsFCP6ILng0bNqiU2rAUYozSrc2hSBBkqvXRnXI1VxVRwR8PJ3tTHcX3GUeJhx0ZlHIFQ1fgt2OVEgeHL2zS0rIjHDDaEs7TE5244+oZ9iHg9Aby3Xff0Q033EDr1693/PgRI0bQb3/7W7rgggsiLn6qM59k1AWsNrofKAYwnIsQWvATQ2cUbAtfuFn+TCxXMK8XW3fQjsqwnFVdqt5PrW2mxJxEh086Bi90ghwiKtSY0KZ1TGlDer0cmzeQHt4wj77Zv41aMYh5WGcclYStNvomXgxACPWIPqZTgdv6U71k5+bQqJljHcIIWRPRh+M1L59CNPsj+5sdG5BXfUIxFXuJtKyRwSapZyYl9sykuj3lFFdrp7pdZZQ6wIx7KNToggdWZaSRRztgwYN9RjCEcdgwPoKRaj6cRXJMWjdKOeICqp7/EpX+5w7qedd8igqwgA1lfcVa0rLrYxraESzNEM4wqOq+zZ4GHwgLkYwfBL+m3/zmN2rTHsTyHXfcoUztM2fOpD/96U+qkz3++OMjysSORoCBBBE9FixYoDYocoNg1wn48GAWhQ7ElCxkOrJxz7O6CpToYj9B3Z8Yk0q0oZaUBGqkVoqOiqLjJ0yhWFu0sZMJ08pjIhOze1FqTByVNtbSivJ9KmJJZ7BlT7cUY3LOWQ7R56KdeLKsaj+YbU/fuGfd8AUhDsGMAQ3xeDlKAgtmPHqahKBh1zJqqdhL9thEiuo7mUIJkovsfvdH9bzsx21GiGQTRSDKY/VJRX+lWwkxqdKX4L3ZyBWu9QPc1TVZZ/yRan58mxp2LqPqRa9S2pGXB7xcptSXTVuxwOo5skvCTx59CCc64Q2Bul8z+pyIyLj3zTffqE76qaeeUj8QFYKO/ayzzlIJRd59910lksN5AEXZMYteuHChOvC7du/erQJyjxkzho499lgaP378ITuFi4qKjP3dpooaE8vlzzKxzygLY8yqMRChU8BABOEDwYLv/L6kLUtY76T0dgKZMa2eTMGUwcEKruH0bv3p470b6av9Ww4RybwapVuKeVMM2gdWEXxNGNF6MJFIZ9Et0B7h246D26y+UQfWRI6Pz8LZVWix2pUfqcemXkdQXExoXYPSRvSk6M9XU0tNA1VvKKTGylqKS2/z2xY6F6UY6/WwYRBjzjZysXXQn7F2Tbyv3e2DY9LyKHP2XVT6v7uo7N0/UMrhZ6kNrIEsl6lGydbWVsfkC32a1VhUWlpKW7ZsUZP20aPbR3QKF9q1dvwwdNo808SPxw/l5xzCLFwH9CeffJLuu+8+deEghI888kiVghtW83nz5oWl36+pYtTkcnlTJt0SyB0ArHS8PI5ZdWc7gZFlb8vs26issc74ejKtPKaW5Zi8gQdF8la6bcg0NUnSLcUcqg/tAjHQ/W2VaxcCzs3oFmifujURPofsnsFhodDXs5UZh9WtrGbFQZHcd1rIxY4t2kbpY3tT2YItuDhU9uN26n7MiJCWyTRLqbvlQdtkyx9WTPWJnh5rl913+PB0JcK0+vFGjKYffT1VffdPairaSuWfPEDZZ/05oOUysb46KpuzTaXu7PsKC5GMDhE/CDcCOkaYzPEcTv+wtJ5wwgnqPHcaEoTnQw89pFJZYykHVugzzjjD8fcrrriC/vWvf7X7nyOOOIK+//57x2tYPW6//XZ64403VDmOOeYYlTKbncoBbt6bbrqJPvjgA/UasZshhnGjWznqqKNU6m2k2GZryVtvvaU2woSrS4PJAt40seVJmTgLFgseCGO2BEL4eLo8ju9FAgoc3pZJMAe0j0lJbeG4FpcU0GfffkPJ0W2h+tD3YPOUq0yZvsIpqX3JuAd3Mz1lMgYzFkVYYcMGU4wDbGVOt1dT4541KolIU5+pZAJpY3tT6cItFIWYyct2ULcZQ8kWE1l7Z0IB+iW0YRyY5AH2o8fB6ZCxEqGLZl49CzfR50m5omLiKPucB6jwmXOo4ssnKO2oqyg2NzDZN02tL3ddVFD2cM7S3C4EHKJYYPcrhO20adNUhw8XDCQXQeO/+uqr1XnudPyYgcJ94corr6Szzz7b6TknnngivfTSS47X1g0lN998s3KHePPNN9Ws5LbbbqPZs2er8vEy5UUXXaQ68zlz5qjX1157rQpjh/+zghjQzgYJd+MkmyqSTRVZJparo84G4gArJfpmTbassP9VIKOXmFZPppTHlMEBEyT4EMPiipUoXnXrFZtMcdGxlDNkAE3I7xfU8uqWZH9l3MNgpm/Uwe9WG0/LylS21X0/vEJYnG/uPppq7bFKNIV6EEc4uLqsaEoqbaGW2kaqXLObMscGLwOglVDXRyDLg1UGLKvz0roe/QDGNEyqMKbqotnab5pWP96WC6nYk8acSvF9xlB0WtvKTKT7JIdT2fxFO3k/ceJE+vnPf+740RC5sO7CJeGSSy7xKFwM0lrjcGfpzxkYhF544QV69dVXlZ8weO2119SM9ssvv1RWbWw0gDiG9RnlBM8//zxNmTJFbUKEtc8VuKEDlXEvWJhq5TaxXHwdcc2t/sRoj4FK/PLkpoW0r66aLuw7hkZldDe6bUV6p+cOPGlin2I852RK8O9FtAA8X9w8iVJjPVtuDowlOTCTN0wE9KXTmtQaKosqJcobre4h7O/YtWuXQxDB4hyKUJgH8mKUSAalS7aFVCSbRiBFqTX6gZ6ggv1R8f26X3OkiGSc2/26/wQlGoipPsl2T6zvBl5zj0UyOjdYahm4LgQy9fTcuXOVIMENNGPGDPrLX/7iWPaDtRizVGwUZDA4jRw5Um24g0hetGiREjUskMHkyZPVezjHHZEMYYQB0dXFNlHwmSqyTCsXZ0PkjQToyOFiAxHM/qJ4DGTM0P8VrKXl5XtpWm4/pyLZNEy4bsEsi57pEAeWkdE38CY7JHXB8jL6Ft3dK1QCGdhbWoKecS952Cx1gLIfflA72+GCxH7NqCNcL100Bzrzm5r0pkRRQn461e+rpPp9FVS3t5wSe7QlSQg2ponAYN7LzhJUqOyQmgsP3CgBQofxuSbEhvfmuunnIzSieq+LRAMxXcD7ixh3zen+vkiwMp977rlqGRvWiLvvvpuOPvpoJY4xOGFpj8OI6KBTxt8AHp3FlsV7fI6/LMmm+v2aJEZNKRf7E+vh2CCCMJhDGLN/ejCzT+2ra9v0mp/ofBe0SdfPpPYUqMEB9zy3D473iX0KEMXYiY1Ha5SHzsrS0NJMzfZWSg5itIfW5sBbkr3Z7IUJBoedQ0gojpDAfs147vd4+1FRlHV4f9r70Qr1smzpDuoZQpFsGqESWfheGN9w9OnTR9UNh5tDu+CsbuiXdReNUOQg8EWM1m2aRyX/uZMyjrmeUqdc0mVcGlojKByw1yI5UBUAP2cG1uEJEyYowfzxxx+rkHPuNmRnjceTxg4h7k1aapMwVcAHS2zxEh9bATkKC/sTw1KMThoDMyzK2KUdTIGMbGhF9TXqeY/EtEP+bmIHaGJ78neKZ15JcBWZxBX3rP6Knt2ymO4ddSxdO2gShcTdIggb1WpWz6GYrN4U12N4h20W72MyioMzv6GuIZpxFBQUqAkKL79zVkBfNvZwW00f1ZsKP19DrY3NVLm6gLofP5Ki40OThtmke9okSyTKgTEX/S+SlAF2fcOkCu0DmSPZQKb7NZuc5KR+2xJq3LWcyt77IyUffjbZ4hL9Wi5ThajdoLYVKIzZcoglTQxWmFkC+Crj5sGNo1uTEa946tSpjnP2799/yGcVFxcri7M7eLJxz1ThYJLlLxjl0q2AnCIcnSoEMXzjkK3MGudaL1Owb+6KxnpqpbZ6yI5zHsPVpOtnWqfnTd1Ys9lhcuQsxbM/ypISG0e1LU0qFFxQRbK+cS/AlmQsJRf/6xfUUlVIPW6dQ4lDZ3ocIYEtiXpWQGsqbRZF3kxio+NiKGNUbypbup1am1pUyursiYGJONAZJt3LppZH72NwH+oRVmD0QL+ONoKxHElwgC6afY0x7k65PCH9mOupau7fqbmsgCq/eooyT7rDiHKZYEm2G9b+fBLJ3IHxknQwga8oZpG8axZh2tBRfvHFF3Teeeep97BMg071wQcfVK+xQQ8305IlS1SmPIAMMHiPhbQ/LcmmXmxTy+aPiQWn79U32aGNYtOUvjTubhrMUIhkjo2cHhtPMU46FBOvnynlceca6T7neopnWJ/QNrAJ02WKZx84Jm8Q3bPma5pfvIOaWlucJosJvCU5sCK5sWCFEshR8SmUMHCKX9Ils283rh27Z8BP1ZtU2txOMif0VyIZlC/dQVkT+odEYJgmakwqj6u+F+KXY3Tr7nNoI7i3sVEUhhLc03qSE5/TrftgsbXFJlDWGfdQ0YtXUcWnD1HatCsoOrVtM2Mki2S7wWULiEhet26d2rh31113qQ1zsMp5K5jRqLGzlYHf8YoVKxyN/49//KMKDQdRDL81pMNG5h9E0gC4ARByDmHfsLMa/4OYyaNGjXJEu8BmGoSR+9nPfkbPPfecIwQcfoM7m/Y8sSSb7G5hatm8EX+80UO3FKNDZMGDMIVoG952iKG4ocsPiuSMWOdLcKZ1MqaVx9qG9MQubC1mn3NvUzz7wuiM7pQZl6iu87KyvXRETltM2UDjLC11oKhZ/al6TBp2NEX5ebMi/E8x2dVTabOlmX1WYYnWRXNHm2wTu6dTYs9MqttTTvX7K9VjUq82sdVVhYNp5QGelAfClV149EkxbwZEJCtOt271aw6m72/KpAuo4ssnldtF+cf3U84Fj3r9WdZymepu0Wqwv7S/aDeKwH1h5syZdM899yhBCqF8zjnnqIgREKoQKu7y448/0qxZbbugwa233qoeL7/8cnr22WdVZqdXXnlFDXAQyjgXiT307/jb3/6mBjpYkjmZyMsvv9xumeX1119XyUQ4CgaicSCttrtEiruFiSIZuKozLK3BXUKPT8yZynBg4ETn6K+lNd2SHGyRDCHVUZlMa1smlYc3g+mWYrQbTtzh7zbSER0NBraoKJqR25/e27OOvinaFjSR7E5aan9Ru6otDn3SqBMD3kassXg5PjWnSuYEFh0JZmzg27OnXD2HVTnYItk0TBPJvpaHQzHi4IkV7zlAG+HMkdhjoIvmjlzw9HL5AqJaZJ9zH+179CSqnPsPSpt1HcXlDfLpM028ftb6ckfAm1h+r0QyhDAn94CQvf7669UuVIRIg3UWAhrWW/iWubLk4dzOGt1nn33msnDo/JA9D0dHoKNE/GRvwe/AgAuR2dkga7IQNVFkdVQuPdQWHrHJDhMVCB5cywEDBqjVi0DPnINZX0fnDaBNp9xKDa0tRpTH9A5Nz3aIPQgYAJctW+YQxeh/QhGLtzNm5rWJ5G+LttP/DZ8RlO/0Ji21NzRXFVHDzh8PEcnBaivoo52l0saBpXf0I2DVqlVtIef6Z5ItPoZaG5qpcs0eyj9hFEUnBC/qiEn3sqnl8Xe7gSDGPiTeiwTNwn7N2LcEazO+0+rXbE1y4mufkjR0FiWNPIFq13xG1Qte9ku6alNFcutBPeRunZn4G9yhw/VIdEiIS4wMOvAL/t///qdcIjBATZ8+nX79618be/E8gcU+rBWdLc+YKkRNLhvKhc4K4fhYGGNZDBYAdFBoX5h0BTPcTygsyfBR7d5B6De9TCYRzPpBZ6tHJ0FbQZ1AEEMMYxXpyCOPNEIUd1QvM7u1bRBbUlpAB5obKSUIoeD8kZbaHerWfo4fTnG9x1JMRg8KNdZU2mgvSCgFo4rK+laxljKzbZS0t80lZf+SLZR/1LCg3mem3dMmlScYugEr0HoSHPQxWIHgyRVcPDE26X7N/ipX9jl/pZTJF1HKhHP98EvMFcn2g32hiWULikhGI4NvGDjuuOPUAasyUkXDnaGriWST3S1MKRuHe2JBjM2Y6IjQQfkaVSCcRXK4TXICfU/rIfswYOE5VnHQNjCowe+cl0ZxTklJiRECuTP6J2fSub1H0cj0birkXzDQRbK/0lI7o3bdl+oxadQJZCJoO2gfyILI7at46x4qeWOper3/+020vnEvZWVnt8sKGKh2btq4KOVpGyPZhY9DE3KUFRzYj8VuouiDWDh7k2AqrscwdfgLU32SWz20JEecSMYghWUtWHG+/vpr+uijj1SGOwxkjzzySMRUDu94h5jrDHG3OBSenesWQLzHnREsxXgPkUpMIRSD16d7N9G3RdvoqG796JQeQ52eY5JI9nd5eOlTj2ON+w6DEJZHsdEOqwudhewzHZTzhSM6ju8ermmpQe5lf6fUyRdTbG7ww6l5K5q7D+5Dtb23U21BGcXW2WlE/kCqSWhVEy5sBuSld/Zrhk97JIxnXa1v8VeUFYxbn3/+uRLQcPVCoIGVK1cqw5nuooHzPemTWhtqqLlin0++yaZNcryxJJtYfp9EMmZYsAJCAJ1yyinKCR4JPl544QWjBI+/LcnhZO0LhYDXxQ77E2NA4iUrWIoxueLBht0sTCIUluSFJTvpmS2LKTrK5lQkm9aB+Foe3e+cs9lZUzyHIqNWpNEuukUALckIb5U04rhDv9+Q/rCjciD8G0QyaNpUSgPPnNBucs8JTvydStskUWPikrhJ9WOtJ0zakXyKxzvuwzhLoJ5229Xkqm7zAtr/3EUUnZ5PvX670Ot01SbWF2DNYWLZAiaSsUz1/PPP0/vvv698vOAHeOGFFyqfZN7UhnNQKZEy62aR7CrChSkuDcEU8LxjmAUPxA6WnyB24LPemQXQ9DoLZrnqW9tWKeKjOw5JZlo9eVIezpjFB9oJx7HGgMNxrINRlkDizmBQVH+A5hZtp2PyBlJ2vPPEMeEY3aIjTBkgnZUjbXhPip6zilrqmqhq7R5qPnE0xSTGtVt611Npc9g5REeAQLJmBQx09JSucJ1MFX3OJhNwOUVYWhz63gl20UCkFU65rsdr5tCTcd2HUGtjLbUUrKCa5e9RyuFnRZS7hf3gdTTtWvqbGOtgB5/jq666iu6//34aO3bsIf8Qzh2FM3CB0ahdieRId7dgHy19WRw+6ZylzJvUvSZa3/mmDqpIbmlrW4kdiGTT6slVpxfIFM+elsU0zpj3Gq2p3E8vHnE2ndN7ZBCjWwSmXy589nyKyelPGSfcQjFp7mUxNQVbTDRljO5DpYu3KteUytW7KXvSgE5TaaP96qm0IYZ2797tSF6hZwXsKA63SSJQLMn+qycIVfRtvL+G2wmL5r179yr3VE6Eo8LOzbqeqj/9K5W9fw8ljz2NojoxlHRWNlPaUziId3/T7ophCRQ+WxBGVhcEVAg3JBaLocjMFwjciZVsslXUm7LpYbbYWoxZMW5wDAb+SMhgmvjTCa5IblsWT+hEJJtcP9ZsdnjNyV0Qss+X5C7hhqt2M7NbfyWS4YMecJGsb9wLgCW5qXQn1Sx/HwFgKfPkOykcr0nmuL5KJIPyFTudimR3UmlzVkAc2OQFA4I1KyDvbzGpzzOpLCaLPk9i/jprJ+yiwYlwcMCNpyZmNPWPS6Wmwo2069OnKWfWNZ2uvnZULtPqy9PraGL53eWQURt+xxBQsBZBJGPZCQfEEmZNeA8HzoFrRriDi4fOLdx9kl2VDdeQIwpw0g6eGUPkwILi79izptZZqMoVRYELZO8vUA7esAsxgM5ez3iIFM/6kmKwyhQuIBTcU5u/p2/2bwu4GNAtyWTz//fUrm6LZZ8wcDJFJ5udkKOjek7IS6fEHhlUt7eC6vdVUF1hBSV2z/A6eYWeSpvdMziVNu4RCGZcd1cbwYONSSLFRJHsL/9aayIc9KX7m9ZSw5y/UMPXf6P5toEUm5DUzq+5s3HX5AgSrV0g2x44ZKSDmwUC+MOqjJkyBkOeRWPDDd6D1SiYg6QJlmTT3S2sZWM/Ud2fGNcNAgfxRSF2PN2pGykCJ9g3NjKygdYO6iKUkwleMtQtxbgX4FYFXzy+50N1v4dbJzw1ty/FRtloV20lba8ppwEpgROX9oMrFPBHDkQ91XIq6lEnUTiTMbavEsmgYvkuSjzJM5HsDIyPOBDBB8CoxO4Z6IsXL16s+leIZleptAOJqZZI08oTqP4X2qLH7Ntp16IXiSr30aT4XUTjLlTtBMERtmzZor5b92vWV3BNvX7+Sr4SDhwy8v3zn/+kJ554QsVC5riTyFhz44030hlnnEEXXXQRRRoQj+HsbgHgKgGfKBbFmNiwn6g/Nk95g1iSD9bDwcdW6lgkBwu0Yd3NxlmKZw7rB1cKwTOQRGRidi9aWLKL5hZtC6hIbj1oSQ5EZIvWxjqq2zC3U5FsSn/oqhwZo3pR4eerleW9YvUuyjtuhPJX9ifwwWcLInyYJ02a5BDOHE4MlmjdPQOvA42JIstES3IgN6HZ4hIp85S7qOTfN1Fz0WbKPSiG9f6YXTTQdtBu2JUHj8C0+urSluTrrruO3n77bSWQMXjiIiIsyqOPPkqjRo2iU089Vfng4G+RYk3G7wgndwv2J9bDbGF5D2FqIHZgJcYj+8iFCpPqLJTl+uPIY+i2oUd1GO0gkOVxFsuaLRcdpXiGZdmk62ZSWdx1uYBIRorqqwa0hR0LpE9yICJb1G+eT/amOorO7ElxPUeQ6XQ2WCMlddqwHmrjHiJdVG8spPQRPQPaXtH34v7iNMkwwuD+g2hGKu01a9YoYc2CGY+e+Kp6UhbTMFkkB4q0aVdQwsAjKL73mHbv4zvR/+Jg/3erXzNAjgrdRcOEEJr2rmpJxiAKdwtrJAssC+BG59lWpAhk/BZYkl35kLElORQ3uJ6hjJMxAPYnxmwTAtm0GNYmW9+DWS6kpO4epPJ0luLZWSxrZ4S68zUVd+tlVrcBdN+6uUokw8WG3W38TutBK2EABqraNZ+rR8RH7ux3h0tbyRzbV4lk3sAXSJHsrF4gmnNzc9VhjcGLFUD4/2Mc0i3NnHUyEOUJJV1RJEfFxB0ikJ2eFxXVzpUHgnnu3LnKaIn2UlBQoCZY3FZ0v+Zg12mrm9EtTNUA7nKI0oVLxW233aZ+2JgxY9TNvGnTJrrlllvo9NNPD7l1MhC4u3EvWDc4ysKb63BjwBLIlgl0soMGDWrXgcI6AZFsGmJJdr+efJlAWbPZcYpn+BSjrXjje25Kx2baYOoO47N60POTzqQZuf0DJ5BxjVgkRwdgiTgxVSVBSBp+aBIR03CnT07un0ux6UnUVFlLB7bsp8bKWopLD4y7gzv3jjUGL9/H6Mvh3ojNgLiPdUuzN0LIVHcL0wimcG8q2UFNRVspafgxbpULYEWCVyW4rWCCVVxcrPSZbghhv+ZAh+u1d9XoFs888wxde+21dM455yhhhoqGaDvhhBPo8ccf91v803DcuBeI2IC8vKJvssNOaU7GAB9R9ifuqKGZuqlQRHIbc/dvU1bFCdk9fU5L7SzFM2/IdJXi2ZPrZhImDarulCXWFk3n9xkd+LLwPR8AS3LWab+nzFPvxpdQJKDSUI/tQ0XfblCvK1YWULfpQwL6fZ7AghgHQH/OQsiXVNom3TsmL9MHSyTXbZpHex89iaJTsqnPfRuUv7Kn5XLWVuB+iQkWxgS483Bcbz3Jib/DdLZ2VZ9kWJ1ef/11euyxx9SNiYqAjyvPYiIRd+MkA38k7dD9ifGoh9gaOHCgx3FnRYx6RrDra0HJTnpk43y6esAEj9NSsy+jns2OUzxjOW748OGdTqC8xcTBtSvDCVw4JB8GxLymNhexQI1Tqk1FRRvfRtwtB6JcsEiGy0XuUYMDdt/4+rkYb1jgYAOtnkqbs73hPfQDLJrxvCPxaZKYMaXdhEIkJww4gmIye1Bz6S6qmvciZRxzfafnu2OUw985GQ7guN7s17xx40a1zwSrz1a/5kib7ASCmI5+PAZiCDaOi8xZtSIRd6Jb6O4WnoClEd44xS4U+AxOiwpLMRq3L0sjIpI9r69gkhbbtvpS3dzQYXm4XQU6xbM7mDSgmlQWT2hsbaFnNn9PC4p30qtTzuswkUyH/3+wHfBAh9UlHuSwVI8Bzm6vVpFTaupq6fvvv1diKTs72+cUyk3F2ygmu59bvs7hdH3iMpIoeUAu1WwrpqbyGqrdWUrJ/drcHUzHWSptth6ifXAqbc4KyKm0TXW3MKk8wSwTfJMzTryDSl6/kSo+e4TSpl9NttgEv5ZLj+sNfaFPsjnt+urVq5XG00Wzpz7wrV3Vkgw+/vhjeuihh2jVqlWqIuA3BX/kW2+91RFMvav6JLtya4DY1q3EWA7H53PnBasAJhv+nIGZukFOxHsbqTEHRXLToSIZnRcGOTxC6EAMoX2g0+rXr19AlsncwaT2ZFJZ3AWxkiGSC+sP0JLSAprerX+n5+uTI7QHtvzw6hIeeT8IfFYhkm32KLKTnZJTUii3Vy81AGLwQ1tiCyOLJXdFs72lmXb/eQpRTBz1+vVciu02kMIBdwdrbOCDSAblK3b4XSQHS5TqURH0VNqc4GTPnj2qHeDvAL6rnaXSDiZdWSSDtKmXUfknf6WW8j1UPf9lSp/1i4CXC4JY92tmtz20F/QnsDazO4/u12zrRKe4a0kOVGi9YHHIHfPuu+/SVVddpUK9/frXv1YDNirwj3/8o9oc9uyzz6pOtyv6JDsTfVZ/YnRUmMGhgSFmJpIxBDpci4hRs4VXykFL8oHmBqcpnnnZyyqGBLPw5B7GuTO6DaC3dq2iuUXbDxHJ7EaDQYpXDHhyBEuhO5Mj3riHmL8wXuBAu8ZEi1MoI+4qvgv9EazMLJo7Gtzqty+h1rpKsiVnUUxOP4o00ob2IFtCLLXWN1Hlur2Uf3IzRceFXjj6ijVFMi+5FxYWKkMNp9LmrIDWVNpdXST7e69RZ0TFxlPmSXdSyb9/RRVzHqa0o66mqJjYoJYLkyX0Bzj4e7DizStXO3bscKxMZGp+zXp76XKWZL4Y8EVGrOS//OUvjpNmzJhBRx11FB177LHKF2r8+PFGNvRAimRGd5DHAQsQW3zcHdy6ikgGJpYrWPXFA1Vj5QH1el95mbIWs/85wvqgE0KbwiDG4aFCjUntKZz7mJnd+iuRjBTVdw05qp2lmN1ovF4xwPWxHxrdAvUFsY2DxRJEM7J7cYxeDH5sacYgqVuM6tZ+oR6Thh1DUbbA7o73F560VVtstAr/Vr50B9mbWqhq/V7KHNPH72UJdbvlJXdYDhG+FWO4nkqb/VTRF+mbAYOxMd9EX9Zg93epR15O5R/dR83lu+nAD29R6pRLOixXMNqS7s6D/oj7jfKDonn9+vWO1OssmqF9TLuOgeCQKTRM79ioZwWhpLjiuopPsp6IgX2J165d62hMsBSHMmWviaKmo82OoR40glFfzlI8Q5A0xrW1reZYG02fPv2QpW9Tr5/gWxSSUTHp6vny8r302XffUE5ymhKnWB7Ho0+CRCtGZ77DumjmZAVoo2xpZtHMQil6ZVsq6sQRx1I44Un/AlEMkQwqVu3yq0g2Db3vdZZKm0UzhDSvZuhh5wK1/8Gk8SAUwh1+yOnH3EDlnz5IrXVVnZYrFHWl9xu9DrrY8qo52gyySHK4UbjlsnD2Jtyo6Ryi7mAlhk8yLMeYUeBGQuNBaDj4JnM6xUj0SUZn8d1339HIkSNVA+BGwCl70TjGjh2rXpuEySHgTBXJ/kBPKcoTKT3FMzoXzLx71pYT7V1IJU11HXbEJolk00S7SWXpCN3Hjy3FEBi94lJod+MBsh/Wkyb38WPmOq1OomxRXi3Ls2h2bADbs41Sdq9UmwE3N3ejzG3blFByJ9RYOLWPxF5ZFJeZTI3lNW2b+KrqKDYt0a9lMaW/66xuMEnr3r27OgDGQH25HeIHolp3z/CH66Bp40GoypQ+65eUNuNnFJ2UERZ1lZCQ0K69IPoZ2graBFxxYW2GXtL9mtF3hEP/7ZZI5k7wt7/9LZ133nl02mmnKfcKdKYIVv3555/T73//exWHFZhy4XwBmY7mz5+vOgNktbn33nvV8tTLL7+srD3WmLOwupiIyRZbYNpN4q0ItKZ4xgE4xTPajDXFM+ibnElfzLyKeialdVgekzCpPCaVRQeTId19Au2Cd4tjcoRHDCrHLC2mf21fTt+X76FT/SqStec+CFh9A1jW/sVURHaKzh9O2X2HKeEMixHaPQslFs2mTaQ89RfPGN37p5jJqwood9pgilTcrRusqFo3d7GlmTO9cRtn0extoiLT7utQlMmWkGKUr7SnREVFqT6OPQ842ytPtOCai36yW7duxmUD9smSPGLECCUYYTn+8ssvlYkdnf4777xDxxzjOkOM6SAG9BdffEHz5s1Ts2VkFcTsZ+LEifTwww8rodMRpg0M4WKxNa3O3L2O1nTgsBSiw2J/TkQqcSdsTpwtmo7I6d3pOabVkXBoW8CqGqyuS5cudSRxgVDghD/O4o4elduf/rNrNTW0dp723mMObtrz1JLcGXVr21JRp4w6gXL69XP4JnJ8Xvg1b926VZ2D3436gJ+raf2OO2SM0RKLrNpFOUce5pffYKIl2duywI3QmkqbswLCwATLIc7R3TPc6Q9NbC+hLJNKKLbxW7IlZx6SutrEuurIRYXHRt6fxa5d4e6i69SZFjfFH/7wB3XoIKwMKsDdeMlwXUAoOQwqMMcjcgbSXgP4AP/ud7+jTz75RM04sEQNy/Vf//pXh88UmDlzJn377bftPvf888+nN9980/Eas5abbrqJPvjgA/UaVvAnn3zSaRSO5cuXK9GPScDkyZPV915zzTXqsTOBbHqoNWBa2UwtV0dlcpbiGYMApwPHjDkQPlemdYKmTQZDURaeILFVBM9x/8Nygg1x7iZxOb3nMHXEexgn2TOfZP+0H7X0m5FPyWNO/emzo6IciQpYNKMuOD4vllwhnHVLszfpk4PdPuBukdQnm2p3lVJDcTXVF1ZSYr7vUZtMum/8LbKsERH09MhFRUVqxZmToOhZAa3fb6LwC2WZKj59kMre+wMljTyR8m96z5hyuaLVRXQLdu3Canw447LnhhUBaTEXL16swr8hFNysWbPcuniYRcBSe+WVV9LZZ5/d7m+YXSxbtozuvvtudQ5utJtvvlkJ3B9//LHduT/72c+UKwRjtdhcdNFFKtTRnDlz1Guk1b700kvpww8/PKRMsBZ7Eyc5XHx/TcLkcqFMuh8pRDHaup7ieejQoX4L3/fZvs20oHgHzcobSLPyBjgtj+D8WgUDTgPM7hM8QcJAz6Ec4W6FvkKfxLvC7+LYmU9ytM1v2cBwuLoevHEZhg+spmCyANHM6ZMhlHTR7GmSAm/w5vPhcgGRDCpW7vKLSPalPIEgkP2Knh4Z4St5uZ1XHbAZELB/KrvqmNjXhVKMpkw4h8re/yPVrplDjfs2UFz+0LAIs2b3YLOjqb/BHZz24JghQuCuWLGC3n//ffroo4/U4IHNfJzBxZ0ffdJJJ6nDGehk4fagA+vvpEmT1GCETSUMZiLsLG4FSz4QxwitdcQRbR38888/T1OmTFFhbtiHujPcFcmmW5JNE/CmiWSOTYslYvhaou1ABEMU85J5IFI8gy8KN9M/tv5A0VG2Q0SySXXUVUS77j/HrjS8atDRBMnXeqlraaLEaD/FpdVvdT9Zkr1B+fceXGLl9MmoV4gk3brIYgpWSFN2wCMU3L5PV5G9pZUqVhdQ9+NG+jzhMM3dIphl0ZfbAa86sF8z+7dDXGO85YQVvmSHjASRjIQ9SaNnU+3KD6nyq6co95Kn2pXLVJ/kVoP9pQMqkmEdgP/xG2+8oTa0jR49mu644w665JJLAp4OFwMVd7pWP+LXXntNDV4Q3XAD4UxCixYtUjcaC2TAbhQLFy50SyTDeghfw3AVD6aJUSuhKpezLGa89IMBG35TwYgLCnoltUVEKairPORvJg2opuLrIGYNlo++hndiw5UGMav1Tbr+ZGX5PrpmyTsUY7PRouN+GdLoFh1R8dXTFJvbnxKHziJbXKJfhBJbF9mPFaIZhgv2Y+XDV9Hsbf8SnRBHqUPyqWrdHmqpbaTqrfspbXA+RRKhFH/6qgO76mCcha5A3wwjnJ5KmxNWhCKkaqjdGjKOvVGJ5OpFr1PWGfdQdEq2EeXqDHcEPM4xVZd4nUzkrrvuoldeeUW5OLz44otq8GDQoDGwBOKiYYPg//3f/ynXCSzJMBdffLESM7AkY3ctyrdy5UqHFRoZhbB70grew9/cAZZk/DZXiLuFZ3SUpTBQcBxHPuDS4yzRC9oPJlnBEsi6SN5TWxUWky9TyuNtX4P+jMPzsShmARdI/3JnIKrJxuoS9bykoYZy4t3b0+GPOMnu0Fp/gErfvououZF6/2k1xeUd5l4R3BjA2UcVB4tm3JsQzeifN2zYoPpfXTR7M1nx9jpmjOmtRDKoWFngs0g25b4xsTwcSYXDymEPEGeHxD3KqbQx/uth54IhmkNtFU0YfBTF9R5LjQUrqGreCyojH5fLVJHcanDZ/MkhrQ+WYzRihEZDBZx88sk0atQoFSOZrbeBWAa/4IILVKVjQ50OxDqD+MUY3CZMmKD8mRHTGTi7UJ7MwDpKJhJO7hYmCq1AJ+7Qg5vjkdOuQggh+Q0sFM7Sroairnoltk38dtceakkGJl07Ezs+V/czR2HQ2wOvSmGJH+3BV1Hs7f9CFI9I70ZrK4toXvEOOrPXCKOiW9Rt+k4J5JjsvhTbbRAFEt31Qg+lxxETkHkS/TGn0Mbhr30BzkgdmEfRSXFtluSN+6ilvlFZmCPlHjLREsll0hNW6Km02T2Ds7xBNOubAQOR0TbU/a/qq469kYpeupoqv/k7ZRx3M0XFxBntbmE3uGwBjZN866230s9//nMVKeJf//qXcrPAxpWjjz5aHdi0B8HsLyBOEZcZ/kpff/11OyuyMyCMIXywQQTPYWFGlkArxcXFjniP/kpLbaolGUS6SOZsj7qlGEt2aC8QQnCrgSh21+oQ7Lrqn9ImCgpqKw/xTTVtEAMmtqWOErlYRTEGUnfD8wWLo3L7KZH8XZGfRLJ+fXwUybWcinrk8UGvL6xMWiMmsGjGZmxkOMWKj9XS7K+2Ch/kjFG9qXTxVuWbXLl2D2Ud3j+i7htT7gFXZcJ7uLY4eO8TDB9saYZ/O1zmcF/rlmZ/rAiaIPhSJp5LpW//lmxxSdRUuovi8tqyHJt4/bq0JRlgZnfhhReqA43yv//9rwq5htBrjz32mAq3hs7MV4d7FsgQvN98842jo+wMdJr4Pwh3gA16WEpdsmSJ2vQHEIkD702dOtWtcngS3cLETtBkAe9tnekpnlkEoc2xKMb193bTRyiuY7f4ZMqKS6SyxjraVFVCYzLzjW1XJnV8ur+91VKM91gUw50m2KHHPGF6bn/6+5Yl9F3xdr98XpQf3S3q1n2pHpOGH0ehRhfNWDWEG5xVNGNvjC6agS/XHTGTIZI5sYgvIpkxpR2aKLI8KROuNaLJOEuljdCD6BOgV9jS7G0qbRPqCZbjnnd+RTE5/R33tAnl8nViYWr53cWl2Q2zuiuuuEId6KQwswPuiBNYejgMDIC1GM76aMho9Oecc45ym0D0DAgg9iHG37GkgpsAm/bg8gHrNZbibrvtNho3bhwdeeSR6lyEZjrxxBOVW8Zzzz3nCAE3e/ZstzbtAXyXOz7JprpbmCi0PC0X+5DqlmL8H6d4xnKcv9LjhqKu8J3D07rR/JKdyj9VF8mMSR1iqNsST5IQVgwsWLDA0R4wIGIjECxKobb+uMuRuX3b0j1Xl9K+umrKT0w1wt0CFqum/ZuJbNGUMGQGmQZWhtD38+qlngUOUZCwTwX9N/oPuGt4I5ISuqdTfG6qipes4iaXHaD4LNfZ0Jxh0j1swn3s7zqyptKGwYzbw86dO2n16tWOSRQLZ3fcdUy5boh0ES7W2lYPymbqb/BJJKPRYRcqhDFm9RiUEBaJBYY7PxrxjuGewcCVA1x++eUq3jIn/xg7dmy7/4NVGUlE0Pl99dVX9PjjjysBBaF0yimnqOgWukiHkIZ1+/jjj1evEWv5qad+CqPiinCPk2yygO9IkOrRBjg+Le+GhigOpAgK1YTisfGzKTU2nrpb0pGa1oGEojzsTqO7T/DKAcC+CLSNUItib9tNZlwijcnIpxUV+5Rf8nl9RvlYEPKLSK5b/7V6jO83kaIPbi51uwghuIecZYFDMiqMU8igijELhh095Jyr5XjlpjO6D+3/aq16XbmqgLrNHEaRgmn9iz8FKcZubNLnzfs8icLBKw/QEbpodrY3wRSRzLQ21VPjruVkp24h7/NMdlEJukjmhoJNe9dff71qYBzCAxmmEAoOAtfdxgSh21lH6qqThSi2ZttzBho+QsR5i7sb90y11ppcNi6XsxTPmOh4muLZX2UKBYPTcly6FJjSUQe6Lek+5iyKORwUBjNeOUC7QQp5f60ihJKze4+gURl51DfZD0kr9OvjQ2zfhp3L1GPSsJ+MGZ4Q6vYK0Yw2gxUHuNfplkUWzRBFunuGM9GMxCIskuFykTujzSDkKSbdwyaWJ9Blsk6idB93jqbCSYL0ZDcm1VNzWQEV/Gky2Rtryf7LLykqyv8bFf1Bq8FW7oCJZPxguEf89re/VZY8WHRhtYWLxZlnnqnCr2FmDleGUIdM8SfubtzD7zXVkmyaSOaUpRA+iI2KpXPUcyhCcDnDpLoyrUyBuCb6znUWxmgbvHMdqeKdCWG0o0jhV0PaXMT8Qqt/LMk5Fz1O6cfeRLa4wMbAD9Z9Y7Usol+HQMIBizPCP+obv9i1LzYtkZIH5FLNtmJqLK+h2oIySu6T7VNZTMAk8ReKOrJuDLXG7eZkN2g3OPA37G0IpbaJzuxFMZk9qHH3arKvfo+iRp1PJmLvapZkvplgtcHghRTU6EDwHiw+8AtGmue3335biWTTOgNfiBRLcigFPG+w4YNTPAO0I4TvC2QoJ08I1XXEdz6w/jv6sWwPPT3hNMo76HZhQp34u344RJ/uPgGXJnanwZ4EiGJ3N16acN8ZdZ3aJROx+fSbsIs+3Ono2kD4IMoRRzpCG2TRDIMQ3PggmiGikvqlE20rdqSp9kYkd1aWUGDCfWOScHcWtxsrnBDL0Dk//PCDKp8eci7Ybl6om7QZ11LJ6zdS1Ir/kG30BWQirV3RkgzQaaCR8G5SCBv218VzDrdm4s3nLeEeJzkUwo9TPOui2JriGa+RLtxZ2KauKJLxve/sXksbqoppWdkeOqnHkEPcLcIZWIrZSoyDQ/RhoPE2GkmkdcIt9lZaUb6PEqJjaES6eyEqA+mT3NVAX69v/NKjJexqLqd0G5Gtlah89S6KObw7ZeXmOI21Hk6Ydg+ZZN3mBEPomzBZQiAAjGUcdg7BBrCaxcIaY1kwUmmnHnEBlb79G6LynWTb/QPRCD+EjfQz9q5mSWbQYNCAsTyO5XB0ELAIYecwfJR5I14kVQ58lMI9TnKgBTwGE10Uc/tAe+nTp496dObrZ6L1PZRlGp/Zo00kl+91iGTGpHpypyxsKWZhjDaCpUoMJhhsgjGYhBv3rZ1LD22YRxf2HUPPTTzDL9EtvI2TvP+fl5G9uYkyZ/+G4nv5uJEwTGkXLWE40c6yJVS9Zg9RUyttX7iWlqe0uQTpcXk7E80mCUDT+hRT60i3iiorblqaOjiVNsY6Xn0oKChQk3/eSxOoVNq2hFRKnXwRVc19jqJXv010whVkGq1dzZLMPxYbqHDxseyAjXcY6OCCgZjJsAYi0UikiWRYFyIhTrI/y+Zuiudgl8sfhPLGhkj+986VSiSbUB5ndFQetro5y3DoaTIXTzGlDflajqk5fYloHs0r2u6bWPAxTjJ2z9cs/5DsTXWUeervuvQ10cke379NJBNR94YkGjt7vEMgYdMXZ4DjjIDBSpscSYLU1DI5m9CjnBj3cMAYpEfhQZuwptJmi7M/Vh/SjrpKieSorXOppaaMopPbYoKHoyU5yrDr7QmH3N0Ix3bdddc5Ggx8SY866ig69dRT6dJLL1VL6JEGx0l2dfOiQbgTTzkU+CJGeVOVLorZKugqxXMgyxUoQlmmcZltbkzLyva2S88KTKonlIVXD1gUY3BgSzE2XgbCghJJnaszJuf0ptgoG+2uq6LtNeU04GAmRp98kqM9r6OGrd8rgRyd1p3iegyncMdf7SS5Xw7FpidSU2UdHdi6n6KbqF0yC84AV1paquL24zULJAhnjJumtVnTymNimdzte/VU2thsDDB2snuGPpHyNZV2fO8x1JozmGwlm6hu3VcqI59JtHY1SzKDi3/SSSc5XuO5/joS8cSSbKq7hSdl83eKZ1flMkn8MaEq0+iM7hRni6bSxlraeqCMBqX+tDko1PWENoCOHru+4X6EBB6woKCT92WiJPxEckwcTcjqRYtKd6l4yV6L5HbJRDy3JNeu/0o9Jg6b5dNAZ8Ig6c/7hmMmF8/bqKz1lasLKGfqYR1mgGOBhAOJTTCxBNgIxgIplC5HJlptTRRXvvjXwnCIfTh6Km22NCObMG8O1WM1u5tKu3HGnRSfkUcpE0OfDdNKl/VJduWvE4lg4HfHQmyq4HNVNrxvzWbHiRpw03oaacBf5QoVoZzsxEfH0OGZPZVIWlSyS4nkUN1XEMW6pRi+d+jMMVFG54eslqaIYhPakL/a8lHd+qnr/13RDrq8/3jvPkQvhhc+yXXrv1GPScOPoUjAn/dQxpjebSKZiMpX7qLsKYM6/HyrQMLGdsRmhlBi0cz+qzjwPJii2USRDEwrkz/rCRMpbFTGoUdUQT/LYQj1hDcYgztaoW/uPooSUrzL/thV21ZIRHKkzxbczbgXLtEtgpni2ZNymUKoyzQ1pw+triyk8sa6du8HukwckYT9innzJTpp+JmzLx3aDpaTTRHIkcaM3P704PrvaF6x937JUe1CwHn2//BvbNi5VD1PHOpdEpFIJj47lRJ7ZVHd7jJqKKqi+sJKSsx3LwEMLIRYgRs9erTDjQ33EkQSMsDhHkQ/zO4Z6I8jfXwNB3EVyDJZI6pwwhsceiptPcEJh0vVrbX25kaKijEnsUirG7kyTBv7vSEmEhq4v3ySw9XdAmWCZRhZp7BUjoDoatkwIyPgKZ5NF6QmlunWodPotyNmUczB6xEon2Q9TB86ZIhfWDBYFHe2+dKUaxZpfQ2YmN2L4m3RVFh/gDZXl3aYidH9ZCKe3dd1G75VPs2x+cMoJrPNAuoNprSRQJQjc0wfJZI5ZrK7ItnadnG/4YBhgt3c2KcZkRIw7rClGaLZ3zF5TRyvu3qZnKXS5qyA2Aiop9KGISPRXkeFf/8NNWz/gfr8ZT1FxZhhvGg10G0mZCIZFxGVYeKmhK4YJ9lZimeUC1ZBLPEgSHqwUjybLkidEep6SY11HirPV7izZfcJxPvEAI1BuG/fvm5vIAl1/TjDtDbkC4iR/Nj42So9tdcpqn2wJMMaFT9gMiUMPIIiBX+32fSRPWnfnFVkb2mlitUF1P24kRTlRvrvzsSWvumLRbMeXmzXrl3qHtYtir6KZlMFqWmEsp6w8pCTk6MOPZU2+nEYvnZU1VP/td9QdEMF7fj6VcqefI7aQB3q62oXn2RSSwEI/7Z161YlkAcPHkxHHHGEGnC7ortFqAQfOk4IYRbFEMgoMzpTzEZxXXCN8Bqdr0mYKpJNKROSS0RH2bwqE7cLXrqDpZiX7dAO8OjuBhErptSPafirXi7uN9ar/8MAqlaOmu3k8Gz1MLpF8phT1OGP3xLqgTpQRCfEUerQfKpau4daahupest+ShvS5mPqL5yFF8M9zKJ5x44d6lrrotlTNzkT72MTxZVJkwk9lTbG+9zcQRRTcgHVz/s71f/4Ji2O6q3KqreLUKTStrtZZ+G+ny2mo8H3xRdfpJtvvtlhoQSwTGGT15NPPqlSU0cKsK7hd2IA6iyiQ7DcLWDV1kUx6h1iBxZBWIqRqMGa4tkUK7fJglQn1GV6a9cq+uu6b+m47oPowbEnuVUmtjCwlYHbBYtitA+IZF8xqUMzqSyhgNPm8sYfPFe+rrXNxGsCsDbZqtM8Xj2KlLoN1L0MlwuIZHa5cEck+1IWXA+IHRwwRLFoZp9mZH9j0cxxml2JI5PEnyl9b7jUkx6/OWfWNbR73t8pbvf3NOtXE6mmOcoxmYKBjFNp61kBAyma7Xa7kZOdQOBUEX722Wf0i1/8gq688kq64YYb1CwXNyfiQj744IMqXjLCQw0fHv7xNQEvQWMncmciOVBCFFZstghCALHvKBo6YjG6I35M9Zc2USSbUKaYKJsKAZdcsstRJmeiWG8XEEg8WdJTfweCUNdPV+CTvRvp6/1b6YbBU6hfcqZ6T7cm8nVHn8SpvdHnLl26lJKT0GdVqv+pqKqkrYsWqdUl9m3tKBV8U+lOsiVmUHRSOkUSgRA4KQO7UUxyPDXXNFD1xn3UXNtAMUnxQSuLLpo5+xunTGZxBKyWZuv3myb+TBSkJpZJ9/uN6zGCYrsPoabCjVS3+hNKn3yR0gfYW8LtgsPOwQNA93XnrID+jKpiPzg+mFhn/uYQRYiB+PHHH1dC+IUXXmj3txkzZqgDcZPvv/9+evXVV41tXN6IZFd+yf4Sop2leIYFoaMUz67KZqKwMbFcJpTpSJV5jWh1RSGVNdapMkEUs+sEWw3RNnWBFIxkPibdzyYlWvF3vTy+caEKBTc4MZNOTuntuO5sFYLYRXxq9A2HCB+tOoYMHUpJ/XNUXwKrI6IoYPMPJta8bMuxWUv/9xuqWfYu5V78BKVNv8avvyfSwIbI9FG9qfT7LWRvtVPlmj2UPWlAp/8TyPHQWcpkXmXAsWXLFnUOC2YcJm6uMlEzuBOpIRToCadSDj+Lyj++nw4sfUelrHbWLngFgn3d0Z/oqbR5QuVrIqjWgzrIxDrzNzHOBBziOz7wwAMdVg6szDfddBNFChzqyh2R7M1gbc1mh9ecpAFpwDEj9CYjj464W7iPCR1098RUGpaWS+uriuntNYtpYHOrshCyr3lHbjXBwgRRGqlwsoEhUcm0iIg+2ryCJvZq8yXHqp1b/oX6XN0Wpc5nYYRsiLAk4TsgmrFMj9isKclJ1H3tlxRlbyVb3hCKFALZVjPH9lEiGVSs2uVSJAcT9AsYO9iiiLEZFkVcc7jgIJEF6gZ9CvybMVkyaUO3SZja3+kuDckTzlEiuXbt59RaV0W2xDS3fd31pDeYQHOmSD0roCchP1sPimR3fZIjSiTjgqAiUbkMlv8Qy2/KlCnq73l5eepmjBTYautKJLsjRF2leMYAFojMZSZYR8OlXKEqE/uX8jL6oMY4Wk9E88t20eDYvspSjB3Ooe5UQv39zjCtDXmbtAV9KwYo9AdHpPegl0vW02ZbPY0dO9azencR3QJWotzcXHVwGYrXfEv19ZXUGptEC7ZVUVrZIoel2ZvlWJOuSaDabEJeOiV0T1exkuv2lFNDSTXF56QaaSXF+MSiGcYX9Dfr169Xba+4uFiJZn0yhevubJUi0Jhq3TbRKqq3J6SPR2rq+L6He3Tv6aEI9VTa3B9xKm30SXpWwM4Md/aD329inQVcJKNiMAOBMMZNBJAlBtm3ePaACoZQBqY1dm/gwcFVhAtn7hYc+5KFDw6IbZ6lDR06VD33R4pnV2UzadAyuVzBKhNbdngZHf7FaGtoFxAv52ZMog+XvksrmyvJFm9TkzWT7idTlkVNKAPjTrvhqCO83Im+FGKErby81DmsuZFu3PYN7amrop21FQ6/ZPcK8tNTd0KToV9PKlpJ9fC1HTaLps882hGvFxni0Gfx4Ih+390oCiZcm0Dfyxlj+lBh4Wr1vHzFLup+7IiQlcUTcP3gcgPxg+Qm6I+4XSKe/saNG1U71N0zgiWaTWg3JvZ1nU0o8Jj3s1f98rlYocTB6dVhyOP+Cm47nEo7U7M06/uiurRPMgbqMWPG0DvvvEOXXHKJqjz4uKHTBPCb/Prrr2ny5MkUKSjHeDcSirBIhvCxpnjmbHbYUBWoFM/ulM00TBTJIBBl0tsGi2IMVGgXEMUQSPoglNaUS9HLomjbgTIqSoB8MYOu0PH5ExYfui85+lEMLp3Fp06OiaPxmT1oSdluWlC80zOR3Op5nOS6DXPVY+Kwo9ulU9Z9GCGasTSv+0WbtEzfEYEsW8ao3lT4xRpV53C5yDt6eKd1blI96f0c+iKOgIBY+mi3nMSisLBQWRR58ycfsD76+/eYKEhNLFMwy4X+yppKmy3N2w+6a3EqbU6jHe6h3XyyJF922WV0/fXX0yOPPOII9cFWZYAoF3fccQd1hVjJeopnzL6xVLps2TIlfCCM3fYhDDAikj2rK3+IZH1XMU+YONNhZ5uumLTYeDqr10hKi40jqjZvMmHSwGFS3ejXvaMIFO5usJyW21eJ5HnFOzyLnazXhxsiGSlt67csVM8Th8506cPIG8LYtxWTfrYy43AWOSNSQYSL1EF5VL2pkJqr66lme7GKfGF6O3V1D+uuF3o0HUyU9u7dq8Z5zvzGmz993SNhqgXSpL7OlRtIS0051az8kGKz+hxyL/sLXHd4C+Qd9BjQU2kj6Q3aCcAqFAvnjiZUJtarJzj1Abj88suVaOQsezjYbxfPn3/+eceNFQngd+L3oiHAlWTTpk1KAHM2OxY+sBDDtWLatGkhF8VWUB53UmsHGxMtyd6WicNz6eKI2wZvwvTU4vbCEWepR4RUNKWewr1T8zfsUgVhjEckWAI8GcIKgbcWtyNz+9GjGxfQjppyj/5Pj27hTlrq+u0/kL2xlmypucq30dMNYWxxZPGEpVcYFSCoMBH0NmmNPwjGfQOXC4hkUL5yV4ci2cT7x93y8ERIF83WdMm8QqJbmj1BRLLv/tuVXz1J5R/dR0ljZgdMJLtKpV1RUUFLlixRkyY9lbbunoGxMBLo0FH2oot+CjFiJZIEMga9RYsWKbeSq6++Wi01YtD7xz/+oTZR6dZADJL79+83TiCbKkZNLZe7ZWJRrKd6xnssiiEg/JUe1MR6MqU8oRAdHIGC/fQwAcUggMFi5MiRagDwRz8AS/Lqk37leXpqD90tYvMGUc7FT5K9ucHj+tQtjugPOXLGihUraN++fSpeL+qDrcye7pQPhzaSOrg7RSfEUkt9E1Wt30stDU0UHR/c3xjse1jP/Kanvcc9oYcZ1EWzqxUUEcmeXz9r204+/GwlkhHloqW2MiQxz6MOGlChlfRVCLQNaCS47vD+m1GjRvkcvcs4kbx8+XIlGmEpgHUVj6gEdJa4SS644AIjhaI74LfAp/q7776jb7/9ln744Qc1O0JjhIX4n//8p9rk4Oz3mShkTC+biW4gHQ2o7JvJgphj1kIU40BsUn+JI53m1lZa21hBqXVVqlMJNaZZwoIB++DxoYdIwuYWWFUxgUa/yPsz/EFidKznAhnot7obIjkmLY/SZ/zM8+9x9lkHI2dACGMAhBGBw0thMxgMD6gjXyJnmIYtJprSR/aish+3k725harW7aHMcf2MX7b3Z3lw3WE4wgF4soTrjiV4hI7l2Nwsmq1JsEwco0y8bp25W2AliBOL1K6ZQ6mTzg95uaItqxD6JtFgT5iDIpJnzpypxAK7XODA4IAfjuWVs88+2+3lNYjRhx56SMWAhdXh3XffpTPOOKNdZd9zzz3Kcosb7ogjjqCnn36aRoz4aQcxvvv222+nN954Q7lDHHPMMfTMM884wpkA/C9iN3/wwQfq9WmnnabSZ6OD1oHox3lTp06la665RiVEgfjBUvmZZ56pQjGFk+AzvWwmincukx6ZhIUxJoMsioPlb/7LH9+nt0pW0S2pNhrVoy3JiAmYdN38XRa2ivG113dz6xEojEUPAedGdItAAitR9+7d1QEwwYAbBg6EDuVEBiya3Y2cYVo7zRjbR4lkUL58Z4ci2TQCJf6sYQZ10YwJJfxVebMXHzxZMk2QmiySnWVQTB4zmyogkld/GjKRHNVJffEmURgXTKxXT3A6CqBjww/DTYAfi04Ofrr33XcfXXHFFR6ZziG2ES0DKa4hrq0gzfWjjz5KL7/8Mg0ePJj+/Oc/03HHHacsEhAo4Oabb6YPP/yQ3nzzTdXJ3nbbbTR79mwlvPmmg3sIloDmzJmjXl977bUqayD+TwdWD/wWK/hN/oiTHCpMFKOmlYtFMTpxPM6fP98RmcSjRA5+ZnpuP3rr/9k7D+i4iuuNXzVL7r3g3m0MBlxopvceEmoSQiDwJ4SSEEoIqUCogSSQhBYCobd0QiD0jjHY4Aq2ce+9V9mS9n9+I64YPXZXW97uzu7Od847aivp7bwp39y59/sWTpHXV8+X6yX3cG1SC+N+oilQEOXiucdToMgGFmxZLz+e9KKs2L5Z3jgiQRc8az/cVLpF9aIpsn3O+9J81yOkWdeBkmnQrrZyBmNNSTPkiWdhFwGGoZyRjT7bvHt7qezcWqpXbZKti9bK9lUbpapzG6fHTzbJX5A0a7FXkDQDiuCJSOcylz0fHPdiaUq3GH6srH/pt7J12isSqauVktIyJ9sr4sjaHzpJto1EFL169TJpCSeffLKccMIJCeclY2HNFasB77zzTvnZz34mp5xSX8D0yCOPmIrKJ598Ui688EKzsGGPTcT3yCOPNK95/PHHzf28+uqrcswxxxjBdMjxuHHjTCQaUFyI+Qlke8iQISmrW8SKQLo0EbpM4HNJktXYxU6fINpB3hztRW5p2FGtVHBUt3riMmXTClm1fYt0rmopLsDF/pSKAgWLNPNIqgoUmUb7Zs3lxWWfSZ1EZNm2TbJL89hmFdHNROL3380T/ibr/3e7tNr/LOn6nQclmzBRr5YtzaXKGUFXuKByRq5cJpsC90T0ePnL9ZrJ6ycukG5HD3d6zOTyfoLFXpBm8lVJy0BWjGAcGyQ70pyrjaprz60pk5OqAftLaYt2UrdljVTPGy9VA/Zz1hCmxMGxnAySOk8k0qupF2GAgYI+49FHH93wPXaWhxxyiIwdO9aQZKLFDC77NeQIQnB4DSSZwjuigUqQATrOfI/XJEqSm1KH0M7qIkl2KWKbq/vi/2jBlRJjNj6qYU2/gRTzM4qNgqk4ubSoHlTRRmbt3CgvL5+VnBRYBuBa3waJOF3qKYE+e6DGLcxdmdB8DQNIAe7ZvptMXLdM3l01X07v3Zh4RUUjdYv472nbjLfMx+ZDslMJHw+0P2OQS5UzgrJjrAFKmLmaijZmc95rt2cvWfHqNInURWTdlEXS9YjdvpTu4lofc+V+WGM1n5l0R9s63Taw0A1TNk938sFxz0ZJWbm0GHaU2QBXL5yUdZIccbS9skqSOYamAzNxEY3jGJpoAFHcsMgFBBmoFp+CrxcsWNDwGpUWCb5Gf5+Pulu1wff0NWFFkkGxk1GX7kstwJUY8wy14IqIIQQ5WDTkyqJhY9/mnZ0hya4h1vPSUwK9WHRVeYQ6g7CUR5q6jzBwQKc+yZFkS90iXuEe1e/V8yeYz5sPOUTCRBjj2ja4UOUMxrOmZnBEbxMnPkYrBMpaSkGLSmk9tLsp3KvdUm1k4drsWu9a5mIAxVUyowZethYvc7cWgHLCAGlWq2S9MlUE5tpzA02dWnf46nXS8eu/MUW5ubi3EsfaK+sk+b777jMFd5Bk1UkmmksOcdjHlMHGTuQBBF8T7fXJPMhEcpL1bxH9cK1au1gK94KRYk41mEhZZHfdddeopDjT9xQG9mvRVR7fOEdeWz5HdtbVSkWWc8zyYTOYiAKFi4QgERzYua/cNWucvLu6PjjQJBLUSd4++z2RSJ1UdBkgFR2/nEaXLsJeKIMKCszJ6gRILQk1LjxnO9qYbbQf0ceQZLBu4vxGJNk1uDaG40nABQtA1SqZS589410Jc5hSgy6Svqbk8hjTuUJdEukWBUWStaPce++9ct1118mll14qZ555pjmmZGd3xRVXmBSIJ554omESSwc6GIj2qh2iJvXr7pLX6OJoT4i8hiMbfQ25TkGsWrXqS1HqdCLJdrqFa3CR+IVxX0yUNimGGEGKiRiSRsOCmawKgYttNbhZW2lfUSXrdm6XD9YsMqSp2KFRRU61yF8kcpxrBYpM9Zv9O/UWlpxZm9aYAr6uVa2SULcoadKKuirkKHK2wLxsRxsZ/0qayW1lfqBQkI0xc0Q2Nkqt+neRijbNZefGbbJp9grzka9dJFv5fD9Bq2R99lzo8NpSg0qaU50PXGsne65xceMfSfCEohCsq6OSZArlrrnmGqMioaDo4oUXXpDRo0cbhxVIcrodi5w0CO4rr7wiI0aMMN+DqKJf/Otf/9p8PWrUKDNR8pozzjjDfA8pOSZIotqAAj3y2nCA2Weffcz3PvjgA/M9JdJNQR338jHCVkiFe0qKNYVCU33CJkautVV5aancMPBg2bVbLxnVoYe4gGy3EWRY7ZB59hR4QYDoQ0SKWSzzWZS+qeK93dt2lakbVsh7qxbIKb2+kMCMCvvZlDRNkl3IRw4D9Af6ApfmoVOcTb/5+OOPGyln8DHslBvNAW+3Vx9Z9fYME9FfN2mhdDm46bqXXMElkpIOZ7CffZA0k8uuJ0t2pDnRtcJlklzSxPhe+/wt0qzLQOl89t1S7GogmUDUHsTiFO0Yiw6oigGJgrwikvLtYj2cmujEEG/k3ZCWgwBx8TmRa3X8IzKAEx6EXSc+NJMRsVe1C47Zjz32WLngggvkT3/6U4MEHDJxiRTtJUuSiyGtIVv3pacESopZ9OxoIc8/7Dw0F9uKezqmU3/p2rGrE/eSDTCOmB+UFKsCBWMc+TD6AAsjNtC5rHzPFo7oOkBaV1Qag5EmoVNQnEhN3baNsmPZdPN586H5GUlORDlDU6wwgVLlDC5OP1nIdd3gY1jFm+1H9K4nyaRcTFognQ+qL2p3jWwV8v0ESTO8JEiaNTVHSXOsVDzX2snmGfHuK1JXI9tnviU7V8yWTll8D3XFmm6hOOigg4wmMVFjiAqdj8nmhhtuMNJrqoOYSCNNmDBBDjvssIavSdkA55xzjtFGvvrqq83fv/jiixvMRF5++eUGjWRwxx13mMWTSLKaifC7docnBQSTEFXBwEzkrrvuSrghEslJLqSIba7uC1Js2zyTZ6akeMCAASZSnGmHHlfbyrV7Cvt+bEfDZBUoXGubTOBXexyV8GtLNMoUJ9WitHkb6XfHUqme/1FOintcUs4gpQ+pUFs5I5ojXKJo1q6ltOzfRbbMXSk7122RLfNXS6t+9euiS3Bt3GTyfqiVUn1uoGo3XJqaw/qipFmdIF2VdU0k3aJq4AFSUl4pteuXyM4Vs6RZt8FZu7fSYowk65vGAe+0004zhJN0BXZjTDAkz0M8ITOJAve+eAODjkn+M1csMJHhnscVC3R6lDdSRSKRZL1fH0lOHLQVkxN9B2IESSL6AzHC5TAbpDgI1yZDm7iPXbVAnlww2eQkf73PHjm7l7AWtHgKFBCZRIwkXHlertyHQSQxjeTSqtbSfGhmUi1cIhbR7sNWzrAVmyDNqCehnMFcZJPmZOaiDiP6GJKsDnyQZJfaROHS/WSzfdhwc+HMqyfgms+O8Zg6QdpWyvmWblHarLlUDtjPRJO3TX89qyS5JIHn6NomLbRIMjsxjjiJzr755puG5EB2sYVmYStEJBpJdjUK6Qp5pw2JEGq0mON0FismI/oOH3N9bO7iM9QJ58O1i+XR+RNl0dYNOSPJ9v0kC02f0RQK5g5VoGBeccG8xXWs27HNKJx0iVe8pxJwTWgke3wBooaxlDNUckwLwVQ5I55STuuhu0hZ8wqp3bazXhLuuD2cm1dcI+25uh/+Z5A0a6SZ5w/ee++9L0WaczlXJZJuAVoMPayeJM94Q9oe9r2s3VuJQ/0qk4iZ1U56AykRXMWQsJ2IugXw6RbRFQj0+JycQCYitfvl5xx1JpobXqwkGXBPJ3QfKr+c+qq8s2q+IUsUdOXyfhJ9/kqKNX2GRSZV9ZFU7yUbyPR93PLpm3Lrp2/JJYP2k5v3PCbOjcQ3EqlZv0yW33OGNN/1MOnw1esLekFL9ZkkopwBUVLSHFTOKC0vk3Z79JY1H8yRSG2drJ+6WKRbuVNt7Uly006QFASzRlH0r4XDCxcuNHMb65iS5mxLTCaa0mBOip4V2fbZu1l73pFiTbewwa6aiUL1CSGQNAxWohBncpULCYk47rkUsc0VeaeNyPPT43NIMblgTCbkq/PRdshCqs8VgmPDtXvSiW1Q646ya5vOMn3jKnlp2aycp1wEwZF1rOfPSUGY2qXFiP6tOhj+O3b1wvgv1KPYGAvVtplvSfX88SKRWin52q+k0BEGMQgqZ3A8r0WAkCb6Pv1bSbNR3RnRx5Bk1UyuODZ32rX5MM+5RtrtNmJzz6ZI7dPhPTx7SDPGNqz7ummCNGf6VCzRtqrsPcLkJWNRvXPlbGnWNfPcrC7BYGnBScApyNf5v//7P1NAp+oCNAgRQuTXDjzwQEOSCymqnGgk2dUoZKbuS0mxRoshRZBgJcVMGvGKX1xsL9fv6aQeuxqS/NyS6TlNuQCMcZ65kmL6AmNF0ydUgSKTyPdJNhmM6dTHfJy8fplsqdkhLctjpCZ9vk+PVbi3bWZhSb/FQybGsn08zzzH/yBwpKQZxSbWPshSZacWUrd6q2xfvkFKV29xrr/6+0k+95fPIc1cnIjq89dCQFS6mBs10qybpjD5UKIkuQRFnGFHitTVSmTn9tD+fxj3VghoRJKV9P72t7815hxvv/22IcSxUCgEGZAnS45Ssadb2JFCiDHHT5BiyHAqpMh1QuoS9J6+0mNXuW362/LqitnxiVKG7oEICh8R7Icg016qQEEKBZHjYpkgs41eLdpKz+ZtZPG2jTJ+zWI5tGv/6C/U/hsrkqz6yBkq2qu/BXfGUKb7I38fEsSF5bkqZ0CY1ndbL5Wr69eO9R8vkO27tTWpG5nePOYjmXExlzWRAjn7+StpZm5U0jx37lzzOjs9g0hzOu81mbba5dJ/SLE/x6xGkhctWmSULZQgByfDQmycQlC3SOW+lBRrpBhSzIaBwU6u1rBhw9KyIXeRkLrYf+17Gt62q/Rt2U7mb1kvr62YY0hzJqEKFJpXTJ/gmTHJc2KUiAJFpuFaH8ok9u/UR/62aKpJuYhNkmPnJO9cPU9qVs8naVaqBiZmpuSRHGzljLpefWTGrP9JXXWNVCyvlnW9tpiCd1XOUNKUi4JlF8dNrueSVEhyPLlBNk38Dc1n5pozpz4FR599KsY2Luf9JnJvKq1XUCRZHyCybxwnQJY5anKtU+eSJOd7JFldzTRSbB+fQ4oxZgkzUugqSXb5nvj8xO5D5eXls6U2Ev6GzLb6thUomMip/OZz1G1ww4Qg5xquzD/Zuo8xnXobkjxuzcIm1S2ikeRtM94yH6v67S2lTdlbF8CzyfVYLm1Wbgr41o6fKyV1Eem0pZkM+8qYBsJEaoatnJGuhXI+PiNXI9upkuQg+F1SU7lUo1sjzdRxqbGNnZ7RVPAhlbaqWb9USlu0N9JwmURdXV3W+q+TJBkjDhzrzjvvPPn2t79tjtvJ1yU/lYeO0x3uRi52+FSh7zEfCVa8+6IzKynWSDGdm/QJqrqHDh2a0eNzF9vL1gF2qf/a7XTd8CPjqxukIMunfYB0CrX6Jn1CRfVtuNQuwJU+lI372L9Tb/PxwzWLjRRcRWlZUjrJDfnIGUy1cA257q8dRvczJBmULthk5lhbOYONqBaBqRtcPOWMsODiHOfS/WTqnniWQdKskWabNNuR5iBpTjalYclvjpbtn70tu1z2nLTYLXFjolQQcTjKnZWc5AcffNAcF3Xp0kUuueQS8z0WUXKskEq55557DEnm9fF0JPMJTGqFoG4RrdCKZwQh4nlCisKyZs1XkuzihB28j2bRiFGCCEuBwtXnVugY2qazfKffSBnRvrvURiJSETcnOYqJBpGkVp2KiiTnGlVd2kiL3h1l68I1UrJpp/nYsk+9HrMGYWwLZepfYilnhJHP6uIc5+qcko02Uq8ALky07Jx2FKBmzpxpOIhNmpMVRijv0NN83D7n/YyT5LoCEm1IyXHv2muvlWuuucY8NFW2gGgFO1KhEGRAvlg+6iQrKWZ3Cjgm1wHZlNVvsZLkMB3lMt1O22trZPamNbJ7u9i2wvbRnp4W2AoUpgLfkuVL9H5cgUv3kg2UlpTI70ed9KXTAN307KiulpLPu0ptXe2XFqzO37hDOp35WykWuDKOiSZDjsHaCfMakeQg4ilnkM9Kn9djeT6S35zKOHClbVyOQOZiI2HntONgHLRQp2ha24m0V/pAU+t41YD9ZfO4J2X7nHEZv/9IosobhSoBR8SYRVYTr3mAWsxDtJUFt9C0UCHJ+RBJ5n8zoWpOMZfmQ4G99tortChEGPAkOfUFbdr6FXL0m3+R5mUV8tmJV0hZSWkjBQolxfQBnXTDTKFxaYF14V6yOabsSJNufNTOvbzsi2l7y/at8tprr5nv4yTXkOuYJSLi0jyTa7TZtbtIZZlIda1x4Ks5dg8pb1mZknIGz9smTKy3mprBlazCkCtwYRy7SNyjWaijzYx9+tKlS02KDhxFo8z0geAcXzVgP/Nx+9wPJUKqRgbfU12xq1t89NFH8txzz5mdC7lURDH4SOSYSfv44483ecuFFHJ3tXBPowwaRVJSrPaZHN2wKELwiSKnGnEoRpLs+j0NadNJyktKZVX1FnlryWwZUtKyoR+osD2TqvaBMN+XS23k0r1kCsGNz5q1a2WxbJeFFTVyXv9RMnz48IbTgFVLVzT8XvsOHWTQfnsaQsVp0twpH0hJq87S8XPCnCyhSvaePaSRA1/5gA5S8+kqidRFZN2kBdL5gMFpHc0TZYQwMe9ragYmXxAkfb7xlDNcTLdw6X5cJXxwLdZynvO+++7b0AeYH5Q0Mx/Y6RnNd9nVmIpEqjdLzep5UtFlQEFvLHJCkrUDT5w4UW6++WajBwhoDEjYqlWrDGHbbbfdGl5fKHDFTERJsW31zPeaIkQuphC4TpJduy/7flSBYkyLrvLChgXy4KS35cruI00fUAWKTE9SrrVPoYFnrKSYj2qDa55xvz7y9Vfvlu11NXLmnvtLdztdxnoupeWlX+i39u4t85/4ikRKK6TmjAdk4cItMnXqVDNfQKaINGdTVSFbcKmflg5sL/LpKvP52o/mS6cxg9ImYBAmJcR26o2amqhyhkYY7WfsUtu4SpJdvKfgfQX7AKRZ5w3M3z755JP6vPcO/aVs5XTZNGeCdMggSa5zcGORVXUL3Pa4giC6/Prrr8txxx1XkDnJuUi30AiSEmI+Kinm4viNRa4pQqQ/d21SdJEkK1y6L90cYQPPxEdhD8TnqA59DUn+MLJB9hyxV0PKRabh2gTo0rNKFcwvOsZ5xox7yA2khsBDUOFgVIce8t7qBUYveVDrL/JbS6zpp6Tsi9fvWDJN6raslZLKljJo1KEyuLz+dExzXadPn240sZlXNDUjG5utYuqvJa2aSaRLcylZuU12rtsim+eslNYDY9cTpBrQoQiby95s8Yxt5QxIM8/fpbHjIiF18Z6aui+4F2OYS+cWwyGmDBFZOV3mvP+8TN3RrVFee5gnSpFijSTHgqZVnHTSSTJ27Fj55S9/KU8++aR5MIUSlchW4R6/CwGy0yfYFSopxjc+FXtLV6OjLpJkF9pKFSg0kkjhHYsfOcUcsdIX+HrPulq5YcmHsqJ6s7y1cp4c3jVz0YEgXHtuLiCZNrENBlRphoUqUZURpOAgye+vXijn9Btp/eHoJFld9qoGHiAl5fV/l7+P3jWXrapAagY5j9yjneuayyLfQumnJf3biazcZj5f+9G80ElyEEQQ0bjn0mespJnPiTIuW7asgTDlsmbFRULq4j0lG62Fh1Go3/ygb8j2Lj2ky+7Hyvauw0w/IK95ypQpZmzb6RnpkOa6Akq1bQrliU4+NIp6lhcKMbbBYpKJSLJNijWKBEEiasQimSopjnZfwDV5Ok+SoytQQJi0EIMKd77PYgdBDkrBndJrN3lgzgR5esGUrJFklxYNl+6lqbFOpFafMRfQgko16knGVARAkm1gWBGVJM+sNxGJJ/0WVFWAxEOYKRAj0szCaZPmeC5xrpFTZ7BLSylvXSU1m7bLppnLZefGbVLRJrPmDtGeMWlZnE6hcAPUPlmVM5Q0Z7uOxbXx7CpJTiVa22rEyeYCrVG66dy5UYoOfYDNMaSZ526T5mQUkCKOtlkmEJXt0oj/+te/zITO0Q0NzDVu3DhTaXvLLbcUXLpFWDnJulDakWLItx6rZjKf1FVCqioprgyqbESSoxVcxlOggDTHup8ze+9hSPJ/l84wknBVlrpBJuFaX3IRzIt2XjFH32yAWXSo6UjWitbG6M91T+dtWSertm+RzlUtY0aSI7U1sv2zd8znLRLUR7ZdwrRATCOQkKnJkyc3uMRxrBvNdMYVuDK3mHmurFTaj+wrq96aYfLH1348X7oemllr+XhgnmHOCSpnqD6vKmfYqgmZgkvrgMv3FPZ9BVN07Lx2HevJ2KjXFWtOsj4Uij2uvPJK02B8j4mRnSlGFLfddpt89atfNa8vpEZKNCc5mG6hpNgutINss/CwqLCLZ6HJxuLiKkl2cSIK+16Cm6NkFSji3c8+HXrKTXscJcd3H9pAkDNNll16VsCFfq3jyybFnA7wXOO5F6aKds2qjLHIjI2rZPzaxXJ89yExc5KrF06Uuu0bpbRFO2nWa8+U/h/3TeRJo0/qEsdF5ImF1Zaac2VMu3Ifdj/tAEl+e6Yhyes+ni9dDh6SNVm+IOy2iaecoQVgiSpn5Puzcvme0iGidds3SfXiqVLRsY+Ut68/RWiKNMNZdE7TYlDmtQ5WpNnuB8lEuV1s27QL98g9jndsX4j5KJqT3NSA4We8DhkWJcZ8rZFiHJWyRYpdNzpxeYCEsaFQBQolTbo5oh9wnJ1sGk2s++Fevz94TMPXtZE62fulu2VIm85yVt895bhdhmSEMLvSl3LZh+wTAcY8qVNE4jRNJmwSEW2DBEn+cM2iBpJsq1soSdZUi6pBB0lJGm6NNmyXOC0wJjVDVRVYB4hEEqWEOGdKai7fQH8lvaL1kG6yacYyk3axceYyabtrdMKSSTS1ngVVEwgU6WkCpiaTJk0y85i+Jl11FBcJqYv3lE5x3IoHvyNbJ/9XOp75W2l3xCUJ/Q5zmG2jzlpGP1hrkWb6gRJmNlcutlkmUB7vARElIVeNYxkahF0HC0MhToZ0klg6yaScaHQQGTw6iFaJk2cIMXLhGNL1SLJLSKWt6B+qVUlfUAUKJo10+0Ey9/PR2qWyYOt6c728fJa0q6iS03rvLmf3HSF7tdsllMmrWCbAWOPdjhbriQDHkRBHDHuy1T4XDNhbTu21u1G6UDSKJJfXL6LNBx8sbY/6oVT1HZWR++D9Elni0mP7V1991awFGoHktFFTMxgT2axdcaW/2mMYBz5IMljzwdyckeRkwDOLpZyh6ijMc0qaGRfJBgJceVYu31M691XZc7ghyTsWT0mLD9kFv9Wf9wMuFJhY++gP9AvGelNFyPmMmLMYu4cf//jH8tJLLzWE/Wmw7373u/LDH/6w4IiybSZCTjYLJO+Rj3QQyJAeNdIWw4YNE9fgSXK4C4geReoGiU2jup7ZChTZfnb7dOwpHx1zqTy1YJI8tWCKLNm20eQsc+3WtovcvMcxcljX/mnfk0sbm0zei8onKSmGCOjmBxMPrSHAHpbXZHNB3bN9vWKBjZIokeSq/vuYK1ugPWgH8q4hzpqbzaJJ3YqSKU3NCMrbFTK0f7Tq30UqO7WS6tWbZeuC1bJt+QZp3q3eGTVfCGBQOYPnqik4bI40BUdJc1PKGS7NKYVKkpv1qPex2Ll8Zmj3UhnoB2+88Yb5HG7EeIc0B7W6XQgcZowk0/nPO+88c7yGNvLAgQNNY/zjH/+Q3//+9ybSct111xVM2gXyOFi7UjwF+eVrdKK/973vmTxDJniNiqDuwUThIjxJTq+ttKhFCRP9gclB0yf4mEwFcLJIZgEZ1Lqj/HL3I+Rnux0mb66YJ08smCTPLZkun2xYKS3Lvzj+X79ju7QqbyblKUoKuoCw78VWGuHimZOHqZsf5w03Yugk5xIqX6jHtUqmSM9Agoo2t1UzwlRUcIl42fdiVCT2GSDLXphsvl7zwRzpebIl5ZeH44dxQvE5l6bgKGmmAAwoUYr2nF0kpK7ymFTvq6LLQPNxx4rZkkl07tzZBIrs0zcuPXEg0DB48GCTrpXPiLoSLFmyxOwOyEdS+RhwzTXXmE7/xz/+0ZBkIm0udq6mAAF65ZVXzG6Ia9asWYYMM4Cvv/56OfLII81Azxcimimjk0JPt1CyFEuBghQKThOyMamn+j8wFzmi2wBzrduxTV5YOlP2to7mr536iry4bJbR2T23/0jp3rxNwn/btWeWKmwZRk2j0OdMNAQjD5dPxsatXmiUTfbt2EtO6rGrlATULbZOf10kUidVA8ZIaWWLrN1XvP4RJFOMMwizKipwnGuT5nQ3ny4RL/te2u3ZW1a89qnUVe+UDVMXSbcjd5PylpnbaAeRSVJqp+BwoqCSgkHlDJs0u0iSXZ3nUm0rJcl1m1dL7ZZ1UtayfcaLCquqqhrqF+xNciaDSjklyRo9I8cs2uSnWq6pHjWT00Z0IYiLL75Y7r77bjn33HPlkUceafQz/MuRoFMQ2b7qqqvkqaeeMg/kiCOOkHvuucdMyk3hww8/NDJ2hx56qFHrOPjgg2XFihXmaPX000+P2zFdLI5z+d5cIsm2ji1H7MjeqLshEzn9OtuaoWFuvto3ay5n9d2r4eu6SETeWDlPlm3fJLdOf0tun/G2nNh9qJzff7Qc0qVfkwWqLiHZttHCEyXGHAurNBtGHvGURlxrkzdXzpM/fPa+nNF7syHJEtBJXvf8LUb+rdNZf5S2h1yQ1XtLpE14DUexXKi8qKUui6hqtmpxmFpnJ3NU68LcEqvYqqxZubQf2UfWvD9bIrV1xqoapYts3k+2YEsK8pwhUkHlDE5o4A2c1jalw50tuOoelypJLq1qJWXtukvt+qWyc+VsKeu3d9bbrHnz5oYwu7aOhEaSMbg46KCD5Be/+IXcfPPNhlgwaUEqnnjiCTnttNNMtJldI4SaAZEMxo8fbyZKxbRp0+Soo44yBFVx7LHHykMPPdTwdXAwkRdNKsjTTz9tBhuSdSeeeKJ89NFHTU6wxxxzjLls6EKaiLqFa9Fal6PcuTY50YIDjRbzjDXHFFJMJNGVCTLsZ1daUiLjj75Ynls6Qx6cM8G4tz27ZLq5SNe4YsiBjUh1pu8nVSQy0dr54zxvW8LIpeLaVEAOOkDhAtiRZJFa2T73gyZNRFxC0FKXDY0e2bMW8HUyea7A5cW44z79Zc242Ubfeu2EudL5gEFZTZPJVdswr6oawqBBg0xggufL2FRtXs39V7m5XKQ5uRjdTleLuKLLgAaSXJUBklyXxL252LbJoDxWpyH39v333zd5yLvvvrvJz6RT05Fff/11+ec//2kICMcsEOdkoDqciltvvdUQlkMOOaThe5BvrawMgnt58MEH5bHHHjOpEeDxxx83eaNUXAcJcCLQYwEGcrzdrYvRWpdJcrbvS0XS9dKCAhZdmyy99957JmrsCkHOVBtVlpXLab12N9enG1bKA3PHyzMLpsisTWtk7ua1ce/HZegRvpJi5gQ92mVO4nm7EKUKA5iK8DTmb1kvK7dvbpSTXLNmnkjNDilr16PhmDXfwHPSoiBNjVGpOXWIC1pnu4poY7hZu5bSZsgusvFzObgNny6RdsN7Ze1+XBnLEGDmXD7CKfS0J1jsqYQ5W+Y1LrWRDfWoSAVtDr5AWo08RSr7hk+QXY6+Z40k02FYZL7zne80dB4WIFIa+BoiSTid3YRKxaQKBgoE94orrmjUUd98803ztxkokOebbrqp4X8RLYYMHX300Q2vJ7TPwBs7dmxKJFlTRyD+8RZXH0l2iyRHiyDaChSxirBc21BkY5Ie1raL/G7ECXL97kfKXxdOlWN2GdTws1eXz5Y/fva+/GDwGDn8c2UMl9rHNmvRkwHmH54vm25qCmwHw0JCm4pK2bVNF/l040r5cM1i2cV6LjtXzjAfmw89pCDeO++B8cvFZodnzAYIIoVO9aefftpgdqFScy71UxDtOXTcd6AhyWDNh3OKkiQH7ycoM6Z5rIxv27xGSTMEOhPvxbU2CuO+Wu9zhmQKEcccdHNCkumQRJCzgX//+9+G5JCHrDjuuONM6gWTJBFt0j4OP/xwQ46J+KLdzABjANmg2IqfpQIlxrG0kl0lV/lA4MNsM1WgUKKkKT/0BdKEEjV3cPE5Zut+WldUyvkDRjf63l2zxskbK+eaa/e2XeXEim7y7bqm8/szCdWlpnKa4lpNlVEjj2TNWsJArvoMKRf1JHmRnFz3xXveuXSa+dh8yBencIXUFlpgyYXKkm12oXqtbIJJreH7yer2ho1Y5KFFn45S1bWNbF+xUbYtXidbl6yVFj06ZOV+XEI8chVLOYPnGlTO4GMqdQXJ3lMu4fJ9gaKOJMcaYLEGXDqNRdoEpNiWCTnzzDMbPic6PHr0aEOYn3/+eTnllFPi3m/KOTyfR5KbIsk+3SJ5pDPQ1fFMSTGkiSMoJkmOZ5HsY3JN5Z5cWkByfT93jjxB7p31gTw672OZtmGFTJMV8tCHs+SSIWPk3H6jjEVypqFRQ9vyWZ8t0SYKfp2WZssgMBN5eN7HMnHdMvlq7RdzZc3KT6Qsh/nI2V7Eg2YXbKBQYWLe5iMnS7aaQlhEKl2YlJF9B8iS/0xskINrcUqHrP1vV5DoGp2scgYfU03DcZWMpiNNF9lZLdWLJkntxpXScq+TQr8vkOhzzHeUJ/MGw37DKFyQQ0x+czxAhhgoRJN0wVSvcTuazOAZM+YL+95MRZJdjNa6QLTCuC9brksvvqfHbkSTwlCgcG3w5vp++rZsL7/e61i5Ztgh8tDcj+SP09+VFTu2yi+nvir/XTJDXj38/ND/p0aLlBTrBohnjfQkH5EW4gSJxbJYCTLASREs3rZBSmotg5HarVLeqZ9UdOwjxQj6B+SIi/QqNtTkM3MRaYZIaWpGGFJz6RCutsN7yfJXP5HarTtkwydLpNtRu0tF6+Y5u59cINX1KRHlDPqCbo4gzYk+a9faKIz7qt26TpbceggNJ/3v3igl5eG54UV8JDl7QL2CiMAJJ5wQ93UMBNyu1O1l1KhRZvJD6/iMM+pzb5CUoXIWSbdUwOLMrg3ynY9E1OUod1NtZtt+qwIFk6GmUGTiWN3F5+jC/SAjd8XQA2X/zc1kYsU2eWTFJ/Kd/l9YHVfX1sjmmh3SMUU9XnL+lRSrFF9TEnyuLGC5vI/d2naVT47/ofRs3kYmP/Jqw/e7/t8DUtFsoxQ7eDZczBVcyPxBpIJSc2y2bKm5sDde8cZwaXmZdBjVT1a9M9PI+K0dP0+6Hj6s4OeUTBR8RVPO0Getyhn6rJtSzihEklzWuosIplI1O6Rmw9JQN9F1SUSSCwE5C83Q0JDkc845p1HnJRKAUcmpp55qSDGT209/+lMzqX3ta18zr4FAnX/++Ub2TQcAmsnoHKvaRarRZAZbPhJRV4lftPtiI2IX20GS1fY7m3JdLrWVaxNOZWm5nNJ1iFy0x8FSh3bV53hk3sdy7dRX5cKB+8j3B49pkixrYaWSYiLHKvuEiUcx2RWnAxwTe7WotzQuqf3ieVT1GiqVHVvn8M7cBf1KSRLOX6qmQJSZAkDmHTZotnV2GOMw3t/osHc/WfXeZ/Uk+aN50vngIYY8F8vckilCCoeggFeVs6IpZ1DPoP3BVs5w2XEv1bYqKS2V8nY9pGb1PKlZuyhUkhxJIpLsUt/LO5JMmsXChQuN/bUNOu7UqVPl0UcfNYsrRPmwww6TZ555xiyuijvuuMMMDCLJaiby8MMPp0WuiE4nEkl2Od3C1XuDEJMOw0dVoNAoABNWto/SXdtQuHY/9oJWZgTI6vHaijmypXan/G7me3L/nPHyvYH7yqWD95cOzZo3kmZTUqzW3mriwUYoFRMi19oml7BJcmmz4k1BSbZvBNUUbKk5CsSNjfTnOa4Q51RyXJu6F9Ir2g7rIRumLa5Pu5i2WNrvlblUGdeipNm6n2jKGUqabeUMnjc/c5Ekpxt1L+/Q83OSvDjU+6rzkeR6PPvss6bzQBq56FTBjxydYjaSCpBvizahUKjz0ksvNfn75B9hj80VBhi4LN75rm7hwr3ZBVhc9BPyxlh4XNGwdaWtbLh0P7EWsqfHfF1eWDZTbvn0LZmyfrn8ZsY78qfZH8i3uu4mX2neQ2o3bjGvU2vvoUOHOq1rm0/PaPK6ZfLr6W/LOdsqpJ/U51tun/myVIw+OSf3kw/9NR7ol6RzcfFeVGoOhaTp06c35Liq1Fyic1ZT90IBH+RYC/iwrs4UcSxWkhyNV1DnwKW1EEqaV61aZb4Hr9FNkgsFn+m2VXmHepnBmnXhkuRIEWkkg5hhCFIbmBSYKIjyEaHlo9pKctFQN954Y8E0WCKRZN6rq9HaXBE/29hBFSjoJxAlTgIgyejYMvm4AtdIcq4n5GiI1j7c59GdB8io4e3lXwumyn1LJ8u8ms1y75KJMqPdanlsn9PMiU+Y78fFtskFdtTVyn+XzpDv7hxe/426zbL9swnSOkck2RWEMY7pY5xocZEbrzmuRJpnz55tTr84rtfUDOa2aOteIvfSomcHad6jvWxbsk62L98gWxeukZZ96t0Hw4ZLc5wrpN1WzmCDxIaINYp0G543AgGqoJRLA5t00i1AeZv6KHrthhVO3VfBkGQI4zvvvCN7750ZxxYXke+R5GzlS0dToAAsMNEUKCi6dK3NXBvkLvcrPRnQFAo2RDzfIzv0kdP67yXvbF0uv5n5rtww+nhDJMDOulopLykNrZ1dbZtsYnCbTsbauGNNfbpKSe1aaT4kt1bUroyjsO8jmONK/rLmM+txvZ2aYUceE7kXosmL/znBfI5ldaZIcqL3k024dj+AYCDpYFrwqcoZamCjpwr6zDOtkhJGxLa0dX2fqt28KsS7Eh9JtgER0kYJNlLDgyiQKHKiJLlYC/d0kVBSTKRFFShIoWCRiNUXXCSALt6TK/fDfVBwp5qkejLAAoHQf1Be6RTpJF/rtVujxe8XU16RGZtWyy17HC27tk3PldMV5Hpxb1tRJYMr2kplpH6cldSuk+aDz8rpPRULIElo+XOpdjvjg4tIM5FHyBNrZiJRxzbDekj5K9OMTfXGmctkx7ot0qx9y4zMJ7nut65HIYNk1FbOALZyRlAlRU8VUqmzSOS+0mmrFsOOkNJmd0qzHrvl5BlGHFnPQo8kNxTrlJWZhRK4KskUJpLJSXY53SKse1Mdar1UgYKJg4WCiGGiRZIuElLg0j3luo04btRIMc+b50+0mGediDa1/bN1O7YZFQwK/Ma8ep+RkPvZsMNSlo0rxPkmVYyq7CwL1iyTxetXSL/262TY59GiYka2x40tNYfBjR15JNKMBNmKFSsaUjOiyY+VlpVKh737y8rXPzWnA1hV73LMHgU7v7k8npsio/GUMzA1YWMUSzkjk/fVFCp77WmusBHxOcn1YOBDjIoJiapbuDz5pHpv7JZVlk0VKNgts0tOV4HCxTZz8Z6yCX3eSox1otdNEEY/LPJEjlPRWn73qO/JL6e8Is8tnSEPzJkgf1s4VX686yFGOq6iNPkFxJVnlcv74DmNv+VeeWrSlIbvHTV3nlECsk2VihG5JF525JFUJE7YmDshzCo/pqloKjVnfmdUX1n19gyJ1NTJuo8XSJdDd5WyyoqCJqUu5CSne09B5Qx4kp4qoMwFh7CfN/NqKifuhSBNV+LYsw41kkwH4IhJG4VFNdeKBNkgyYnqJLs42JMhfkEFCiw/ed6aPhGmAoWLhNS1e8r0/fC8bWk2fd5q4sGkbh8ZkkeeDga06iBPjDlT3lk5X34y5SWjhPHTKS/Lo/Mnyl/2OVV2b9c14b/l2jjLFdCGnztlWqPvvf766/Ltb39bnnvuuZzck382XwbBBJRduAAbUCVRbD4Z5w0qCsO6y6Ypi6VuR42sm7hAOu03sKDTLVyacxXpruXMo7Zyhv28Sc9g7k3FKj3d+4rU7JTtc8dJ7eY10nLEyaHWh5Q6SN6zRpL1zevDfe+992Ts2LEm9aJXr16y7777muPXQgSkMJFIMnCRJMdT3rD1ayHFEGRbgWLYsGFGJqcYCClw7dmFfT+0t2qDquUz/yOZ5x3GMzuoS19564gL5PH5k+S6qa/Jkq0bpFOKaRfFDCyWX3vttS99n3kZ51HyYrM5L7s2nl1BtHVBbbNZP/k5G1TN999UskY0Y3/l2M+k9V69pLKqsqBJskv3E/Y98XdITeNSaUHWXU3FiaacwTwc7f+nT5KrZelvjjKf9/vDaimpaiWFmleeScQ9P//e974njz/+eMPREcdE5GDdf//9Bal6kWjhnquLhE1GdUerpBiSBIgYkluFAxUTdzY6u6sk2aV7CuN+NI9cibFt7824TUaaLcx+UVZSKuf0Gykn9dhVpq5fLt2af2EK9MLSmXJUt4FNpmC49KyyBU61IFLo9r7xxhtxXwtRZuHlWRfTApZvfYNnw5jk6t+/v9SOrJU5696RHQvXSd2mann/n69Ks77tGxWFpZrf6mK7uEiSM5nWwHsl3YJLlTNUj1uVMyiC1udtK2ekS0ZLKltivScSqZO67ZukNCSSHCn2SLJ24rvuussc5f3rX/+SY445xuyCiSg/8MADcvXVV8uTTz5pIlLFHEl2DUSVyCX+5JNPGilQsHgmS5IKmZC6ek/J3g/P25Zm0zxynjcmHmEVkIQFXPkO6dKv4etXl8+Wr499Woa16SK/HXG8HNA5uvOYa4tqplNilixZ0mDCo5t3nmc8sOH98MMPGyJZXbp0MWlTzGnF0H6uvMdkSSDjc5eDdpUFT4w1X/fa3kqa9+1rSNS0adPMegRRVqm5VOZwV9rGVZKczXuCXPI8uTj5UeUM5m9bOYM5nJ+lU4jPeyqtbCV12zdKXXW9yVM226ukpMS5Zx0qSX7iiSfkkksuMQSZgcqCvGzZMrn++usNYeZzSLKLnT6TOcn6Xl1QuAgqUHC8zntgAFJ8pQUiuYaLhBS4dE+J5qhBhG3LZ543EypHe8m4grnQPltqdhji/OnGlXLcWw/Lmb33kBv3OEq6hhTxCBuZmOcYw0SUcP3CBUyjWiyUzLPkOeozPfLII01EWVWHlGQddthhctZZZ5kCooULF5pTP2yWUVkgpYrNEn8H4uzSpqkQx3EqaDWgi1R2ai3VqzfJtkVrpXtJC+k+vF5qjj6h+a1z5swxfUMJsx7Vx4JPt3C//0RTzlC5ObjIRx991BDoSuVkwaRYbN8okerNod1znaMFhVlPt+AhaeEBDaJue0AtHAsNiTru5WpgNaVAQa4bRJljPJfgIkl27Z5i3Y/qU2vaDBMUz5tFMpMpM9nY+J7cc5gc2PEWSNEAALDvSURBVLmv3PDJ6/LQ3I/kmYVT5H9LZ8pPdztUvjtgHyl3cCJOt89o5AhizHjV9C6KfyiWhhjH0hx/+OGH5dxzz5VXX3214XvD9j7AfJ/nBWHC2ZILIk2fISrNnAFxBryGvsOmKh3rXZfGDnAlUJNK0IjXd9xvgCz97yTz9epxs6XX10Y3cobjVMA+ql+8eLE5MdTnqVJzdvGtJ8mJPzNXSB8bYi36JBC5xx57mDlCTxY4XdKTBZ53U4Gw0sqWwpa6LmSSXOLIeMspSUb6iYmcBoEc8yCIUrz99tumGl53PoXUWInqJGcrksxCx0KqBImjWFWgIH2CCJEdOSQH2bXFy0VC6uo9cT/28ZueDpDPxjNnTKYqJ5Tq/WQaaCffOfJEObvvCLli4gsycd1SuWbyS/Lmynny1wO+kdV7yQSYJ3iGkFUWOj4HzKk8S07jiPAmIq9IH3j22WdNkd4PHnlD3lpXJaedcVBU+TeiTXaEinkNYkWOM/eCegn9iON7yDl9KxOGCB6Jod0evWTFa59I7badsnHaYtl55G5S0bp5k0f16gJIYSfzP6RJc1tVncoluHjy7OI96X3xDBnDamKjyhmanmErZ/AxmI5TUll/Kle3Pdx0i9IE1qB8nbOD+NLMrA18xBFHmOMdRNGZyNnZXHPNNeb45ze/+Y0hyoUGCGdTJDmTBIsOr8fpQQUKjkv5GG/ic5H4uXpfrtwTz5yNEOMMAoUVPNEhlWbjmaeqT51P7TOqQw95/fDz5dF5E+W6aa/Jt/uOaHQv+QTIC1EgCCnjmc0u74GoP5tbosXMNaludiBI+x1SIm+9NV8WrU9Myx4CrLa7WnEPUWauwRCBi3tSV0UW3UTzDnMNF8ZxuoSrtKJcOozqJ6ve/UwidRFZO36edD18WNzfYV5gg8UFmD80NYNnq2k5BLeINjdlCFSshNTFe4p2X/GUM7hQztB0HCXORJLN3/KR5PBJ8oUXXmjC+0rKvvWtbxnixvc1DaPQkEgkOUxras050/QJVaCAGKWiQOEK8cuH+8rVpGirjqg0G/2JiY9Fb/To0U5GgLIBVDBw5zul127SpvwLGawXNyyU2u1L5aoePZxMwdCNDpFanqembEE61ZCFU58wTwB6tavvI4s2bE+54n633ertaiFTbNIg9kQlUdNQUg8BY0GmT7pIJBSu3Fs681yHffrLqrGzRCDJE+ZJ54OGSGlF4vmnbK7pa1zcB8+SnFby3Yk0s77Z+cy2tXwxPqt8I8nJKGcwlqdPny7tuh4ibbqPkfWVu0j59u2hrC0Rh9JTsoGYISoG0qGHHtrw9fe//33zkUXgxRdflD333LMg1S0SjSSnmm7Bbt8utiPqxAKaikxXPpBRV+8rm/ekNqZ6QkAf02dO/jg5h3yfaJ4rBDmXz6xtxRdtsKZ6q/x+5RTZWLdDnls/z6hg7Nept+Qa5IqTtgAR0TQnTV9gHDM3ZtJ86djBneSl80dK/w7pa5uTmsFxrh7p6nsjEs6RLkWAvIajfE60SM9wkVS4glTbhvSKtrv1lA1TF0ntth2yfspCE11O9R7YeAPkWtkIaW66qijQVzU1g8hjNoo6XSSkLt5TKrm/wXQcnvm6daPqTxbWrpVpb75p+oT9zFNJsYo42l6ZQkLnuEQ7iTS8+eab8tJLL8m///1vIw3HQlBIlY6JFO4lSyBs7VqVddIc07AVKNIh78VGkkGm7kkXJH3mHLmzIPHMd911V/PMgwtSMU06yaBdsyq5sPNu8qfVn8jUDSvk6DcfkrP67CW/Gn6kdK6qJwHZAJtZosUcb1KXoSo4RO8gjkTvWICyNRfu0qbSXGFDCwBZZLkYI/RhouR8xHaXS6OQtEeuZeZcmlvSvZdO+w0wJBms+WCOtB/ZNz2t3M9/l/lGyZGuS3pMTwGgXRBGgIw1KhPP1EWC5eo9gXTmE545z5ILEJxhTdLUDNYlnnOymtx1CXK+gpWAU7AIMHDQ3vznP/8pzz//vCHLxx57rBGuHzNmjHldoRDkRCXgmkq30MIrvWgz1T2k6hyClKkcU1fJqIv3FeY92W6GKs0GiUjW4tulNnLlmZGC8bX2/eWk7kPlgXUzjK31EwsmyfNLZ8jPdztMzh8w2rwmbLAQMHYpXmZRIbpqgzFNRJV0hGyZ8uQCvC81KaFfq7mJajhPmDChQbKONDxSM3JRAOhS+6dzL827t5cWvTvK1oVrpHrVJtk8d6W0HtA1dPLHfESQS2VcbStlTg70uWtqBn28kAmpi/cE0r2vHUs/ldpNq6Wi60CpaNe9kV26KifZyhmccCppjhXAqyt2dQvtMH//+9/ll7/8pcltOeigg+Tmm2+W0047LSdFRNkCEwfRomQitmrooKQYskQkhkWFPCE+ZmvRcIXY5MN9pXtPtuUzzx3wrCFNGD/EshrN1P0UOtqXV8pdo78i3+43Uq6c+LxMXr9cfjTpf7J/p94yvF23UP4HkRa74E4XAyLEjGWixWx+VLuWo2uKm0mR0QhcNgstn5y0TGau2iLn791DercL31KeRZP3qOTJSJV17GgizHxkvqQtKBLT6BSXarVrAWAhajNnknAhBwdJBmvGzckISbYRLAjTHHvbFY4+roSZK9U1zcU5zsUcW+UX6faltc9eL1smPiudzvqjtD3kgkY/45naaVZ24eeCBQsa5EY7fv7MNRXUxfbKCUnGbQ+poTvuuEPOOeechsKiQkYikWQ6Dm1ENIXcLjV0SFSBIpMIq6AwbLhIAJO9JwiUnTbDLlwtn1lYmEDSmThc25m7dD/2vezTsae8ecQFRld50dYNjQhydW2NVJYlPkcxlkmLIf+WcaypVhBhNjuQvGjRFFu7VvWIWVgojmKhsY+tM6kocNfYRTJ52SbZv3e7UEiytoduACDAehzLe412BE87kEIEaAuKxCBWtAmbDUAUkkJk/kaym8dE4Nrcki7aDOkuFe1ayM71W2Xz7BWyfdVGqercJmvtQn8nosiFwo5KzdEv4ASTJk1qJDVHf0907nMxautiZDSMdIvP/1BCL9NCXS7Ud9S4as3npJnnzr2oEyAb5HjPkp8VyriMqW5x3nnnmUnyhhtukNtuu80U8Z1wwgkyYsQIc0TDACo0RLOlDipQcLEYcDzFsUUmDR0KgYy6el9N3ZNWCispJrIC4VHjFvp/mJtGF9vIpfux74X0iv8bsHejn8/atEaOe/MhuXrXg2OmYPBMGd+QYsgchJa/S6STTQ7RYqIqyTzXoB6xHltrlNlWFEi1UCYWerWtNCQ5FYULBW2g0WL6uuYx0hbJRgz5XVJQuNRwSrWZkSEjOsVrVB+aoEJYUWYX5t+wxkxJaYl03GeALH95akNuco8TR+SsXYJScwQIlDxRAAhpUoMj+kw8kxoXSbJL81zY6Rap/h1ez5zY+vNCZHs9ZCxzysS6qFJzrupyZ5Qk77fffuaCEJKP/Mgjj8hll11mBsNee+0lV1xxhfm5i50+XXULpFNQ8KAqmPdHG0CK6BAsHhw/ET3UhdEVuFy45+J92ZOjboaUFHPZGtXDhw/PuGSSS5N1vo3pP83+QFZWb5GrJv3P5C3fvtdxJhWDBRxCzMkPGx3VLFa5LKImfB7W8aEdjaHPq8UsFtEUvNkRuHSLoxpk4BLUSgb1Fe/19wQ5hiRrHiJRw3Rc+GzwNxgv/E0u+jaLLKkZ/H/mUC5ew/9mPqVtUvnfLo0bEEb7tR/ZR1a+OV3qdtTI+smLjGZyeYvk5p9Mrc2QIeZELjviSH8i3YZ5U/t4kDy5yBcKOd1i5/IZ5mNZm/Rke0st5QwIMvcF/1HSzNymyhlaw1AomQflTT0odvpf+cpXzAVQtrj11ltNoxQKSWYBJb0ENysS2HmPw4YNMzqi++yzz5dczmgT1yZmV6ORrt4X96SmD0qMNSLCxcKeTfF9F8eQK88skba5dc9jZUibzvKraa/LlPXL5fg3H5aHux4orT9XdLRTorKVJ6vC/qoooBE4yARRVc3x1QhcspJxPds2rZVsF2Xxf+nnqt/MiQgLWjYWM96rHuErWSctg7kX5SQ+5zVaAKiGK8loxLuAsNbDssoKaT+ij4kiR2pqZd1H841ucrL3kmlEizjqJkzJE89Ux4GrqQ0u3hNI5752rJgtO1fMEiktk6rBB4d2b3V1dWZzG005g4vUDDZObHyx1c53xJ0dlRhSjEbEgUnrq1/9qrmCr8k3vPvuu/LXv/5VXnvtNZkxY4aJjkOGd999d0OW+TzfIqMuklGX7gsSrNJsLMwMbIgL5IkNUZhyfKnAhTZSuLZoxAPH+pCsvdaK3NV6hDy6ZZ5UlZRJ96rW0qV3fW5xJjWLU4nAaXEUxBUyweZc839Vgqupvtj780jy4gBJtnNI+fu0jx6Ho7DjQnoYmxSIsOY/sr7QDpr/yGWf5BC1KqYCQNBx3wGGJIO1H82TTgcMNqkYySDbzzm4MWSO1dQM1ln6IicIbIQS7efFSpLTlVDb/MFT5mPzXY+QshZtQ7u3uigbHYIQQeUMnr1r7Ro6SWayJe3gH//4h5lsIRGHH364yU1O13Xvuuuuk+uvv77R9/ibRBa0k/Dz+++/3+xM9913X7n77rsbXKIAYf+rrrpKnnrqKTPJYqV9zz33mEWxKWgu1a9+9SuTb82g/uMf/yjPPfdcXILsEukLwhfufXkwqzSb2nyzA9bjIJ4haRQuwMU+5dL92PfCc+WZQox5pmoABAkdvEsveaTXGBO9cplU2cVRqEWobi3zLIVRvF9bgitavl+vzyPJC9dvN/1cc4vZCJJCwu9SVJeo/mmuoEVDqMIALYZUB0PSZQDvCbJMhMo+5XGtn4ZFDJq1bymtBnWVzbNWyM4N22TTrOXSZsgueUX+IE+apw4ISvEM6a+cpnCPdmpGLjZwLrRTJu5p84S/m4+t9/uGZDs9pbKy0onARMZJ8k033WQuSCS7fI7tJk+eLG+99ZYhrCxE6QDC++qrrzZ8bU/kFAv+7ne/k4cfftgUx914441y1FFHGVcyjnbAD3/4Q0Nqn376aTPArrzySjnxxBONFWdTi8LFF1+cc1vqYiBa2bwvjUjZecUAksAGDMLAQguYoDkScgWuTdKu3Q8kknxHiCBRCi24Y0Ori3A+58AFdWttCS5qJDTfD+ILsYZIVu6ol6tcuqFaxn7woXTt1NH0c+ZV7ef5iGAxpBZbcvoDcWYtYg7WZ+/iEX5Y6Di6vyHJAKvqZEmyi+CZ8ey0n6sVOv1cj/GVNGeDaLlIksPo092velk2j/+7tNzzJCn09sqJBByGIZiIPPjgg3L22WfLpZdeaiZq8pEhzffdd5+J4jJZpxqlYFHTHWbwHu6880752c9+Jqeccor5HoWDLABPPvmkXHjhhSaCxL099thjcuSRR5rXPP744+b4DuJ9zDHHJH0/iZJkl9MtXL2vTE3Ydi6UOhpy4kEUDsmpWDbfLm4o/P18AU55IEVcjHVtG8gfkUROi4iu5vqoNhOgb9KHubAt1yNr8uchicy5AAJx16EdZP/B3WVQ905OR4vTAe+TgmkuNe7R1AyO8AGucfQV+gab4lwt4mETiFYDuzaSg6teu1kqO7TKWzJj35Pdz1Vqzi50JSBnpyCxOcxEH3e1ndKd28rbdJV2R1wiYaOugDel0RAz9ELeEETjG9+oD9XTKFpsggzcxIkT017YiQwhucTukXQKDEtYFHD8YWd59NFHN7yW1xxyyCEyduxYQ5KJFrN42K/hb5FTzGtSIcmqbtEUfCQ5OYRJSG3zFogxCyYnGiyM5FsmOpG6RpJdm3Sy3T6ao0vEkCN2dXRjTDIPQYg5JeC5IynG69W8o1DJIW2g+ZxcPA/VXla1iF7r1siahVtFtqxrqCwv1PYgomybmwBkyQi20DdUxUTNMfgZQRP6TrbGV9hjhhzkDqP6yYrXPjFfr5swT7odPTxv55V4hJTnaJ8gaP/nmZMeydpsS83FCoCEeU+5gov35LIaSE5IskYl9QgTkqqamUzOepyX6oOEFD/66KMmlYIIAOkUWF0TEdC85GDeM19D1AGvYQFl0ARfo7+fKZLsGsEq5PtSiSElxRAo+iRkgAWQ55+qNJtLbeXis8v0/diaxcjv6eTLpoeIIJve4HGrRptYODmetYvSuMKyz80FVItUiQEbQCJpvC/6erQiJ7s9SEWDWNguWdlUaAkb9nE8F59DjGgPCq2DknG8nlQcZOZ4PcEWIpLMF7yW/sTJZaY3EWG3NyoXyMFFautk3aQF0uWwYVJaUZaXRCuZOYV53XaEY47QTRKFnTxHO5851RQjF9spnWht7Za1svLh70rVwDHS7ujLQ39vdXV1niQr2aTzMOEwQTPRkB9HER/HINdee615Xao7iuOOO67hc4qn9t9/f3PkQloF0nIg+HAT6czpdPh8T7colAi3esorMaatVcsV2aowijuKkZQmg0wsGqq6QNoAZFDdLdUelXkGUhdvTrGjTbpwsmhCtHG804I1dQJzPapKX7fNPFQdgFShRHIy563fIY9P2iAtK6rkR4cc0EjuDSLB79tmJq7nbdsFjBo95/7pG3yMtyFW/WsCL1z8rl0AiBwZl1osq1OmawQpiPKWldJmtx6yYcoiqd22UzZ8slja79Unr+aTdNdnlQfkss0t6Cc8X4JrrAup9HUXSV86PKZ64STZOvm/snPZdGl/zBVO3VtBmYlQ/MEk8s4778g3v/lN8znFdCxEmIqcfPLJ5ug7rM7F4ghZJgVDJeaICFPIouCYVaPLRASYUCFSdjSZ1xCRTpUkBx33CpmMunJfGg1TUszRKYsXEx19IhMyQa61lV2p78oElG77sPjwLFnE9LkCFi+eKcQYspsqcbMXTkilknA7yqwKEVwuFLPZOrLcJ6SWKKe63CVL2pZtqpbfvL1A+rVvLlcf2s/Mo1zM18zPkEMlzPb/4grLOCSsaDFtAvGxo8XpjH3VobblyLQAkIAP/ZK/zf9TbeZ0HREzNX47ju5nSLIW8CVKknP9fDN1T7a5BYETrU3RPHXmGjXuoS/Fk/d0aR0II6Vh58p62cCKroMlE6hLYlPhWv9LBTFXJ3J7//KXvzR0IMxEkOg58MADG6IbYUZpOCZkYTvooIPMYgEJpniQ/GfAgoeqxq9//Wvz9ahRo8yExmvOOOMM8z0iVOiNQuZTAVEKFtqmBrKrkWQlfq5NjkFCqvmntuUzBEZNPLLh1uMqSc73+1GDFjarpAvosSHEjXGtmsWZiNzY9rn28SykiFQEjTJrLnO2okeQUztazLzFPdDX07Wq3rVLS/Nx3rptsrm6RlpVfjFu7ONoAHHQfF5SEfTnGnnLlmSTRot1o6DRYvrGnnvumTFnS9qZKCSXpnFxUsp9EPzh0hQ+7oV2SXYcZGpOad6zg1R1ayvbl2+QbUvWybal66R598aphq7PK2FZLUdDUKdX+7rqkDMP2akZmoak9+RaJDmddIudq+aajxWd+0smEHGMX2Qa5fEekhbPMMnTqUaOHGk6Ht+noXgNx4XsxIlcJAOUMU466STzeyyo5CRDls455xzzAJB3o5CPXSIXn3OcQlQbsDM8//zzjeybFqzwN4k8qtpFpiLJrhEsl6ORNnnSiCLEWCMBnBTgbpjtKJ9r7aNw6dkl0sf12FML7nT8QDaIEkM2SJXJ9iIUPJ61o8wczRJ5ylSUWXV+dZFWwxpNFwozT7hzy2bSrXUzWb5ph0xdvln271PvaBcNasXNxXPjeXGP8+fP/5JldqoW0dFgS9ppbrG6sEGKc2Hio05xzD36zFiH2OBxn2ysVL+ZfsxJRaIFgJkYv/zNDqP7ydL/TjJfrxk/T3qeHJ8ku7ZGZZIkx+vrqohC3+MZs2HWNCTmgGzdU7bWgbrN9UWtZW3T87LIp/SUnKlbfP/73zcdiYWPSYRLI5W602GiJfqLpnEygDChnEHHZRIiD3ncuHFmMgJXX3212Q2iZ6xmIi+//HKDRjK44447TPSISLKaiaCrnGqEuxDULYAL90afUUJMvigkRQvuiCi6ctTrClx6diDWs2Hca8GdpguoZjHPlGdLGoVrua/BKDNRRCVDLJp2PmOyUeZgURF9XvNeUVzJtOLEyO5t5IWZq+XjpRvjkmQbvD/uiwvibisJEF0FtplJstFdla3TNqHf8Pdwz8OqNpo5Si7B87F1qunjtANzF8XibCR4DYSe19DHoz3TTI7fdsN7yfJXpklddY1smLZIuh29u5Q3jx/9z/Uc68I98f9I2+FCPQseoylPPFcwfvz4RqdMua5lSCfdIlJbH6goKa8s+EBONhBzJWNS1Kpx88Ly8i9dRF4hP+qUlAwwAIkHHgKufFyxwESLSx5XGEimcE+1Sl2CDqpcEC3Nf9S8YkgIpEkXRr6nqTMuwLXTABcnHW0fxrjq9BIF1II7O1oDycyX6IJGEbk0yqzH/0SZ+domiNGizHYOJERQI9Ns+DGtyabKxqge9ST5o8WbUv4bQSUBzRPWoijaSqPM0U4G7GidRouJmNOGkOJcW74n2z9oD5wQuXhvzGtsDHnmtAcXr6E9OA21I++ZGsulzcql3Z69Ze2HcyVSU2dylLGuzhcyk81IcjxAgJUQk/KErwLBOU7ESNdkg0Qf1/EPuc72Pafz7CI19RympKxZziLJEYfW1oyRZKILmIkUEwpBAg5ky90uaPnMJkNNPNhgaY4jR1y8ziW4+gxduCdNteKZojlOugDg+UIG2PSweLgWLU4VvA/NZ9QoM0SPTQFFQBplhvRpPi1tw/dZSClyzpTRQSIY0aP+dO3jJfUOfOkiaPKgp0K0CWkZbIjV2p3XKjnm+7QTRNvFaHFaqQ6fR90BawR9Q81uKAKENNA/+Fkia0iqQDMZkgzWfjxfOuzTPyaZ8iQ58fmW0wEKN/nariFAQtAuAFXrbJdzkkvK6uflSE19umzYiDi2+co0ypNdsKN9jwYrhEYrBFtqkKl7UzMHJcYMZBZKomccKxNxi9YPXCSkrt1TrscPRIiFn+NlSKIWptLXIYBsfCCE+RINDCPKzGZA81SJqGqbcEICeWRhdYEIEkkGq7fukK07aqVFs3DJOptdCqnZRECIIYX0Ey6dN5kHIMeFbGai4P0SPeZSwkz/oG0ARle8hnGjBYBhtUlVlzbSvGd72bZ4nVSv3Cjblq6XFj2i5ya7NL+5FEmOd09aZMzFnGdrl9PvSUNlnbMLXtNVRIl1X6m2U8fTfy2dzvyNlLasL9gNG3U+J/kLxCI8hYpkIsmuqluEOTnSFqRQqO0zEUWOntTIAyKRCGlyjZC62I+znZOsUmRM/Dxj7fccIatTGYuBGlWwOKjjmx5DZksNIVtQ1RWNItnmFRy70/c1ysQ1Z86chpSCWGkI2UCnls1k6uX7S992zaW0NNx+rWkl+p41ikzuuZI/7SNE3W1zF426uTbW0oXmn3Px3lU1hUABfURTkxhXupHQUweIV7pt0n5EX0OSwbqP58clyS61fT6Q5HhSc8wBWgCsiijMB2r4E+YckE5OcnnbbpJJRBLsVy4953RQGOelIYEdIYsAi2W8nb/rkeRUCbzumm3LZxYALe5hAkjliN1VkuzaPYFM3ZOmUGjBHacCWnAHESQqSrQw+Hz5HpfaIPO7FDKRk6mLA1c+mDJEAxs/2/pZj1ZjmVdolBmSqMVpXKQhaHGaRpmyGWXu3yGcI2A73YTLTitBOSgaCQjK7mmeNpr3mrerBVH5mKKjG0reE6RX1Upi5Z/rmAGMOaLMnEhQCIgcmT3miL4nG4lsu3tPWf7SVKnbQQHfYul2zHApaxa9XV0ak/lIkuMVAAfNgHi+uonU1IxUC9TTSbfINOq8TnJicG2XGgZ0QWTxi0eSXSVYyRJ4XdRsy2eteueomYUgjIXe1fYqBoc7FmdMedjwMIGrKxkkkKNgPk/0NACCxEVERcklCwQV4iweSphddnazpc+4d/q/kn2ifMkU6UBu7E2E5uZqLrNGmZsyM8g17Ggx7aKFi5A49PITlccLmruoigB/l6gbGzPbMtsFhZtYUPLDRdto/8bJL5n+zZpCag6XbjRVChNfAC5eo6dzmucdDxBiiDJRZIjyRhz4RvR1en5z8X5sMppqP2R9tKXm7A0mfZ45ws5nTnQ9TUsCbvsmWfffW2TH0k+l26X/lJKQ551IAXK/eChPpmGYPCGPTPZ2IxVKo+mOnt1/vM7sarpFIoSU96akmItnCvFhkmYiD1PDNdF7ygVcu6cw0i3okxA1osU8Y9U514JKLbgLg6wxPvh7XHakjeihkiEliPSpXIL7sc08tLqdaDDtEUZOYVBmypZAmzJlSkOUWY9lw44yr9+2Uy7/70yZsmyTfHDJvlJeFvsZ2xJ43B+bBi1OpAgxLKMVW0UA2JbZFERBNDOd25ko7JMSosW6eSJazLwYBqG3N5qATQTpThT/ae675sQSqWSjQUpTtP/bfmQfQ5LB2o8XxCTJLq3LrkaSw7ofu56BucV21+T0jdMm1Qdv6mQlnXSLkormsuGNeyWyc5vsXDlbmnUL13mvzuckfwGtamZH9PHHH5uHzUTGEREi7CyQDGSXOn060ElaJa7yLd0iGvlTaTYlxUz+avnMMyTClelCG9cIqav3BJK9J8aoFlLxbHUCYzImMsUYzXTuMP9PIyXkZdp5u5BmCCFkI1tOd7YOqqaWqKRTWIQn1SgzGxiih/ZiGUaUuXVlubz82RpZv73GmIqM+LyYT2GbqdiSdRTjQYyzYeYDEeeiX2pEXwkzBMLO7cyG7BZtoO1BXwH8b2QBs5Fzz7xLW6iqAv2UI3vuB0dE3UjQdxnHEGedq3Hbq+zSpr54b/FaqV6zSSo7fuEhoHBpbS50khxvXuQEwlaJocaD0wpbas6WEUznvlC3qOy9p2yfM062zXwrVJIcScLR16XnnA6ibmNoBAbrAw88IH/961/NLojFjYfIZMvDpQPss88+cvDBBxuHO3Kz8h06KTbluudyJBnogsyAJDqix3nsbnmO2Y7YuEhIXbunRCcUxh8EkKgTz1YL7iCiEDIWXAhYLo/2IUJa/R90urM1iMPK2w3KNtE+jGX+Prn0uU7/CEaZbVvmyZMnN9gyp2rcAcpKS4yRyP9mrpZ3F6yXvbq3blRgBiHVArNcS9YFzUyCueGsN5obHmaRaLR8a6K2bOD22muvUF0Gk4W6+7HJ5FLXRnWy5H6BPkPGVrs9e8mKVz4x318/aaF0PWK3L71fl4iKa/eT7XtSlRjNV7fnLDZFQEk14yGd+2o54quGJG8e96S0PeSCgt7oZBpRV47nnntOLrroIhk9erTceuuthgjrZKYgGvLf//5XnnzySfnwww/lsccek3yHTsRNKVy4RrAYbJpCAQkh8q9RIoxesmlskA/t5eogj9ZObMaIMLFYMpnyOYD0cSLAqQ4RJldzgKM53QXzdjXKnAxJsck37WKrKkAyXFZVYJ6x3d1s4w4iTGxydBMBsU50w7N/rzaGJL84daHsVbvAtEm2o8Vhpe9om1DohsGDqoxoxC3RNglG0O18awoRXZDwiwY2MIwLLl2TtACQuYAgVnmNSNcSkZKIyLrJC6XLYcOkxFI3cW3OLXaSHC+YoOk+qozCek4foO9rOlIyG8VW+5wpa/7xU9k+533ZuXKOVHSJbTqTDCKf9ylX6ysygfJYC9sbb7xhjggUmoKguTJEjrl+9KMfGVJdCGCw8N6bIsm5Trfg/uy8YnJPWTgYSEQdKLaBMLgCV0mya/dk5/5TbEeuIqRSC+6YVDkKJlrMhJlvE1UwZ08jqpCXSZMmmdfEih7aBJvfsSOjzEMuWMmGZdxht0m8KHPQDrtqDbJgJTJx5U4Z/LXdpVPH/NQsVm1uLRKNFnmPl99tp/swP2q6T5j51tkGp3+MGS5N34Eo75y3UJqt3Sk1m7bLu/96RVoP6GKKyGgX10ipa/fj0j3Z+erMAzNnzjQbRcbv7NmzzdxnpyM11Y/L2+0izYcdIds+eUU2jXtSOnzlF6HcZ52ln1/UJPn444//8gujRKq0MvSkk06SQkEihiLZTrdQaTYlxUyQROBYKNjI2NJs7EJdg4uE1KV70sgZ94MRgfY/SCITIotevi7uyURUgxJzRFTZGGj/V3mlfIiMhh1lhhBpmzD2tUhTI+iQwG8NHiI3fjpBNlTXyuIdldI1DwlyIm2i6WTk4nOiSR/R4lDIhF04yqlCrgtHM5W+wxjYWNJelvxzgvl+67V1srrtarPB1oAPF+3BRsEVMugSXCHJ0UC/1jRW0i/0RIRCYK0p0A10tDqL1vud9TlJfkLan/TzUN5nJIl0C1fbNVnEPaPV4ykeCB+ZkJmcuZiM9t13X+c7WjLgPUCSm8pJznQkWSNESoqJmjHZMSggTHyMlbfoEvlz+Z5ALu/J1izmWeu9MNZ41upwliuDilxFVG05JsgQUUHmG7WOhgzmOsc422RIyQ79gM2Calzzc01V4YJMHtq/g/xn+ip5dfYaGdWzcfFeIbUJ75WLdmGO1DQk2gWCTHpPtiyEc4lWg7tKaVWF1G3fKRUrq2XokcNkxdpVJuquFtlvvfWWiUiqpTxzSy5OGFzkCS7eU7T7YpNDah2XrU7DRaSZ56n5zFrr0XKvr0hZ+x7SfOihEqneLCVVXy7sTBZ1PpLcGCNGjGgkaq0FMixeqFrgOAVc7GSZjiSHTbAgTUqKSaUgagZBotNz5JioNJuLhNTfU326kuabER3U9CWioSxcbH40QkgfQK1CDSroA5ChQnS5o9/bZh6aXkCeHmSYyT6WxJwSxEIjQlq0pWkUtBHvl00CijT0EzvyrlFmyONe7SplTpfm0qll7uTUMgH7/XLZx8/Mj0TSgKafkLvLkbVaCHMVomX29h3VUtq7jdR9tkYiNXWy5MOZ0nbPXmZ91k0nfYl8ZoItzClcjCsdZ9lQEnGVkLpq2hFPAs5OWyP9ztZ+Zy4gj5k5wvT7y9+VDp06S2lIQYWIL9xrDIr2eFBMLOzYaSAWqaeeekrOPvtsKURAQrKhbgFJsqXZ1N6SiZzdYqqyUC4qbxQjSeYZ8Ey14I7jMsA44tnyjFnggxFRnrld6KbH7ZqCwO/yexDETGhaZxqaOqF5t2y4leyQax2tUC0oMadEiI0ERaqam5wtibmwocEHbRM2BJwUabpANHIXNHfRzcaxVatkn+bVUrJltkydui6vLcRtrWkutUWHAMZ6T2pmAnlQJRh+F8JMG9mSW/k6flTtgv7PhrFjx5aiWdmdt1dJn0GDGv2Ojh1tU9VmZsPO/KSSkWzCIM2ZUj9ykSQDF+8pGZ1kWykGNR/bHGjmZ7Nk2+QpDQG3ZAtf82VTkUnE3V6cddZZUb+///77y0033SQ/+MEP8nLyjYdMFe5pDqGSYsgPu3k6NrJQYUmzuUhIdUC6NElm4j7oNyw8RLGIdOmEwmJMwQ3R4mSq6YNFXeoCxuKIhipjT6Op9CNXySELuUaK6ftKeuORnXigPbn4/WxIzGUyWqwkUKPFuhFIVp0DUq3HsbY6BJsrIkv0IT2KzVbkMCyJNu4ZibZklD50LleFCHsjokfUmvPvukukFi4y7vlIG9jyhmyg5sx4XXau2yJb56+WnRu3SUWb6Pn6rDGMGy5N6yP6yN8lAMbFa+iLWgAYVvTdpfnfdWMMTTFLBTw/NjxcgH6/auJLsnHaBPm49dCGwlft+8nMNZE0TE7yFSk9BSbi1157zSzahUSS6Si8n6bMRBKJ1qo4vJ1Cwe/ROckJ49g0E4VHuVbeiIYwBNLDRhibCY3qEI1hQdcTCAgLkWAWmTDtiG0LVPtInuIlCLpOelypaO2GBTWw0XQBJmmNgLNZ4JgwrH4QTWIOMkG0DIk5W04tlzq4QdUFW8s5VrQ4VdDfKlq0lurW5bKfFWVWSTXaQKOpuY4y01f03lTKj3sKW6JNN6tcREvtPgphtvso/z/MPposlLzSj3WzQD+G7MeyTm87vKesfnum+XzjJ0uk4/4Dm/w//A3+rhaH0SY6dphb2OwDSJT+b9asVNvFtXXJtTUpU2S0buYrUvPIN6R1u+4y7Iapsnl7feqfpiTZm8Wm5oO6BDcVLj7rjJBkBgxEmAWYyUvzZu+++24ZNWpUweV3gUQK92IRLH7Plmbja4452JVz/JeNidfFSLLCtftK5TSAZwop1qNO/gbjgMUGAsgGMhsRKVtH1Y7AqaOb6sry82z0O1sYn76vEy8R8GxZDtu5erZpB/c1ceLERgYVXJm+J3X+U2LMXJoNLefxizbI0Q9+JJ1aNpPPrjrgS1FmTXfRFJ5sO90FNwuaWpJNKT+70Cl42oGxg/3zbGwk7L7C3KIa14luFtru3qOBJG+YuighkhwE79l2ieQeiDJzP2yu6C+8hj6i/SmZZ+UiIXXxnsK+r5a7HyvlHftIzZoFsvHVP0r7E64xG0LmSLvfcTqJxCLPV/t9+8B4TOa+XGzXUB33eIPo7TJAdOfAR6KsfERHudCkdRIt3NNoLR3MlmaDqECWmNww8ciFq5WLJNmOJLuCRNuJ/s4YQFaJI2yeOYDgENElj5boSi6PoKLpD+tiy8IGaVdiGNbxaTBdwCaAHAO7kO9pS4dpCgJtMn/+/Eb53bEklMLaLCgppn2ysYHavVsrqSgrlaUbq2Xi0k0y0rKopp9yH1zkMqvTnaZmaCqM9pUwNhJ24aXahLtm/MIY1lMa3UjY6Sr2RiKsEwmCTjpO6Su0Nf+DtSPZk4VmHVpJ857tZdvidVK9cpNsX7FBqrq2TfneeH9sXugjXMyVRN4pAORZMn64eI0WADbVLi4SUhfvKezc35KKSunw1etl5YPnyrqXfiutDzpPytt0MT+jj+kcGCykpt/v+FxiUkmzavYXE6LO2NoIOOpBEpjYaUy+z47y2WefNd93tYNliiRrxI6iBzrLO++8Y17PhKbV+LlOP3G1cM9FkgyCfVjlDYnIshhowZ3m6aFE4XL+IqAP2pFDJSgUuTEJ2jm7iab8BI0r+JtEtzKRLpBpgwpIvOZ3awRFCUqyGwn72N4mgPyNXG0WmleUyVEDO8i/P10lz01f1YgkN+V0p+SQaKpNDrmSOZFQAqj9RRdjCBdt4/L4sTcSPEM7XYXIaqo24qrxrGkUWrTKaQ8nLulu1Nrs3tOQZLBh2uK0SHIQ3Je2CWCNJHCgpkekaGj6BrmwBA+C7eIiX3DxnjJxX632PkM2vPoHqV7wsax7/hbp/I07or7OPnXSOX/N56QZNTMNLrFZou8XolZ9EHFnqr333juqLBz5tGeeeaYhywyGQgIEwybJKuKtecUsJExs2j4uREFs+Ehy4u0EGPBa8c3ixaSgeVcQHJVny/XmJ1XYyhAYz5gijlWrzMJGPlo8W2itklaCoAL25P9yNJ7P0mvB/G6IrqplaBGdksPg+4QI245umlqihVQuEMCThnVpIMnXHjkgJXJoR5mJvivR1f5kR5lVicUmgJnKQ882gsQhaCOuxYW0SVDX3M65Vt8BXse6qdrWYaHNsO6y4uVpInUR2ThtyZdsqsMEz573wKVFkWwgNMebi3GgBYC8VxcJqYv3lIkCuRLWgVNvlqW/O1Y2vvVnaXv4xdKs66D4v/P5pqdVq1YmH511kZMVyDJ9n5MEVRZSicVspNVlGynN5gxsGoqIWyHi7bfflpdeeskU/tx4441mstfBDkGGLLz33nvOEeR8KNxzARpdBe+++25DoSaLoUZBWNQLrYrXLl4iR14ltiA25OwCTRGCIEEG6ONMgvls6dsU7PxTXfBtiTkItUb51NFNpcRcSS0J4pjBHaW8tESmr9wis9dslYEdk9/QBKPMqsWqUWbGCIukpp3ZGtdhE0BXEFSc4ThaN5LoD9MW/ExrW2gX5pVs2GKXt6iUVgO7yubPlhub6q0LVkvLfp0lW/MKaSL2xoDTON18AiVQjCFXxozLJDns+8JUpMXw42Tr1P/J6qcul10uey6p/1H6uVQg88J+++3XSFmIeVKLX5Uw0+cLniQz6NXYQgv3GPR/+ctfTDGBHr3kMyAK48aNk1deeUVeffVV8zkRtqOOOkq+//3vywEH1Be+2NCOxcLh2hGzjyR/GTwniA3RYiZvLbgDDHSerxas5HPEK1loqpC2hR6NMxnqYq8ud/kcNU5lwacNuPiaOZC5T8EiQDQ91yoi8dC+eYUc1K+9vDFnrYkmX35gn7T+nkaZ1eWOyng2UXryYheSFmpEKRpoD8YIxIHPaReIA23E3GJH2rLhntl2956GJIMNUxdnhSQHQV/Q8UFbcGrFpSe0BCZoBwJOzLlswnLVX1wlyZnSI+54xm2yY+kn0uag81P6+3WWuoWtLBQsfmVjVNAkWRvivPPOk48++qihQI/Oz6LAjvH3v/+96eD5DAxR/v3vf5uJ7Mgjj5QLLrjADNaTTjpJLrzwwoR0f12DqyQ52/fFAqWTM0e/WnAA2dNjQhY2XqdHoRMmTGikrZqtSvtswtbQZTLjc1XCQItWJetsBQK0UxkjmpZRiFbZerqgR+O8fztarI5u9CVVEeGoXdvOBYm5IE7atZMhyf9NgyTbUoNcqrpA6sEee+xh+oVtcMEJI8GVTBRFugRNRdL0EjU6IbqsqSh2pA3FGS2CUtKciZPIVoO6SmlludRV18imGcuk7rjhUlqRvfQfu2iYsaTzKX1Fo+jMOaRm0Dac1nLBK+hXzMu8Llv9xVVzjEzpEZNi0fvGT6WkrDz0TUXzQPGri+2aCqK2lD4cCveYDBjwkAV1dgljJ3bLLbfIP//5TzNAaNwxY8bIr3/9a1MEpDj33HPlkUceafR7++67r4n2KojwXHXVVcYFkJ3MEUccIffcc495UE3hlFNOkSuvvNIMYH3P//rXvxKypdb37hpcLNzLlsMdBEYL7jTyp/miWnAXnHiiFbkxwdvaw0oO8/X4WHNL9VIpNC02jRYNZQHn51y62BeaVbZGPjS3mAW9Kdk6ol9ctsScpquo/nCYyhDp4KRdO8uWHXVy2vD6SE8y7aLkLxGJNtvxi7x3u11JzdB2VdOOXLdLqtDCVW0X1Q/ec889o+qhBzW8gxtPVYbQ4+kwctlLK8qk9a7dZcOkhVK3o8ZEk9uP7CuZgr4v+gsXJ826cWSMRNsgabqKbsIo/lMTJj7qaQ7txvxDO2WScLlI5jIZ4bYJcu2WdVLaol3C/6uugMhvoiiJJMlceDmLppJmwNc0XDJRt2OPPVa+/vWvm+I3fv9nP/uZWYC1EEJJMpHAhx56qOH3WJBton7RRRfJc889Jw8//LCZbCC9LHhEwFOJAp566qmGNF9xxRVxO8qbb74ZNRUj14DccU9MUC7hrbfektGjR4cqG2hrFjNR6wkIEzMTLMQ4VQJnG1Nw2ZXorltC2/mjLMgs7mFp4dqFS7QLbZQvVtl2xFOjxWFFPG1lCG3zfImmRmsXVejQVJt02iVWhJ6/ny/torroqgxDu6RT2W/r02oKWFjtsmX+aln4+NiGr1sP6y4dRveT5r06hNLW2i46N6pijgYTUjWAYW5hQ68FgPQVvsfmgbHEfE5qS5inexSk8hwQJHAJ77//vqkbQb4yU9g84e+y6onLpNMZv5bW+38rod9ZsmSJufbZZ58m+7emZuU7yuN1HB5ScIfMINNIAAObSMETTzxhCC3HkonixRdfbPQ1RBhiA7k9+OCDG76v+aLRwKL04IMPymOPPWbSJcDjjz9ujmzILz7mmGMkWSTquOdqJNnFwr2wIska0STawLPX58SkTB/hubO4hHFMFTSmYCHQRQHJMC3G4XIh/UAVF1SfV6N33HuY0bt8s8q2pd5oF1VoCNvkJJoyRLBdlDC7IJdnS7SpVbidLhDW4hZUV7GVQUjNsKX3XFAGacoCOqz7C+rT2pbZ9Jd0ou8t+nRs0EwGmz5daq7KLm2k/ai+xp2vtFly78Mu8uXZabvwTMPSXmduYePB3+SCjNM3tQBwypQp5nUqPUmUOd0aEldzkrNh/7xz5Ryp27JGVj91hVQNPlgqOvZu8nfqijCSHHWk0OHJyWWgEu3df//9TafkoTGJMNExkD/88EMZO3ased0555yT1o1AekAwnYOILeQZInLIIYfITTfd1JAoDqFm8B599NENr+fYHBMU7isVkpyo416xpjVk2+GOvobcDJMlnwP6G9FQdtn0hWwsrJDioCW0nX6gC142nNyaikSFobkallW2HWHKxqlLtCi6RnTZMGSrMDPYLvqsKApuSmIubPxtynJ5atJy+flBu0jbunpnRj0ZgeBkU6INEqQ1AbaGt1pDax/mysapRCoW0JkAfUDrJewINussc0wyJ0H8rPc395c178+WdR/Pl9ot9etZ9cqNsvx/U2Tl659K2z16GcJc2al1k2k3XDwnlYsk6pqN/Hsl4rqRYD6BMKsuM+sCr6HvEiRhrCU797q8Vma6fdsdc6VsmfKCVM/9QFY+9H/S/YoXjVRcU/dVWmD1KE0hKrtgoEKAicr+7ne/k6uvvrphV6vHrQwgcrEuvfRSOe2009K6Cf4m6Q0HHnigIbiK4447Tk4//XQzWRGx/sUvfiGHH364IccsuOQyEaEJqmxwJMPPUkEikeRCj9jm2uFO89NYyHXnygLCyQZ9k2eUy4EatIQOOrmx0OvPwyJAdk6jmnlodNIVfd5cWWXber52VBTy50JusB01jCYxpzJ7YRdFavTvT+/OkveX7ZCONWvl+6M7ZUSjN90oMwhGmbWeIOwoc7oW0JmGneMdrClAp1ZrCvSKtvkkUtz5kKHS8YBBJoq87qP5sm1JfWSZor514+eZq0XfToYstx7cTaS0xGwSdMPA5oG1lfGKN0KujSMYx6wBXDq/kJpBu7D55FI+oNrMTc0xrpK+bJBkcpO7nvcXWfSrvWX7Z2/Lhtf+KO2OuixhdYt4KKRoc9xZ51vf+pa5GDDjx483HZKJimgtOSk6iNN9oBBtjlKQhrGBYYkC8kxOK4T5+eefN0V3sZDO/SQSSXadjOZThFtzOfVITdtej+2Z7FxIZ0gk/QA3MbvoieIcLe7hSjb6YlfHsxDY1fGu2Pnmwio7Wv6vRtpcN66wtaqZy3jGSvBVZ1eJYbLRd9sVkYvxxP85oX9reX/ZGhm/qbkhga62jR1ltp0MGUe2k2EqUeYwLaCzjaBetdYEsB6zKdfNp1pm23NlaXmZiRpzbVu23pBljEYiNbXm51vnrzZXpKpMtnQpl61dy6VD9y7ObDCbml8g74D+QoSZAAvjic81uKKnArRjsM+4mm6RrbSGii4DpNMZt8mqxy+VNf/6hTQfdqRU9tgt5usjSbSXi+2aCprcmqu0DVHdaD9LtzHQIv7Pf/5jDDyaUqRgp09nZ9IEHLGw+BIVsKPJRCFRy8g0SXaVjLp6X/QX7k0L7uziDD02I0rAYpDrqGg6C72tCqF5fJMmTTI/19SDaHmfdvSV34MkMMnz2ljKAvmCpqyyNWIVyyo7GC1WJQnXF/OmQB/g5ItLbYuDEnPaLtGO2WNJtPH3MK+gLXevrpEbP3hHZq3eKpOWbZIR3WPbVLto8MKG0D5F0ShzvBzvTFtAu2CvzqZcc6i5Jk+ebN437aGbCTsq3nyXdtL8xL2k7YEDZMUHs2Tbp8uldEv9qWnJ9lpptbBWWi3eIa23VEvrNuV5NQfz/G1XROYU0jF0U66OkWwi4BG8jq9dJcnZjHC3Puh8k3axdcoLsvLB70jPn7wjJRXRN+d1Pif5y2gq9ymdTgBBRnKNvGMWu6bARMDuWSs+R40aZRZHjEDOOOMM8z12krhB3XbbbSndF5NvIk6CLkeSXbsvyCKLuUaENJ3F1lWEDLoaLQ6DAGn0k0mb/Ev6KMRQF3iNDtFOfI8NoJKcQkMiVtm6wCs5ZkxqtJiNVLZyRbMJ3k9QYk7J78KFCxs2BvxcNxpqiw35i7WRal1ZLicM7ST/mLZSnpi4LC9IcjxJwmCON31ExxJjTjcambSAdgW8J9ZDLnuTxTqIvKpusvmo0XSjSNOhrXT66q7SeluZbJu2XDbPWi7CslEX+aLQr2sb6bDvAGm7Ww8pKcufuZlxwtzBJoKLdqG/QJr5SPSdi9fQX5hvXCPL2bwf/k/nb98ri64fLVUD9pNIXa2UpEHeI5GIcxwkqxJwYeHiiy+WJ598Up599tlG2sjs9CAGDOTrrrvOSLIxAbAT/OlPf2oWC81vVAk49JyRgGOSRDOZRTVVCbif/OQnJopz9913x30dhYG6KLkEcrchonoMlQuwgHPsSzuyiLOIKXRBZ1NUiASwKWgOM4sYxFDbRvMv2TC4ZkqRLbCIQ5SpJ6CNdKGgPYj8UKSZrxHjdKCEWMeT6rizyLMBY35sKjL6yqw18tVHJ0n75uUy+0cHSlVFfp5IBMFcR7twesicA5j3mZfpM5DjfD19SbfP0CYUuHEixcYC0E/YgNNv7HqJnRu2mSK/9ZMWNBT6KcpaVUqHUf2k3ag+xvo630GwgoJIdRMGKh1KuzAH51ra9fXXX5eRI0eaE4NsoXbzGilrVV8fEAufffaZ2bzbtWNB6IkxbVgIYy9n5yn33nuv+XjooYd+SQoOOTkalxy9Rx991AxyFoLDDjtMnnnmmQaCDO644w6zGySSrGYiEOZUHw5kpSkzEVcjtrksKKTNmJBVO1cT/JmIiYox8TC4+DmT93vvvWfID8Sn0G2PIX+2mQfPxzatoJ00LePjjz9uyNdVi99CmGiigXawc4uJgtEn9IidRUsj7ByZsjnWnOxi6TO2VbgaetAnbKdIiqybSj84fEAH6dm2UhZvqDY21afv0S3v+wzjBWIMkYAQ0ydYmFWXmZMrPbHJpMudKwi63bGh1Hxc2siWiSRlRaXU1Myky2G7SueDh8jG6Utl7YdzZfvS9ebv1m6ullVvzZDV730mbYf3kg779o+riuFqn1GlDk6u1PiG989YIsqs+e9ctB39htS/XGy0clFQaBNkosmRndVSWtkiLwodCzKS7CpuvPFGkz9qG5hEwwcffGCOcrQy2xUQaYdYxNvphQGVKSKKwwSkedwsUkwuHHFyLBxrQBFB1UmL6BiLlxLmfD9KtwvL7FQBTTGIFym2Xf+08t52/ct1hCNd2CkEtvufLtbxjsTtokj6TCFZZdsmLVx8ruklsfKR46k1RMvxvun1ucam+seH9pOjBrk1byVjAQ1sx8dYpwuqJKKbDds9UIlhPm9Abfk6LrWY10LheKcLEEPb5IVNmW1mwny8fcl6WfvBHNk0c1l9KoaFlgO6GLLcsl9nJ+dqW6aTi3lV2yWeDji/x+sJ+Nha/KpAw4YjGxstfB5wF7YDgtlCzbrFsuIv50t5u+7S9fzGPIhABWCzXiyRZE+SA8AamygnBinxQPSGlAEGnUsgZ5vJD9fAMEGnZyLVgjsIC4OBQaA6lUT7Uyn2CArV21JiLhhSJAK14tXLLjxK1brZLuTTRTBfXP+iRf5ol2TIXzzYVtn87Xy0ylbyp8RY34MSlVQ2RNEULnSB7wAxbN8+L8ZTLAto3kc0C+hEEK3IMZt61WEgU253dmGk5rnr3NW6tEo2TVok6yfWW13bqOzc2uQtt9m9h1HScC2SroGXVDbRjCX+JlFmTefRNY95S7WZM0EEqbPCn4KNTraxfc77suT2I0XqaqXL+Q9J632/0fAziol5v3aKbKycZPqQJ8kFCNI36KCkdcQDknjsKtXYxBUwoJkg0LBOFxARJgfyRCE3LDLqisSEzATB52EuusFIKkRCj9ezZdSRCGyJKtrbtjg2C0sGZMhsGSv+p7r+8T/bO0R+tOJe2wbogpuJAqp8scqOR2AzFQ2PRsSVVKVKxDNN/nQTHpYFdDTYetVKxDX9QMeTKwt8NLc7WyUn7PuMNbd1bNteqpZVy5YpS6VmQ72xk6KsZTNpP7KvtB/dT8pbZq9PadEvF/cMqVRiHLaCiW741dyK+RgwhuirnJ7Sb8L4ny+99JIcdNBBOdu4rf3vTbLuPzdISVVr6fWLD6Sic3/zfdKXmL9JVWmKJNMurqxJ6cBHkgO466675N///rdR3YgHCgMhiST6uwQivUwYe+21V0oLFQRDq4B1EoCYsoCTn8WknK2Ob8s4KfnRY+RMLJxN3YttHWtHW3RRzSaBj3acaKdlZPNeoqUKqHarRv6ySVJtS2hb/SEX5Eefk5IclWjTtsl2H+bZzFmyQp6avEIO77BdOrRLP6oftgW0niBlU4JM0w+CUno6vrNNVpT8KYFXt7tcpKPZp2TGrIcUqeoqabZgq9SubKwEVVJeKu327C0d9hsgzdq3DP1ebPMmzUlnTCsxzqYRDGObtAwKjVmnNIjEs4IXoMTC3JPss+I9QpJxGM5VcXuktkaW/vZo2T57rFT231d6/Og1Yz5CnRj3RKppzN/1JLmwcf/995tUCxQz4oECK5XecQmoJjBosQ5NBCwGWnDHhKMFdzrQIcauHF1Hs0nVyTETkVt74WSBUKkpjeC4FKHUhUMlnjLh+hcvWqzFiOmkCmQCwSP2bFhl285xStLjFdVlE3V1Ednjzvdl3rpt8uevDpFDdyltVByoGy3uM+yNVjwL6FyQ9Kbu03a31NMzjfiH/QyjFZflKiCQTL1F9bIN0n61SMXK6sZ5y8gZDusuHccMlKqubdP+n3ZAQA13mPtd0Ujn+THuSXdU/X/ARk8DTNxvIv1GSTKiBrl0f9y5ZoEs/tU+Urdtg7Q/4SfS4eRrjRY3Y5ai81jwJLnAQcHen//8Z9NJ42HixImGRFJV7RIgyJBe5GPiET/INJOdLSfFpMzul0Hg+jEJ963RQj5qJTdXqqkHsRZHTRXINcFJlqTZRZGpuv5FixZrbjH9xI5Cut5nouV4h2GVbRMHJThaBJWKO1ymcesb8+SG1+fKXt1by7vf27vBgEiP2HWRD+M9RCsqtB0Fc20BnarzZRinAXZePRfQvhivuMxJ5Z5FK6T6kxXSfPkOKa37cpFfx/0HSos+HRPuQ5mY33OxOVcnWV1ndbOl62y09uB3SflEzSvXwYbN4/8mK/58tkhJqXS/8iWZubW1WUPi+Vp4klzgQLuZvGR0CuOBHRWdnR2iSyB/mHQJjFYAi59qiTKZ8TnQ4gPVn82HCTkTkQY7Z5P20UijXeWdz7Bd/1QZINGF2Hbz4nddjRaHVeiTjFV2UKUj01HYMLF6yw4Z+tv3ZNvOOvnrN/eQE3btHGo0PJYFtAuR9EzkldtR5qYInK3qo3nQ9gbWdfLXVNusX7lGVo2bLTtnrJLSnY0lMaq6t5OOYwZJ6yHdopLDbJ8UZhOsK6zLrM/2iS3vi9NoeITOGczZqFsgZ+vCPLLy4Qtk+7wJ0vWCR+XTVTWmj2PoFAueJBc4/va3v8lNN90k77zzTtzXTZkypUHqzCVQaIehCIOOAUnkTHOltEhIc6XyeUJONWcNYqeuVJAbIoBMxhotzocoRVqL2OfRQlsvVBdpu200t5hohxLjfF/E48G2ylbVAFtGDTJj2xyHpdKRK/zy5dny23cWSL/2zeWDS/eVls1iE9dY1td2NDiWBTQ/z1cL6GSjzOryZytm2G1jF5XaCjWFiLqdNbLm4/my9v3ZUre5sTlJSZtKk7PccURf2bKtPvda1SNcTDHJVK0NqRm22RZrsrqtIkN75JFHOhG8qqvGpKfEaCZTi8WzgUPEgifJBQ4K9n7+85/LuHHj4r6OKk8WgnidJZsLPIu2RotV21EF0SkwLGTyFw8QQc3T5nMWay6OkkmXyZcj30yARUkd7vhciQxtw0RdrG2jRZq0i0Z+7LYh8pPvmtWbqmtk9B/HGXORi/brKb85IbakU7RIKqQm2G/UGZFF1JU6hlyk86jFOp8Xe9tEWJs+XSprxs6W6pUbG/2stqJENu9SLmWDOkj33j2L1lGTTShjSVMgWc+BRtLhGMzDLmw0x48fL11bV0jvoXsVDUnO/TbFMTCBqTFGPGgeX65gaxarfqNaa7ID53iHqCETNbtVdqT5Fu1Kd9Ng589yrMVkQxvRXrQJRIj2YSLKdzOKZKMYwUg6JyL0EW0bPtJuhe76F40ca0RUJdq0bfgZbYMjF1HnfEitiIXWleVy91d3lZMfmST3jlsspw/vJvv2bptwrrsac7AB5/3zM9oG21r6lUZSi4UQqpuojivaRIueiRTSNjNnzjSv0XSeQo2U2iihCHxoN9nasUy2frJQaj9dLc021ltBl+2MSNuFO6Vu6UpZ0nWtrBjaSTr16FrwzohBgkx/0bVaPQJ0Mzp//nxzMsz8oxstAhi5mI8jEN/JT8rOiQ/L9h+9IlV9R8d9faE8Py8BFwAJ89/97ndNznG8h4yoNpNcvAT2TOSWsuO0nYDYYTKpsJBDdmyiZ+ejcukAhBQWWmSZhcg282jKyS2ahBqv1TxmF465MqWVqznb2j7BaLGtVa1uXIXk+hersEzfq219HSQy0Yr07OP1fDs+v/TZ6bJ9Z53cdfJQqaooi6m4oJtxLeZT1RR7jkzHNbDQ3e7solFV9si0RnYuEXRUtSXsKjbVypr3Z8vmmcsb/1JZidT0aCFrOtZJWZvGltmFNh/TBziNYa1SiUoN1gT7De1HPjNEWgN4uu7j1ZCtfO1IJCLTbzlGKue/LeWd+krPn4+Tshbtor6Oy5Xod7rwJDmAt956S775zW8a+8V4D3jGjBmmc/fvXy+yHTZYjFmANYVC85Y0Iqye8olOHkp8GJha3JbPpNCuxqd9WLCM4P3n5C+ZBTma/JKdq5tvKQfRFmQWKV10klmQo5GBfM83DVOiLd+tsqtr6qRZWX0KUiwLaLuYMZmouSofKFFSHWT9W/k650Rzu9Oc/nQ2rsE873xNNWGNSUSGsnr1Jlnz/hzZMHWRSJ1V5FciUjmgk+zo11LW1Gw2Y8y2zM7HOUc3DbSNXZTIlcz7od/ACfg7nAhqASB/gwgzwbJMnmyNe/MV6fLCRRJZv1hajviqdP3eU1+6d0+SCxxjx46Vr33ta+ZINV7H5eiMxTSeqHayYABoHiSTDANAxcmZaDjaDGPy1IiPEmZ1t9LiNlePSNXMQwX2WWQ1GhrmsbdNCiGYGiGifVyT84ql0sEpQiYW3aDrn0ZBXJZmsqO/GhHNhCNfvlplq1rDilWr5P5J6+X0Qc2lf/cuoRZrZkpiLtOwpchUxUT7e1hpSGFuanO1aWAtoa3sE6dE+/vOjdtk7YdzZf3H86VuR30qhqJl/87SalRv2dy8/uSPizbXtnFFJzkaNHdfCS1jSYlxGKk22m8oALQDaVqLBGEOO1Vu7Nix0rdys2x74FSR2p3S6et3SNvDL/rSfflIcgGDxPSjjz5aFixYEHfSJveOnw8aNCjtSYadIROjHqVolSukOBuKApkezGEchat8HYNfJ8dsRBQ01zBoBU3b5FLtwYXjWztlxU7jcMFCPCjRRj9JNSJaaFbZsSygH5gVkUemrZc9urWSZ88ZIV1aZY7UR8tvdsUSOmh1nG23O7Wh1oCAWolr381llDmYwhfmpqF22w5Z99F8Q5hrtzauC2reo710PGCQtBjQ2cx12jasXTwTDZbkMqXHDj5xQVqzGXxi/uX/agEgz4q2YA3XAkA+T6d93n33XRkyZIhUTHlG1jxzlUh5M+n54zelss8Xvgxaq+XTLQoU5CIfeOCBhrjG60yzZ882nSGeh3kQvJ7FWwvumIwZWEwsRCvVnCSXx5DBYyF1xEr2WCgV6NG+LhD8f/sonMUzl22jRRZB8X9NWcn0wq4LlC4QdrQ414VA0SzE7fzVTOfq2gWJtnyd/v9c58OqVbaS9mxbZSdiAT1t+WY56ZGJsnLzDhncqYU88809ZHDnzOdYNyUxl+l+HS/dKtvW4Yn0a40yZ0uWkdMjXRPCMieKh7qdtbJ+8kJZO26O7Fxf71ynqOzc2pBl3PwoCoxVi6JXpusnXE1jpN8wjogyqxSqcg31R+BKdt55++23ZdiwYea9rbj3TNky6T9S3qmf9PrleCmtamVe40lygYNc5L322st0rHiDf86cOWbHP3To0Lh/DyLD3yKNggmOQQSYaLTgjknYxeM0u8CAj3YUNVhgEKablW397GqVsy6sOjlq1CCV/MRkjAu03yRiXOBS4Y7m6oZpmqCbhmBBohIIVwsMs2GVnaoF9KzVW+XEhz820nCtK8vk3q/uKl/bvWva95PsfWuU2U49CJMUpmOyk0tolFnny0xEme2+Y58uZlvb+Qv5uFlSvXJTo59VtGthXPza7tlLSsvL4qoaaduE1XcYv9o/NZquJ6+uzsk8U8g8ATrmHj21Zq6hbYgyJ7Lheeutt2T48OGmz9VuWSdLbj1Y2h52kbQ57KKG3/UkucBBhJjoMIQkXoeZO3eu2WHvuuuujb5PB2GC0c5o5wmphIvLk3CiUVTaJpVjNjtNQKVvbOIH+c5HuTFNWQkWt2kec6rmBLYDYK6jWmGQ2XTsd1WiTdtGbcP1b7mau5ktq+ywLKCXb6qWc/46Td6dv958jY7yr44aKC3iGI5kgxQGXR+TlZjLxsYtF8f72j7pmP+o0ZDOYbaiTa7rVMwYmb1C1rw3S7YtXtfoZ+WtKqXDvgOk3ci+UlZZHtMx1N5QxFL1SSTtTqPp/K6mUWQimp6NcUVaBvVPbILUcIz+w4k2wbtom/U333xT9txzT7MZAJGaHVJS3rhvaC1VvhWfxoJXtwhg4cKFRlZFtYVjAf1CiNFuu+1mBtDSpUsbjjW04lRz2ehwrhbthFWwoYt6tFxU2seOFtuV3LlOE8jk8aQuxmo9q3nMQYkfjZxpioltc5uvm4awjrbjSbRx5btteLxcaq6mrLJjWUBrIWWqfaemtk6ue3Wu3PHuAhNR/uj7+0mPtrld9IJ53hrh1PYJbihynQKUbSRrI6+BDz0pBEqKs5E+lizMJnnhGkOWt8yt32wrSqsqpMPe/aT93v2lvMWX11rtC9o2dg1HLKdVVa6hfVjv6F9KjF0tNE0FGnwgNUNrFABzSbt27UxtFO1Ef3j99ddl1KhRZtwFUbdto9RsXCHlnQd4klzIIC0CRy12WbEmUUgi6hYMODoYJBEwCWnBHZHEfItMpAKdfDQCodaivH+NTtgRDpertTOBaAuRHovq5sHOwdQUk2JBtCIpJXdqhGMTv2IxNoklOcbYYWzpzzJtAf3SZ6tl/bYaOXPPbl9scrbXSLvmuVcU0A2CbjDpF0oGlTC6VEyaqw2FmgZB8pQM6tjSDbyexOQL8du2bL0hy5tmLGv0/ZKKMmk/so+JLle0ad6kGpCSZr6mbWgj+oyae/A9JcaFEhlNNJ2EwN+GDRsa+A0BDE5jSEelTey+smP5Z7L8rq+Zftfjp+8Z/eRCaS8fSQ6AgcOkSkSZSQOwILG7otOoygKdATDh8Lq+ffuaBTxfJpmwoZqoHN/QhioDw8BCv5GNR6FFbhKFHS1mEwaxoZ/wfXbktA1HXIV02pCKRJtdFQ6U9NA+Lka2sr3RYuOuxA9ABlmsGF/ZOvJ98bPV8p2/TpOrD+0nF+/XSyrLS505uWF+hhgCxhaEh7aJZnxSTGDuoe8w97B20Q5cEEDmHZfz95uC0VoeO1s2TFvcSGu5pKxU2u7RSzruP0CadagvKIsGDeRoMb0SQjZTevpXTBvzWJbZCxcuNJsGhYoNMDeTQlq6Y5MsvnE/qVmzUFqOPEW6fPfxgjkh9iQ5AI3M4Kj33//+13SQww47rCFnR4/CiRZDauzK1nxI4A8LGsnS4z09+tRoMZ8H5dNYqLR9suUSlCtEq9i30wToRy4Ux+QKGunT9glKtDEJh2XckI+IJpFmR/yUOGv7ATvanqmIKbnKf5+6wnzet32V/PKIAXLa8K5SVpq9sZyIwY1tGOOaxFyui4pZt6I5I9q5zPk2N6OCseaDObJ+4gKJ1NRLkBmUiLTZtYd0PGCgVHVt27B22fU1tJedZgLsKDPtp9KjLmt6hwUVG7AL9rt27dqwbtNfcACkjZiXAa9Z9MHzMmb+vVJSVyudvvkH6XLM96UQ4EmyhSlTpsidd94pDz30UMP3kDv585//bEgxE00s4mtLwXDpwCuknSgLj10IoZE+NfOIFwll4NlKGfZOvRDSL6IVlXHclOjCrBJh2ZJZyjbiSbQ1tTAnawGcj0jWAjpXVtl1dRF5YtIyue7VObJ8U32F/NDOLeSaQ/vJKbtnjiynY5UeS2JOf7cQjoXtwmrNR060ONbesPIR2Lb1+XTCVbOlWtZ+MFfWfTRP6qprGv2soldb2danuayq25zU+qPzOm1jp3+pZXYhpPDYhYm8z0QdAWtra80pBac4P/zhD2WvnVPk6tEReX9De9nlgsfkkEMPzfuAT1GTZI4P7r33Xvn73/9uCDJEhcUIMsOkSySLKPLJJ58sJ5xwghlUiSzGuuCRemAXttHhkrGSzjU0N0snCHXJ0skzVXLCgsdko5EOndCzpTccFnTx1QXGLirjfaQ6OagahG4oMuHylQ0ElTr42i4kSpWcaHGbnlDoguey618iyg0gDMOTaFbZYSuAbK6ukbvfXyR/eG+hrN9eT0ZO2a2LPPb14eKy2108d7ts6Q6HBR0HSmyCpw2pvA9du2wZNaLM2j651htPFLXbd8qqD2bL+vHzJPJ5/1Q0695Wuhw8VFoNaJxXm8yaqOOWNVGLRxmz+XRCCt/RoB7viWdrFyYm2l9mzpwpzz77rPznP/+RadOmytdHdZVFVQNl4cJFJo0F34ljjjlGjj32WNljjz0k35A3JPmee+6R22+/3exaUJQg4nvQQQel9TcnTZpkKjX79+8vxx13nFxyySXGTQbQLLjq/fOf/5R//etfMnHiRNl///0NYT7ppJOkR48eCRNmLWzjYgFjMGlndGkXqtFQ2/qZKIKtyxv2/dpHg1waIXKxfYCanQSPcTWqEDaB1bQW3VCkav2aLdi6zkGljkwQWFW/0CgzRFz/Xz60j54YZIqgZdoqmyK++8bVk+X7vjZMThrW2Xx/285aKS8tkYqy5N6PrZFMv09E2zkTEnOuWokzP+tcoMV4ttRk2O2jtSa2c6W90XW1fbgMwW/ZWjpsLJO6GWuldlN9rYOiqltbY0zSesguUpLiCYieAGqUmfFrm5m43j5spjRinGjQoq6uTqZOnSr//ve/DTGeN2+eHHnkkXLKKafIV77yFfO+teYGSd2XXnrJXLTPe++9J/mGvCDJzzzzjJx99tmGKB9wwAHypz/9SR544AGTN4wIdjpQubZ4oImwqYYsc+FfDrmmQ0Ca+/Xrl/DkRBSDCY4os109y5WLPEsWUdv6WaOhOsizmX+lx+o6iLV9dBHIxbFoLAkyXSiyWRCkUTBdJFXSSqNHuVDF0GNwJTaa/5gLibZokl+5zvOOZQGtkd1sFrdk0ip74/YaIxWnv/+bt+fLnz9cLJeO6S3njuourQMatvY96WlVrt3u7CgqF+1jR1FzESW0rY6D7ZNtxQXbrMNun1xaQkdTV7KtoHVNjdTWmeI+ivx2rPmiAA0069iq3phkeE9T8JcqNO1JNxVa32S3T7ZPKew1g4v2sYNQiZL42tpaGT9+vIkYP/fcc+ZvERmGGHPKHk0SLtq95EuUPe9I8r777isjR440qREKTDy++tWvyi233JLVe6G5KOZjF0WUGQca7kUJM58n2hFYMLXzMrjoaEqYM7U46KCxrZ/5X0qKXTrOD+pUElXS9skkeQ/mFtvW2C61T9BCXHW5M23DbFssq5643T6upBNp++h92nJXmTxWT8QC2gVkyiqbOeaAe8fL5GX1Lmntqsrl//bpIRft10u6ta7MG7c7tTy2Jeay8Rw1jUs3e5AvF6yOE4kya4Q5k1HUaKdriaYzGlI9c5mRj9u+bEOjn5W3aW7UMNrt1VtKK8pDax+99JRCr0xtcOyNFcE47sNun0RPZxmn7733XgMxZl2EEEOMSZ/I91zjgiHJLDhEo/72t7/J1772tYbvX3bZZSZdApKaa6tHOhER5ldeecVEtiHMEHicaRJdiFXGSAu3bELI5+lANSF1MqPzB62f8y3/LkzHIztarBJ/QcMK13fAemys7cOCbudvpkMI7QibRmtsE4d8KJyz0w7Scf0L0wLaJdhpK2FYZVfX1MnTk5fLne8ukM9WbzXfqygtkWN7V8ixnatlcKf8Kky1T0z0RCDM4kja21YCyrfC5mAU1Y7CM77SHQNBvXnb8ZW/n2z7mDE7d5WxvN66oL5YUVHWopl02Ke/tB/dT8qqKjKiW83nmgsfhneAvXHgor20/yRT5wMPwVWPIOALL7xg2pngH9zriCOOKHhVobwkyVRNkv/LjmbMmDEN37/55pvlkUceMUnjroCOT8ciwvy///3PDIATTzzRdDKi4Yl2VJ0wlfAQ6U1GOs22K1UheQakbf3s+qSbiNi5LioqvZcMIbRzryFPdrSYxc+VaE26SgD0Ic3TTcZQIVbFu6u5iOm6/mlaT6IpEGFZQBeqVbbOQStWrpT/fLJcnpxVLTM31v/O6bt1koe/vqfkM+zTJvuUIpncez1JDOZfN6UokA+wo/B2rm4yRan2xkGlyHSeD9P4ZOvitSayvHlWvbShorSyXNqP6msIc3mrqowENbSN1IVW2yiROcgugOfSjYMqaiW6xtOXCfAR7HvxxRfN+IaznHbaaaboLp/XwqIiyeQBUzinuOmmm+Sxxx6TGTNmiIug47388suGMKO3zCSqhJmOl8yRh+6gmSz4PSXM9kQRJDU8VtvFLZ8X7WQIIZNNtKNJW6nDjgQVuvalnaerOWnRCGE0YpSoRFu+QwmPahLHSlvJlAV0vlll6ylF0Co7WpqAXWQ6YekWY3P9iyP6y/Burc3vzFq9VT5btUWOG9JJSrOotRwmgprVzNl2Xr7Ovbpx0PbRsVjojm62RKHKG8bK9VbFBR2L2dw4bF+xweQsb/x0iYjFiowxyZ69pON+8Y1JwkiBpH1Yp7TgWS2z7TFmS6myvkXjA039P/ohhBhiDEHGFITT71NPPdUE9PI5iFZ0JNnldItk3sMbb7xhCDOdkl0kuT0QZiTmkqkqVcLMBRhMDBwW+lwXCeQa0YpcNFWCz22lDpdyQ3OR561Ej74H2WNxon9FW9yLCRrh0TZSAyGID22USQvofLTKVtlMHWMqQ5ZoRPV7//pUHvt4mQzp1EIuO7CPfH3Pbk64+KWKoMQcbRUcY8me6hQagrne9BHaiDGmwQvdOOTCtW3H2s2y5v05smHKIlPwZ6P1rrtIx/0HSfPu9W68mUwNs6VFNZ2HMaaphlyJprHQL/l7zz//vFGkgI8MHDjQcCpyjJNJDS02ON8qEBuUJNjt2OBrO/3C9fdAojuqHDjV/OMf/zC7vssvv9woY5x77rkmp9m2fYxFtrkYRAoIMt9jp8lAYtCwMy/GDs9EQNuwECn4XL/HAk4b0T7FSJCBvSdmcqVd6ENctAkTMAtTPqdTpAPGDRfjiUvbR8ccpIaL9ik2ggx4z9oGXPQnbR9VCtL2S6R9erapkrZV5TJz9Va5+N/TZdffvie3vzVf1m2rtwfON/CeGUNczDe0A21DG9E+agkNHI9PZQy0CxtMNld8TvvY87bdp3LRRkSLdzlhTxlw6ZHSYf8BUtrsi7Vi0/RlMv8vb8uCx8bK5jn1pmFhg3kYfsA6peSYzTttYrcN5Fkt6qOB1yKZC+8gKDdgwAB58MEHjXQuAUZk3G644QYZMWJEUfKFgokk2xJw9913n0m5uP/++40L3ieffCJ9+vSRfAWTArIqkGYS5SHQ6A1S+Hf88cebhfjVV181+c0cgwDb+ll3kdG0hjXlgN14IRNCtZ9VncpYSgvRlCAScRTKd8QqONKCF52ENb9NI4SatqIRr2LoQ7EsoFlAtDhPX2dr1BaK618iKU12/rVtcxzPHKUpq+xN1TXy0ISlctfYhbJkY73NbatmZXLFQX3kx4f2k3w7obFTduw6klgSc8VwMmE70ur8Yhfe2fO0zue2oojO57mIvGNMsu6j+bL2w7lSu6W+fyoqu7Yx8nFthnWXkjSJpvYhFCnoJ2ruwaXF9VoLoeOM34E3oEdMOufee+8tCxcuNNFiLvjFfvvt1xAx7tu3b8H2saImyQCN5Ntuu83sjHbffXe544475OCDD5ZCgQp0Q/6JKvM+9aiXHOZrr73WWGQ3NUmEpYvo+nGvLjTqAqj5W4ksNOrkpYWRKn2VTF6Xy0hXos3OnaSNcqnNmgsL6ESUCmK5/mkb53tkJuh2F5Q/ayr/OlZxZDw1iB01dfL3aSvkjncWyKcrt8gvDu8v1xzmJkmOpsmtxgyJpgnki5Riqgg6h2ouO22USCpOtLk+l6o6dTW1smHKYlk7brbsWLul0c8q2raQDvshH9crKfk42xeA/pRsjjokmdxi+NDkyZNNv6TdMFwjsPitb33L5Bvn+5qWS+QNSS5kvPPOOybnmogxpiUch5BiwgD68MMPzdFIKm5/0QZhKg47uUa0xcTOLU4nuqDFRhrhAHaFcD4UY2U6QsXipGTA1qvOpwhqpiygY7n+ueyKGAsqY6eRci2aSlTRIiyrbPrzy7PWyOiebaVji/rn8uJnq+WJj5fJjw7pK3vsUl/0l+uIurpfJqs/m+yJT7ZNedIBm0dbyjSV/NlYoE1sxQx7HcimfnSk7nOt5fdny/al6xv9rKx5M2m/dz8jH1feolnMIBbRYnXgZZ3p2rVrUnOF7XqHhvHcuXNNfdPQoUPNfESt1rRp02SfffYxph8QZpyFPZKHJ8kO4NZbbzVHJFhj09FtXeRYbn8QZtIyknH7i+XVbh/nuIBcRRC0ClvbKMxFMFORPl00siXRZkdQ+cj/sWWZXIqgxrKAzqQ2bzRXRNv1zyXNbe3vSvp0wc70iUEqVtmH3z9BPlhUb/5w/JBOcvUhfWXvXk27fIVxr3Z/p3+nIrOVSt/VAtJs2Lung6ApFhsq2/QpE9BNhc5/qZwohjHW0ViGLG+ZU19IryipKDOmJB33HSDlbZs3WleYQxM1P7EB+Z0wYUIDMW7K9Q5lMOygiTRfeOGFcvjhh4f6/osFniTnEcJ0+2Og6qBlwdIcXXa0uZBDcy0XLVraSi5TDoJV86p9rdG4XKiZRJP8ipZnWIwW0MF+bauJxIqgZgPR3O5s05lsP7NErbKnLd8st789X/45bYXUfX72ediADnLNoX3lwL7tQ70nrV/QZ8bGRjeCuTCG0U1FUxJz2UK0jWCupey0rkCjzCrPmK3UlS/k45bSQF/8oERke6cK2dyjQjr02yXpE0rvepd7eJKcpwjT7c/O0eVjmEdkTREajQTYLm4QrHSPd8OGphxotETTGTIZLbH1V/moLmguSrRFy0HlXnXhzJRTk63h67IFtP087Qiq7fqXqY1gsKgsGxH1TFllz11XLb99Z748NWm51HzOlv9v7x7y+68MTZv06fhWoq5916VTtlzpmfN/bSvoVK2OswE7dYU+ZEeZM6mJz/hetWCprB03V2T+BilprB4nLQd0kY5jBkpL2SLC3N21q8iKFVJSXS2R3r2/5HrH2o5kG/fKuk7EuFhd73IJT5ILBEyWDCgIczpuf7b9J5Mhv6eEmUUqncklmguTkuJMkoSwoVF4LWoKM9JkO3mp0oJtWOHSMWsy+a16BGtHB4vVAjqW1Xeyrn/x/nbQtAKSYKd85APiWWVvK28lf/xgmTz80VL5y2m7yanDu6al96ykL9/yyOM5Y6ZLXoMnRfQr+6QoH+o1okWZw9TL13QcDTDR3ia/uHV7qf1sjaybMF9qt+1oeH2LpXNk8NM3irRsKTv//Gep/N6FTPqygXV79eovud6hakWNkkub/WKDJ8kFiLDc/oK2l0AJcyL5eMk4LRVSBbeSwUQruKMV7MRTAcg3aDGPbipUYi1RNZFCtoBuSkIs0ehgtNQXbR/XIn1hWmXvqGonw/t2k7afb47uHbdIPli4QX5+RH8Z2LFF3FQTtfFNVLEjHzdeydZy2KomSvpcrTlIBWE4r6pdtqojsaE1EfXOnSVS0Vy210akuqZOtu2sle1bd0rdzKXS7JPF0mLZYhn256ultAYZuRIpidRJHfKSdXWyVURO6N1b9jn9dO965xg8SS5wqNsfWszsUpkkSPLn+CYZtz/7uI2LBcc+btMFRq17ddfOhBOWioDrsDcVdo6uWmRHa6No8lqFHDWwUw5UTcS2EbfbSBdqPXYvBgvooOufFovZaRnBNtLc2Wj6zoWKaGk27Tp0kpP/u1pWba2RstIS+daIXeTKMd2lcke9VJuqariaahI24knMRetHdn1KUOM5n2EMOOoiUl5WPx42V9fI9JVbZMvOWlm3aassX7NBVq3fKGs2bZUaKZMxfdvJ0cN6mLl4yaYdcvULs2RL9U7ZuK1atmzfIVt31EhNpFRqpFTOG91dfn7UYPN356zZKnvc+X7UeyiTiPy+W61891ffkfItmwxBVtRi6sScNm2alO6yS5ZaxUOKnSSjq3z77bcbvWE0A++8805zbFHMgNi+++67JsJM8R9RB5wAiTAfffTRCUctbftnpGyYjFl8IIV8bucW58vxd9iwDV5oIxaiaG1UKBH1VKAbL12go/WjYjBaSDQtQI2CorVRumkshdJGY2cvl4c/2ykT1ta3Q3lJRE7pXymX799dBvTsVhAnM6nAPrFSJRPSbrQfaTqOS0pHRGIXrt9uzGY2VtfKpu01sqG6xny9aXutHDGwQ4PCydTlm+TH/5slW3fUyuYdteYjJJiPW3fWyQ1HDzTmNOCjxRvl4D+Nj/l/z9+9tZzRY0f96U5tpVzwXmz3x0v27yW3HV9Pkpds2C6Df/OeMASbl5dK84oyqSovNX1wx9ZNUrVgvAz762/kv5YjbANeeEHkuOOkWHGPw3ytIENWOPT98Ic/NA1/wAEHGFtG5NU+/fRTU9xWrCCScOihh5qLTqhuf9dff71ccMEFxu0PwkxbxYuyEMXhKI9JhKgXEQkiOWoryv8h8sdiXoyLtm5IWHxoK6KnaterbcTntBOR9WJtI9qC/qO2vdpGQetsvlesbQRoD20TtTW2rY1pxwKNdSQEu00GtS2TX+5RI3O3N5OHPquRyWvr5K9zdsgrixfK744tk9NHuSPBl03wnplvdK7WNtDTBuYpNmDM6ZwuZvIUglSE2Wu2yry122TF5mpZvmmHrNi8Q1Zsqpbz9u4hxwzuZF731rx1cupjk2P+nebNShtI8raddfLW3HUxXwtZVrSpKpc+7aqkRbMyadmsTFpU1H9sWVEqFSURGd6Ztqq3EO/UvFQu261CpGaHtKyqkE5t20iXDm2lS4d20qKyXLq1+qKIbpfWlbL22sOkolSMpCsnt7br3VlHHinfbd9eIuvXS4ltJ01E/9xzRaZMqS/mKzI84zhfK8hIMoVqI0eOlHvvvbfhe8ijofpwyy235PTeXIQKk6s99syZMw2RJiWDXGaiDFTbUhiIyyGRT7XHDhZLkQ+tKRlEm3kdhQz5ZF4StkSb5pXqwhPMP1VzDlXKKORFPBEL6GipBKq4UgzH5EE3P8iNtpGdahLN9c9O2ynkdItoaTvRisr+N32FXPO/WTJnXbX8fp9SGdC6uNK/bEUKNqTR9HmjSczZOf+pqikQ8f1s1Vbp2rqZ9GxbP/djEnPa45NN+kM03HjMQLn8wPqI76Slm+T4hz6WtlXl0rqyXNpUln3xsapcTh7WRY4a1NG8du3WnfLq7DXSsqLsy+S3WZn5naqKspjOibpmMT/ZDrXaPzTnX4skmZ8++eQTM88TWCL6OWvWLEOKIccYebBWokiBJbRxvVu4UGT33ZkE6/85RFnTxijUnTZNpE/9ey8m7Os4Xys4kszCwXERDnZ0TsVll11mnOvQFvaIDbrDZ599Jo888og8/fTTMn/+/IaoJ535mmuuMe5/iVR/q94okw+EKBsi89mETpy6uND3tPo+0YIyJTpaBMKCpG1UCGQwDAvoaAVXutgXQsFVGG53mg+vpFmNcPJNrSEZNzcdK7rBitVGNbV18s789XJo//YNffHRj5dJv6odsmfPwiqStdWJ7OLERIutlTTqnJaIxBx24pOXbZIpyzfJjFVbZeaqLeZavIECNWmU6jBj5RYZ9cdxhrQO7NRCureulK5crZqZa7/e7WT3bl+YaWUCwfoaxoq2kV1fE+/3CQYRAb3vvvtk+vTpDaeEGH2de+65csYZZ5jx96U+OX68CKYepLQ8/HB9BHnrVpHXXxfZe28pNuzIA75WcCQZlxlsm9977z0ZM2ZMw/dvvvlmQ/yIknpEB7vfv/71r0ZC7uOPPza7O9oQkoIv/Lhx41J2+7MXOJVN0whzPuWbxpJoUx3XdAhbUH4vW+5e+WQBHU26y3ZFzBcyGHS7Y0NpG5+kc+pi6/5yQXqiGXTk0+ZBT6ZUnzyd9zFr9VYZ/cdxUheJyOlD28pZ/UQiW9bHtcp2GUG1hTA32ioxp6cV/K1mbTpI8zbtZfc+3cxYHr9ogxx6/4Sov9+lVTOTt3vVwX0bNiyrtuyUbq2bZbUPak429SG2nJ0WDCf6rKO53qFdzFpIX3311VeN4RcnsaQMfOMb3zD/pxEWLGikkyzbtxdlBDlf+FpB5iSD4ABkUOTLwpArYHnNkdEPfvADU9DHBBLL7e+6664zRyJKmJty+4O8MBi4bG1J8rXU2tjF6Gk0i2yVaBsyZEioNsO2JrV9VEpuFpOzTQZdU8BQC2gWIO5bU0322muvUJ8pixlkkmvw4MHm/9JGixYtMu2khWwuFSDF2zxwr4MGDQpVd5a2JvrM1b9//0auf3PnznVaBcM+edBiTp519+7djUFSGEYKleUlcsLQTvLsp6vkmekb5IW5ZXL5Af3lG/1ayKZ1a0zqmVpl6+bOtc0X7aKbaTtla+DAgaFugoymcOeuMqe6St5e0UJe+WyVTF6xSo7vuU4u6D/TjLe27TtK99bNZLeurWRol1YytHMLGdqlpQzp3FLaN2+8IUZhYpc22THDiKX5P3z48KT6PesVa6MSY9YAFKJ+85vfmHXSPoGg/0LsCDQhv4oV9JdIsk2IizAHOd/4WsFFkvMhfJ/voMtw3En+lbr99enTR0466aSk3f7imZfkagGPJdGmkdBcWfcqcWCS1qP0TLrZpWIBrcf7ubKADtoJK2HOlcqKa253toujraecade/pu5JpRO5L/q7fU+ZGm/vzFsnP3lxlkxcusl83bNtpVx75AA5c3hX2bx5U5NW2dmEmuhoigD3xPyo6SZhjzdyhp+bvkqe/XSlvDhztVGXsMEm49FThzTMk6tXE8Gul2lUk45cpEFpAIaIsUpH6ollMnNAPNc7iG8h19dkEzvygK8VHEkG5M6SFkC1pGLYsGEm6ulCInixuP1BmPfZZ5+EJ0s9EtOFINUjsVRJqC6K9vG0ixJtwSPobEVP88kCWhdLW2s4G8YR0dzu1EmPtnIpup3Le42Wi5+L6HZdXUT+OnWFXPvKbJND26pZmUy7Yox0btksYavsTPalYFQ9k6lFtEVp6ReqKcN+N9ZIsIFOLSrk0AEdjOza4QM6NBTiRTP80cI22xQp033JTjdhE6OBlmRS+Qg+EPDxrnfZxb6O87WCJMkk1J999tkmqZ4is/vvv1/+/Oc/m2pUIp4emQMTzUsvvWQIczpuf7pAEBHQ4gomW6ICYUSXgkff/D+Xj1djgcVIo/BqBKDRpXTJfaFYQOvmS8mgXdRmV7CHobRAO7kQnXUx6q3KN7ZVuf79XNcloMl7z/uLpKqiVC7Zv3dD//9w0UbZp9cX/TxolW27P4ZxqhPLkEjHXCYIOUV3D01YIq/PXisf/WA/qfjcdON37yyQVVt2yMnDOss+PVHnSez5aGGbXbtBX9K5NYxNkJ4asT5wokVfYm1ItBjY3iRiAw0xhiCjQkFwh4gxsm0upSIVKp5xnK8VJEkG7Epuu+02I069++67yx133GEkWTzy0+0vEZmepv6GnTcbT6ItXxGGpWyhW0BrUZuSED221kh8osfWdp5vIbrdBRVFYkmsJZsmpCk56RYnZgOvzV4jX3lkkuzfu61RZzh2cKdGRDGWVbYS5kQ3qela26eqVfzMlOXywIdL5KMlGxu+/+9v79UgqxYW9P0padb6imQl5oLyoppukoy8KM+Me3nhhRfMmsT6NGDAAHPUf+qppyaVKuhRHHytYEmyh5tuf0qYU3X7A1q5TxSBz1lIdLK0J9xYEm06OecibzYXeZ62jmw02bRitoAORk/VkpfLjpTHUozQCF8+KUZkUolDN1l2JFRf52LBaTzcN26R/PSl2YZQgsGdWhilhjP37Gb0ehNJR9L3HdxY8Fr7BEjtsjOdP79+2055YPwSuXfcImPiASrKSuQru3aW74zuIYf0g5Rnrh8HJebsE4Xg6ZQGNvQ0kc/pdzrXJ3rap0Xn1NBwsQ5BhiHGRIyHDh1asGPXI314khwCUHrAtc4GRz8MTB2k/JxjBBYQcnDuvvtuI0BejGDh/PDDDxvssRcvXpyw2180kqPRBU0HYMEhqsrXYUq05SuCuqAs0LQxhIX2Y/FRaa1itoC2I/FKcmgXwMKuJguFpD0chqYzGwTGHASZrznBsM1P8jkyt2xjtdz9/iL5y4QlsmF7jfkeectn7NFVfnvCEGlWXpqwVCF9iT7DJoP+pGMum7rxtiVz9zaV8r19e8q3R3VvlIOdTQQ3FkBP9diU0m62AUoy6XoLFiwwARkUKVBRYt0llYKIcd++fYtujvM8JTXkz7becUB40UhU2GSMY4Tf/e538vDDDxvZqhtvvFGOOuooIxXDLrrYwARIvhfXrbfeKlOmTDGEGavsiy++2GhMQphJzWBijDeZQYJpQyZTJlwWH4gfhIY8uG7dupkNC8SvWEH7sSCrBbTKtNFH+ZpjS9opH47AMwkWYEiwWj2zaLOp1cO2VBbrQgSETi3EAX0J4gdJZuOlR+mFkHaCXBkucD8+tK88+vFSk57w2eqtJlfZJsgL1m2T3u2qGtk9s0Ggn9AmBExoJz7XuYmfc2WyqA1N6IlLN8oZe3QzX4/q2UbOG91d9u/TTk7bvWtMkp8tsGkgD5h5h7YiakxkXecmIzHXtq2Z45s6hVAjLIix7Xp31llnGfUEZASLjRgH4XlK8vCR5JB2aEREkSyJNnAZnHiT//jHPzbfg9BB3H7961/LhRdeGMYtFAR0klN77IkTJxqBcXKYuXSSQxMXFQ0iA0yoqrKg0kNMtkGBfSJdekznmlpFLi2gg5H4QnNFTLTIyI6Mal6pVscDzYkPqkBkQn7LRdh1AbHaIF6BZKFE3mkH3Pu27KiV44Z0Mt/bXF0jvW55W7q1rpRjB3eUUV2aycCq7bJz45qGNrA3V5k021EHvLfmrZMHPlwsz89cLZVlpTL9ygOMsYcrsAsUuaI5A+r8xcVr6T/8HhuO448/3oxTvkbXGlJMKgU64Jh7kEZBoCWq612RwvOU1OBJckid7/bbbzc7XogI5A3HGIT8GbQUBuBgN2LEiIbfYQBDVHCV8Yh9XEaEGaWM999/32ws9EgcI48HHnjAtHFTOXxaOEKUwi5o4++5Zl6SCQtoO282GdvfbOVJZhvRjsKTsRPXPGbaib+TrIV0vsCWZOT9Mo7s/OJ4ZC5aDncYbnkuYuy8tXLiI5OkurZxec/gDpWyb5/28o29dpFD+nfIyJhVTFq6Sf42dbl8uGiDfLxkk2z/PI8aHD+kk9x2/GDp1yG3mzlOGlT2j49Ehm1N/Hj9QXPdUaK49tprze/ze4xdNrmQZnKMUVJiTvf4MjxPSQ0+3SIEQIofffRRk0oBESOdgggoEiaalwwhs8HXkECP6KB6mTwyUjGILrNYkBLAwstkSVQBqTkizJo3GrOTl5eb9uayC9qI/DMx60Sdj/mTsaJS2KQmG5UKuiJqxT0bPI4/lTDnczvZusm0E2M2Wbc7Ng+9e/c2l27aaCfGc77n4wbzsvW5o1uajDV60PVPi0PzwfUvEdjPffuaNfLMoeUyr6aNTFpfIh8u2ybTV26Rz9ZWy2drl8vevdo2kOSPl2yUH/xnhok6E9nt3LJCOrZoJi2bNZcWzfvKqH5V0nxn/eZi3LRZMmdbMylr3kqksoXUllbIph11snLzDpPe8Ysj+jf83anLN8md7y5suL+OLSpM3vT/7d3TuN+50E58ZAPKHDJy5MikNt5sJnDUZC6ir/B3NDVqxowZ5mfMecxdBxxwQFGnQ8WC5ympwUeSMwCiAUSPr776apN3y6DFo5zcK8UFF1xg0gbYGXt8GbgaMbESISA3mU0HE5/t9keUmTxwtBQhy0Tnk5Hwsa2fuSDQtnmJq0V+sSygM+XiFjR5yYZ2azaVK8JCvio75KKdGMP6P9UCOpEIdS4RdHSMZ1qBvvD7C9bL1OWb5ZTdu8iuXerTdh75aKlc/O/pMf/HA6cOM5Fn8Oy05fLNZz6J+do7Thwi3923p/l83tpt8tt35su+vdqaa1CnFjmL1Ktyh55IqWW2pnCF7XpHJJ51gNcg7YZb209+8pMMv8v8h+cpicGT5AyBwryBAwfKj370I59ukQI4QkukoCUstz899rQVIOxCrVwSHJcsoGO5gCmpymXeabSj61zlDrusERyWVnSmXP/0Xrhy7VCoVtCqg8wmVDWMU7m3ZZuqjcIEJJqIMB/XbN0pW3fUypaddfLDA3vLkQPrdYrfm79ObnhtrrSqLJfWlWVSWVInZbXVUlGzTdqV7JCR3VvJ8D5dnVCkYR7QuZO5is2VEuNk2kld7wiCEECCVBP8OO200+Sggw5qch5mroRcF0OdQBjwPKVpeJKcATBIiSR/97vflV/84hem4Ozyyy83kWUAAWPy8IV72XH7gzAncwRnkwjV57TNS7JBBPPBAtp25KOdyDtVgf9skdJ8cbvT4kC7QDKbbnOZdh3MRLEpUciwXf9S2dxoOyWjzZtp5MoqO9oGQse+6tUnswnUglACHN71LrvwPCUxeJIcAq666io56aSTTI4iEwY5yW+99ZapuiUVADKMB/lDDz0kgwYNMkV9HCMVqwRcLt3+iEogMZdMJC+MxaApRHPvyjcLaI0m6bG9HrOGTQSDbnc8A22nfMhv1Q2QEvtM5eeSV27/HzW30I2Wq2kyTbn+qQpEWBtFewOhaVd6ihTm/8kUMm2VHXbwIJ7rHakUe+21l/NjOB/heUpq8CQ5BHz961+Xt99+20xQTBbkId9www2m2MU2E/nTn/7UyEwE+0WP3Lj9HXvssSa/LVm3v7COFQvdAlol+GwiqO2UbERQo03691io1e1OlRLyFXYhKe3Ee7Uj4ckStGDerEZi812hJJrrn51Kk+xY0RMI3dTpBsKWIMtHxNtsp6K+ElYamrreYezBHMx8vMceexhS7F3vsgPPU1KDJ8keRen2p1rMS5YsaXD7o0gwGSIRLFDRQh5UNGIVqBSjBbRNSHjf0TRRY+moKikqBre7aPnedh5ztIhgPLvsVDZu+QJN86E/JSrDpxF81U5PZ+OWL0jGKjvsgmbveudRCPAk2aNowWKgbn/kMSM1l4zbXyJSRywu/A2N7EBoitkC2l6ANTdWI1O0ixYosqDnu5xaurDzvYkI2v3GTtlg42UbVhTiBiJZ2TrdeLJJ0A2akulUlBYKVR9co/Fsxsi/1nGZqjRmLNc7Uim4vOudR77Bk+Q8BikemJh89NFHsmzZMkP0KFJTaJrH/fff3yjNA2tKBRMluUpPPfWUKZrBreiee+6Rnj3rpYWK0e2PdkRDGdk5CDP55slM7hAYNHPRzKZNAUVsRJjJWw8rR7AQFm0WZKQQiaDyNQSHBbtXr17mYzGRmHiAwNCnlNwACB/a4bSV63mz2QJRT+ZCToiIqjOu1W6cseeNJr4AG6+FCxeaDQYbDdW2Zq5DbzgZKU3vevcF/LpcWPAkOY9BRfB7771nhNlPPfXUL5FkCgZvuukmefjhh41pAgWFDGC7YPCiiy4yeWK8hoXkyiuvNMfcEO9CPPpP1e1v9OjRDfbYiNYHyRtRP00PsC2gaVMWEY0yA43Q5HPuYybc7ojsaf6jRk5TzfcuBNgGHFqgqEfedvQUaMTdZX3vTCGYN6uRdVVY0RQWOxpfaK5/ydYKaMoJ7aNzEe2mxkRAayOIJAc39oxjzJ7UDpq/d8wxx5j84mJ3vfPrcmHBk+QCAZO9TZJZOIgI/PCHP5Qf//jH5ntMgkQzVXqOxYPF4rHHHpMzzzzTvAbTEyJTVB4z6RU7aEciU+Qv076ollCQyUJA+7KZoDqbqDOSf/HsZPlbEEQizPlkXpIJt7um8iKVINr53tpWhWT9HEtSzyZ1mrYTr09pW+mmI5tyhbmA5qzbxi1NjSXaxk7L0I2sEsFC3bDyvnUDoaozrAOqOtOU3vgVV1xhTio4ZSRAMHnyZCOxSV8lLY00Cgqh87mANlPw63L+w5PkAh2MWL8iq4ON54gRIxpeR/oAMlOPPPKIvP7662biY7FhkVDgWsffIVXD4wtARkjHePDBB2XChAmG/LEwQJBxeTrkkEOSyt2DBOniRZGWXTXuim5turrALLLpurgpydZ8b81Vpq3yQfItHmySq6oNSnLpB8mk5qRCsvMJKmmnUVCIsJ40JNsP8tX1L1GQ5qVzC4RXTVCSNYuBYD/++OPyl7/8xeQX8wz4W4cddph873vfM853+d5WmYRfl/MfPpGtQIHcDiBiYIOvSSXQ1xBpsgmyvkZ/30NMtBjnxHfeeUeGDh1qosjkghNRxhWKtAzkdVhcyV9mI9KU2x+TJ4sNF86MkBsizPPnz5dPPvmkgSi5ZGAQD3b0iQuSrEVBtFM6xiIswuTeckFmNCpNHiT/N99SDWwDFE2XoO+goZ7Oe6BPERnkIuJnp2vMmTOnUTFpvqg52O+BNiPthveA/nw6kna0sfYb20CEuZHx55LrX6LQOQRiTIEwc8guu+xipNaS2WwxdrF5JpXCdr373e9+ZwIuaPwTST733HPNa4kiP/DAA17zPwH4dTn/4ElygSO4iLAgNLWwJPKaYgKLDTnfRFP69u3b6Gff+MY3zGW7/Z1++ulJuf3Z5Ibov1aZk/oyY8aMBhc7Lpe0k4kq2dE4JXu8h0yZMNiGGHYUlqJLCJWqGrjkIqfFnDbZ06N+Tm0grJmIhvM/KMDlso05Jk6c6LSxiK2ywMZLo+HUVWQiGh7csNquf7Nmzcq661+y+uEaMea+tUAxmf5vu96RX4wtNIES5q7nn3/e6P7b/ZPvc7Fh5aSSE8loaRseseHX5fyBJ8kFCqJuunMlmqBgMtXoMq9h8eY43I4m8xpSCDzqQVTukksuidscLKQqc0SbsnAQYf7Od76TtNsffwsyzqXmEDwTiCD5uLaEVbZhm1XQb9TtLpNkL95CQ7/lIgqrbmBU7H/66acZcUZMBnbqg1pRcz8QsWwXjbFhYdxz2cWTFPHam4tcSMjZZjHq5AZxZ35KNgoaBjj1oC6Dy95coHgD0jF7CevERiPGpCJxL8luTPk7bHAhwUHXu1/84hcJud7xcwqauTwSg1+X8w8+J7nACwQuv/xyufrqq833IG8s0sHCPXLOzjjjDPMaitSIPPnCPffc/jQaqTmZal7ClSm95Xx1u9OcTO4ZQqgENVaxUqZSTuz8YpdOAZoyI9G2ylSqgU3UlezZRN2lU4Cmnm+qrn/J2mZrxFhTjFSVIhlzD+96l134dTn/4UlyHoPFbfbs2eZzcsXIGaOggomTIzfI8C233CIPPfSQibTdfPPNJp8sKAFHfhkScPwemskQsGKWgMuW2x/pFLj9QZiTdfuzi5j4SPQvLPcwtcu23e7s6Fk+5EjH2lzYUmphWDVHKwCzc6TzTb84mq21Euaw2ko3LyAVsucKiHhrWoYaleg4CaOtbNvsVIsUg653zD/o5RMxRq4tmpylR3rw63JhwZPkPAaEF1IcxDnnnGNIr5qJ/OlPf2pkJrL77rs3WhQpSnvyyScbmYlw1OiRG7c/cpkhWIkuXmEsqEok1b62UN3u9PhcNxdawJWMs5jtdmdvUPg7+a62EW0jFpTuS0bj23ai1H5l981CIWhB179Ucr7D2vjarnfkGFPg6l3vsge/LhcWPEn28Cggt794R7MQbyU20eTCOF3Qhb0Y7LLttqIN2GyoDF8wCqySdiqppfbGxdZW2l8ghbYVtp0eYefRq/xfptOCXGyroG24ppLYOdZhpVDxP5Fo44QKYowEKAEPosXMI8lsuj08PL6AJ8keWbHiRC4IbWYbRLbHjRvX8LW3yI7t9sdCR1oGxXzJRJQ4BlbCzIJMRIqoFkeCfM3iqUfExWyXHdSt5lSFI3PIHwSZr7ORf5oPiJanzgaLyCcEmc1Xqrq8hYZom1HIL2ON8Uc7plqMa7vekUpBMZ/tepduykc+wa8/HplCYZwLeuQcLAQoHNx1110xX0PBGgRaL4oDbeAOCDF8+umnTbEbiy+TPRG+YgGLGkQYlysmfpQavv3tbxu1DPLOkZO79dZbZfr06WYBbupvsSCzWOpxLYs0xNl2ZkPxoJgJMqBtICukXNAukGMIDG0FSaYNdUNRzATZbit1TKQ9aCsi7IxZSLGqjvi2qh+DjDF1i9S24iMbC8Ym/SuRzQQpGcwLV155pdFsJ7cY8n3bbbcZksxp1FlnneWUVF024Ncfj0zBR5I9Mq60oZFkCAfHgdHgLbITk2wiakTbIvaPoQLRZdoZqSxNpcAMYdGiRYbE0ObR3O5ULs02HtD0gWIizNFyZoO52MH0AUiPprAUQ/pAMukpdm4uH/lesjnfhRRBVqk2lbWzDYLsok/aiq9pR4qxDzzwQPM6wIb2rbfeMnMnkm2AkyUIMikVxb4RCcKvPx5hIr9Krz3yGhQ0aMEOFs433XRTw0JAmgYLLLJoCnJxKTIcO3asOUYs9omfBfS8884zFxFhFkzSMmgzPbJlUYbsYXDys5/9THbbbbeoESo1L+nfv3+DXBrRfcxLCv2onPdraz0r6aXSn3YMkl5IiOrm2qQad0Q1BSm0QjQFxE1JLxckl/dKv4pGetlkoMvOBalWEsjGTUmgyzJv6SCW1TwnQ9Heb9D1T41BUCWi+BqFIja18+bNMx8hxs8884wpwss31RQX4Ncfj1TgR5pHVnDccccZJzqin0z6CNYffvjhhhxDNLxFdnKAnNBuRIkhskSq9AgXIsL3sCJW8fp44LU8Fy6iVrrI4zZmHxW7qomcillFqpbZNgm07aUnT55sCLKrLnbpuAOqZB7pPskc49NHIYdcpAao9bNar+szcM1JMhlo3j+bU5VL5D2lajGOgx1mM0STGc/8fcg2AQPk+AptY5Et+PXHI1V4kuyRFZx55pkNnxMdphgNUkY0lEKTWPAW2V8GBGPkyJEmCky6BekX+++/v1mQ47n9IRfYVCoFP7ejpkosqZaHTGqEOVrE1XU1hnhRvVRgS+3Z5hhE4/PBHCNadJ1nbZuvhOUOGLR+ttVCUHbJpxQWjZDrZhJw37vuumvC0nhB1zsUKRi3uN6RPmW73pEOhVU0rzn11FPNs4FEu9xGrsGvPx6pwpNkj5yASBwkmWgl8BbZiYOoHHJPRKuCINeRAkku9K7V7Y+iSHX7gzAfddRRTUaGIXZEsLhUX5iI2YQJExr0bokyu1IkBDFVDWQ+6nF2suQlFfC3+R9cgwcPbjg616ipaznfttOe5qWrjTeb2ExHdomK6umFrTuNsovmhSdrnJFJBLXISXfg/ihWTibNxna9g/S+8847pp6A/OLf/va3ZmwH/xabBrW85z44IXJhvOUz/PrjkSh84Z5HVgongmDB6dGjh9x///1GvcFbZOfG7Q/CzFFkMnJRQec0fk8jqtkuzoqmyRssUsw1iJpqW9HPuS8lzNlMYVFbZY2Aklqj0W4uF6Ld0RwMY2lXZxp2/jkfU3VqVFlHSDGXd73LLPz64xEmPEn2yLgVJ9d1111njgrZwRNd++lPf2rkzZAy8xbZuXH7U8LMcTfPitSNZN3+NM1AiRckxzYvCTsvVyOgSjr5nEieEj0ilC4DYmpbZKvtc6ZSWOzUAP4v7WeTTpfzpu1COO6dzUamtaqJamtfpt3U3IMTk2TSTrzrXXbh1x+PTMGTZI+MW3Hee++9Jqo8ceJEQ6ggyrz2hhtuaGR/7S2ysw8W85kzZzaYl+D2hxYzhJmLZ5UMMbAjlRAOJWR8TDUKaBNx2+1N831JMclHBG2Iw0ozyNTfzTVsUw76mTpEalFpqhsM5h3ts/QzjfRzJbPpsl3vSKcgLcK73mUHfv3xyBQ8Sfbw8MiI25+txaxRQCJyiRBbzYFWogexKwTliExFfLMdoXZNgUOl+LgS2QhAuLVvkj+u+djJRqh5buToKzEuZtc7D49ChCfJHh4eXwIkDd1kFn9IMy5fyKVBliHN0QqM0iUlml/MpURPiY8rxYG5yh2GKGt7ae6w5jpzkZaQq1znXCOYIw8011rzmIObNvqj3abJnEawgUO7Xe2g+bsoyECMKYwtprb38Ch0eJLsUXC45ZZbDLFDigvZsjFjxhiB/iFDhjS8hkXz+uuvN4WDFHztu+++cvfddxuTBAXk5KqrrpKnnnrKSGRxdIpiRM+ePaXY3f6IKp900klfcvtLRm6MqBvH26r2QHvb+cWebHzh3Aaxo70gZGwqiGCSduKaaoZrGww2XrQXxNbOl082/Sea6x0bRohxMbre+TnWo1iQn8lpHk6AhdpFsJhdcsklMm7cOKMvygKJKx1kQ3HbbbeZ4sK77rpLxo8fbyTokEUjyqlANg1S+PTTTxspNQgKR6gstsXq9qdHyui4YgrD0fLw4cPlmmuuMe3dVNvQZyDJXBAPyDWRUSUs/D6vgexwFTtoA1ILuOjHpF3QXlz8DKLMz/jo26u+veg/etG/6Fu0F19Dmul7fGyqvYjUo0Zx/vnnGzfGSy+91GzccL3jlOXBBx80EeRiI8jAz7EexQIfSfZIGizK+VQsRdSS6BETO5auLI5o/0KCf/zjH5vXQNjIlyXifOGFFzZI0j322GMNQvTIplFo+MILLxS9TbZNJF566SUTuSfCRuSejQQpGRQAQk5IBSACR/oEETyg0WI73zYsya18h+3kpxJ7dntp1F71hVUXWtur2NJTIL+2hnEsSULN29Z0HtqLQjtORYgG0w/ZJL/44ovm1IQNNnMCpyVEjPfbb7+Cy4UPC36O9ShUeJLskTSIoDz66KPmI+5ZQWgExxUgTYfxxtSpU41RAu5xOFthAYtcnQJix3H/I488YtyvWDhZTFloFZgHsGiSquHRGJC21157raHwj2gdRWMQOQgLm5KzzjorIQIHUbQJs5o3qEJDoRFAdTfk4v2SOqGEN5H3q+2lf4Pxp7+faSOVXMAu7OT9JmtuoxuRa6+91vRV/h7tzN9ibkCukktd7zyKa44lkEKaHfeOGZFH8cI77nkkDY4fydX99NNPDUlmwSHCouTYpUWFye6KK66QAw880EzeAMcrwIJqg69Rd9DXEC23J299jf6+R2OwMKK/jMMcucbkgHM8Tb8gVQUVAAxkEnH7oz/R1ly28sPkyZMbIquQonwmgEEjFLVmxm48WWvmYHupJTdjlPGZK0OOMGHbpENw1SYdBZZkFDyYE/gbpA4tWrTInIbg/Mffom3QbyfCDMnj5Ihn4lFccyx9idqLJ554Qn70ox/JoYce6lzwxyM7yM/Z0iOnIOpC0QoRQz7qESS2quT4crRuRw9yCfIIIW7kFAcRXFSZ7JtaaBN5TTHib3/7m5x99tnGxe873/mO6RvkeQMWlw8++MB8j8jdBRdckJTbHwsTJI+LqA4EEJKjBDCT5iVhw7aC5mhfCxVRDoH0hQHai7bgYqOihhzo9kL+7GI/19OmgtJ2bB64dzbnfJ4oGLeQX9Iogq53DzzwgMk5pg/yOvoVr/vTn/5kUq/IP9Y0IY/imWN///vfy6233mrqW9CPd8GR0iP78CTZIylAeIi27L333sYkBBCNuf32282u+8Ybb2yIJuQa3//+982CiHyZrUih5I1oBWYZChZijXzwGtIHIGR2pIPXoJbh0RjkIXPcH424QNr2339/c5HzrW5/d9xxh1x00UXGWAbCTBFUU25//ExdHG0CiGug2iyreoELi5rtGqdqC9w7/TEbJJX2Iv2Ai+NwVcpYsmSJcbvk+5rGEhZJTxcU1ml7URug98gGKZl7VNc75gBIL6kABx10kHzzm9+Uv/71r+ZUI9jX+BqFGy5cQSHoniAX9hyrhcLBDTanE7/61a/kySeflF/+8pemOJm+6FFc8DnJHimBqBTE5tvf/ra8/PLLhqAQJSRCmGsw4TF5k2uIExPkIPhzCvcuv/xyufrqq833mKxZiIOFe48//ricccYZ5jVElFgIfOGem25/EECUN9LVwU0Xmu6g+bKupjsE0z3Ugpkr2XSPdBGUuVMdba5kpO3U9U4jxuTKkvdKfjHzVTKW6x75P8fGikozJtm8B3/GRl83RaT3sNG+77775OGHHzZpF/Qjj+KCJ8keKQN9XBakiy++WK677rpQIi4sckxc6Sxk3A+7fxZKWxuZKIBGopio0fp86KGHzAR/8803m8ke0kYEARDl/O9//2smSKJ/5GGTC/nRRx85f6yf725/yMmRa6qEORm3v2hGG6Q1KOnKhGRX0CGQ/qFpILbCgqsIKouog12mCiV53qSb6DMiehzNMCUd1ztSKTjhKCalj2whH+ZYThEGDx7c5NpCGg//F2m/UaNGyTe+8Q1zX4xpNrT0p3POOUd69+5tdPU9igueJHuktDMnxYKJhR353//+9yZfHwtaDEEki2hfGAYSsf4fk/W5557bcF9UT5N3aJuJ2KkiRNqIHrAY2GYiFPN4ZN/tT+2xk3X7C0ZM1ZmOC1m6dK2RNV+2UCTrojnY2YWSqZIXNfrQaD/EnI01x+/JRthjud5BjMlz90Y0mYXrcyyEl5Qa/ncweMO4RdqTU4bTTz+9wSgJmT9kLNHPJyXMvk9SLSDuRJUp6PQoHniS7JEUlPSy+ydnj8mDHD/ddceCmkwEF1j9e0xMaBYzYWJUEcw9dKWYw8MNtz+1x07W7c8mtkSskk0xCObLEhHT3y9EYsYm1nawg9hqxDeRvG879YSL55kq4VbXO/oCZIa/Vcyudx6xgy6sS6jtQJRZm/geaYH0ZaTnKDLkhGfixIkm/5wIMXJ/gNx3TiKIhGuqD6Qa8k5xuq9JKS64kRznkTdQEkGaBRMSTnbAJshKaDkyX7x4cdzIDq9jEqOinAmJ/DTIt4JJjaNz8lM9US4+2G5/XKROsEGDMLOQQdTUHpti0qZIF6cVFGxx0e80xQDXRX5GVNOOBNPniFJqVFXzZSk6wmmw0IkZ5IL3y8XRtbbF/PnzjdQfRFeNTrQtVINY24xnQpvSXqRuJLOpYeyzMYIY/+9//zPzCJsjXDAxBnIlv9sj81CHxKaUcFiX6ItaRKx9hJRAUro4bSAqzPePP/54cypqF+Sh5U76x5VXXtkQ0WZuIU2E/u5RXHA7Uc7DSbBAshsnFw2SErR31UmMYhzsnyng4LXs7G3o71GwxWJKDipEhYVYwVEqhIa/la+RZPLymGQ16gihY8K1wRGl5svphcNXMJJGsQxtDlkgisYmpJgAeeXkAsk5ju05gSBSedppp5k0DAqFiDYS8WwKLJKQXaLRhxxyiCGBtDELKH/j/fffN6keyIXRJ9HS5XXkLbJ4FjpBDoI+SR/muBmlEiJqkAYUDN555x0TnaPNyDslH5SxjBQkihI8m0Q1rdkIEQGErHBq8LOf/czMIUSPkXEjonf44YcXFUH2c0h9/9M1QG3Ho4E+hpKJqmfoOkOuMevIPvvsY8Yu/ee73/2umSvmzZvX8PsUo7NWEbhRMPb5f/y+R3HBk2SPpEEeJ/laFMWAWJMVR6Do45I7SOFDrDwzonhEnahwZmLjWAwwIUFSWGghhZqyAfg8SM5dBYQLrU0i61jdEsEkAh+ccI899liTi6sXFd42cKwjgkoUDUICceMZ2O1SbP2QqBBHoRA19G7pi/Q1iBzFRVhmQ3wTAQuwmuHoIqzHtyyoXL5g8wvQHhBhPtJ2tBV9UQukoqkHRAPjmMgzLp5sdiDGbK7JQ2fszJo1yxxzo3xSrO3v5xAxm+Gvf/3rJqgSy7RK50I2cxBl+qeuT/Qf1iD6moLTKEgyaRf6Ogr0KDTkBEPnDoIaBC3yNVDjkTp8TrJHTsECyW6eCBFRY6J6uDfxPWTmcDriayKE0RyP8jEFg6g5EWUWPo6MNZKMSx3FatGgckkQwjPPPNN8b+nSpWbS95J0jcEmhE0EBaUc06OiwAaEyLvt9keqBUf42ODyuVobqxU0fY3+xXOxc3I1p5aIfrGRtmARZLScbE1jUYtt2ggizdc8B9KqaFc2Nox5pNqIRJOOQX4xF3mh+Taus4limUOCihTcP2kQBBlIzUNylJQJXQf044MPPmjykumnQH+f3yMqfOeddzb0VzbajH2+x4mFGqQwdxDAIXDD6QhBCmy2XVeq8QgX/ml7JA0moljR42T+BuBIi6MtiAq7fnb7b7zxRsPPMD3g2NV01tJSE42lsI9jdibAaAtprAizK5FnFisQzG9jImbh49gfVzqd4AEpABA0zQEHTOhE9InUe3wBCBmbK9wfyUGECEME0PFmgSQixGYM+2eKcyB1pPpgq0takJ0WQP8iH5fv83NeR1EplfE8L6JaEI1E0jvyFeQFk2JF2gmbD9JcICuMVRQLcKuzaw40jQXSS3oKEWHai9MUngNtOHLkSNPPSZuhZoFIHX385z//uXm9J8jFO4cwT2tE2D6NIAIMmcWwirHI2FZSq6/Rj6S3sWHgJJPv6d+jv3IyYadSkP7GJo0cewUmInxPTVHo45BuT5CLD54keyQNPUpNB0pYIb2kHWjFMIVTTH6ACBOTPZM+qQXI+bAwc4yGZBDEmo/R7g9Afmxi7MLCy/0QGWeStyWGIAo4FpLrzdEyEQzyLvW4j6gbmwjbmUrbi595xLe01gixOnxBMvgZ/Yv+xPebAv2HnGgskZUg8jWnIET0iDKRI55oeoer0GJFNq+MTwgUCiMQEqKWkFyOpBNxv6PNIDcQazYoRKLJOVbJR43Key3j5J5PIc8h9Bk9oXnttdfkhhtuMGMLgq99iD6Ja6cqUgTBSQR580jOQZD17xEtZ9PHhkFB4S95yvRDBZsP0n4UbO5I9fAoPniS7JGbjvc5ySYSx9E2Ez4g/5hJkGjye++91zAxMSFSsEMUmeNEzANQO6CgRaMqRLexytbCP82VBBzRafRAiXMucnk5xkOD86mnnmr0fY4/0Xll0WPSJvpJ8RPFSvGQj+km2QRKCLQpEbbvfOc75mSCanYibDwHSB9Hs+Qw0/7ooBIZTuTUgep5otFEpiHNqHCQS04ECoLCZg/JuHyAahgTZYOAEIEjTUWLFYn84oSWiHMh45C2hdxAPmgf/h59n/ah/SHNEBUifkT8idhx3O1RWHNIvFNH5t9YczBzPf3tW9/6lonwoqYEuacwlL5CbUc8EHFGUhSSbb+Wvsj6Qg6ybowhxKRWEHSJBR9BLl4UT3mwh3OYPn26IcIspCr4TkSZyN7kyZPNESxFWICCNfQs/+///q/h98kvxWyCiZNcRwr+ONJlISaix9/83ve+ZyqZ77jjjoa0DSLMTKK33nqrITIc39lRhEwBZQqi4xQjsgDEA5J3EBRIC9BKbSJwdiQIsud1O2MDokYOJhFfG5ACFBeQG/zJT35i0gnU7Y/FNVm3P6KqPC8uIneaw8zzc1VLOZhvzbhgzBApTzbfOuh6R2SSqB1tG8v1jrQKLtQrIM7ppnAVA/JhDlFiTP8JOtzZMm6x+hcbWU4IyQHGsAoQ+FDNYjYCBE34eTydbl5H3yOqTvCFiDmBE+TdiDQHN3zRal48PHyP8MgZOMJioqOiXcHkCUGBwDKRkQvGUS8TJ18z8duRPAixuqapGgQRY6ICHKv94Ac/MIsJ0QKKiAATKykeRCc4Kg8eP4YNFgaiP5AwjkJ5T02BI2qIg75fZMe4bzsqwnvnPXiSHBvkHgcJchD0OZ4Jpw2QDzZOZ599tolCsbgSJSZ3ecaMGQlFmFnM6ds8M6KwfE6UVlMXyM8lSpuLHHmIAOOAMcJ7ZTNKNI+xxb2SRwyZSIQgQ6r5G7QbGw5yOykoo60gXvR3IoGJ2FrTRhA6j/yfQ2wCzIkKRdgU2OnPuCDrqJkQ3EAFiRNA7hWQ9w6Rpy/yvingtlN7iJiTfsHc3VT9CVbYBEnooyqXyalItFQhT5A9osGrW3g4A93JU23M8RdRQKJMTPakXUBYkIbSSZCJlagfhAOwUJOjhnsSxIjFnzw2ItAsDhBtJscXX3zR/IyJk2gDfwPwvURlq5IBUmRERnhPFIApiK5xP0RJELo/9dRTzYJGVJMIJ4sA0XYikeCiiy4yRhpEQiD9RFloG46ti01lIdtuf5ATJKEgJ6m6/UEqNT8XokokSyPMmczJjWXuwcUGMZn/C7kh/5qIsXe9yx7yaQ6hwPOPf/yjOVGg70GQzz//fDPX0te4p1/+8pcm5YGxxBjifaGHT5/i3vk5RZ2cNLB5YoNKfjXpdXoyQ142xXSATShtYUeDNY0EDWRkM0mNUlUPD4+EEfHwyCHq6uq+9L21a9dG/vjHP0aWL1/e8L3zzjsvcsABB0TWr19vvn7ppZciY8aMiZx11lnm65dffjnSq1evyD/+8Y9Gf3fu3LmRsrKyyPjx483XY8eOjWzfvj1y9913Rw499NDIU089FZk5c2ZG3yPDLNr10EMPmZ9v3bo1cvTRR0c6d+4cqaioiPTu3TtyzjnnRBYuXNjo72zbti1y6aWXRjp06BBp3rx55MQTT/zSazwyhw0bNkSeeOKJyGmnnRZp1apVpG/fvpEf/OAHkddeey2ycePGyJYtWxK+eP38+fMjH374YeT555+PvPDCC5EJEyaY57lp06ak/la0i3Eye/Zs09//85//mPEyadKkyNKlSyObN29O6m+tWrXKjJNvfOMbkbZt20a6d+8eueiii8z73rlzp+9yWUC+zCHPPvtspGXLlpHRo0ebvsd9BbFo0SIzv69bt67he08//XSkpKQk8umnn5qvV6xYEXnxxRfNuGDM3X777ZGePXtG7rzzTvPzH/7wh+Y9Hnvsseb7v/jFL+LeF+tBtLXGw6Mp+EiyR16A/GQck4gaU+RHlIIIBbJeHBWTe0yRCoVXKBZoRIFioNtvv72h8EOjYRT9cexIXjNRCo70yE0mYhMrsqaRCY4oyeXk/3oUJ0jl4USCHGaiX0TzyH8k5YD0jGTc4Oir9CcizER76Weq+kC0L9EIH/1aNYyJDpL/zN8gfYLPk4kYo0DB+yPCxzhRp0gilRQ/+ZMLj2hzI0V2nNCRKsG8Gwuc8JFSgfoEesf0N/os0XCiyEEwNpirMZxCJpDXk5dMuhDjTtM5PDzChifJHk4iWsU1BVFUdJPbSQ4ducyaj8zRNxXdTKAQFv19XseRHW5s+j3y2VA64FiSog5+Tg40R5DkjKJSEOt+IDMUE0I6yL2M5SLoUTygX5InSkoGpJK+wtEyKRnoNWvBUTIqE1pMB/FVCTs+Bsk3lfr6WvomaUaaSqFjI5nUEgg/hWG8H46nSX3C3INUJ5+z6dEU6I/MwRRkU1ANSJt46KGHTP8hFQ4pQfo1RYgEPyjGZrxAjlGGYX4H9EVSMMjhJ9WE33nmmWeizs8eHhlDk7FmDw8HEO+o7IMPPohUVlaaY7lg2kazZs1MKob9N/7whz9E9t5778irr77a8Np//vOfkUGDBkXeeOONuPdw/vnnR4455phGR4W1tbWRfMM999wTGT58eKR169bm2m+//czRpv1er7322sguu+wSqaqqihxyyCGRadOmNfobpK1wdNuxY8dIixYtIieddJI5Si1mkH5AH7rkkkvMMTDpCWeeeabpmytXrkwqzYG0CFKOJk+eHHnllVdM2sS7775rnsOUKVPM/+F4++23347MmDEjsmbNmqT/PikZHGEfdthhkfLy8siIESMiv/rVryKffPJJ0R5PF+vY4H2FMZc9/vjjkf79+5s2bN++fWTw4MGmLUgtqqmpMa+hPzM3v/POO+brHTt2RL71rW+ZNCb6O7j66qsje+65Z2TAgAEmvWLOnDlR77lY+6lHduDVLTzyAhpVJuJlVzTzORJyRIAPO+ww8z3V3kRLlKNslUpS5yUKRoi0YQahIIWCdAwUM/TvBkEqB8VbFI9Qsa+v0Qgb/8sVV7+mQJsQPUe2i4uiGCKf6jpFgSSuhmjYqjUrx51aJAlQJiHdgKIYKtIpHuLoMxf60y67/dHWRMmQkvvmN79p2otocVN9hf5KkRIazhxfE4HjmJnIGkYfFALyM3RxOdGgir8p8D+5rz/84Q/GeY1i17/+9a+NXO/QIi9m17tiHRtqEpWIsU40aH/mVA+zGcYC/YmUCAr50MTWNB3mWlIu9GvamTZCdYg0CoAyESk/9HckPDnZiHbPxdpPPbKELJFxD4+sgyjZgQceGDnqqKMir7/+uvkexUtHHHFE5Cc/+UnD6ygu+d73vhc5/PDDGyIdCo1STJ061UREbrzxRvO1Fizx9ygyKQQQ9XnggQfMe+7WrVvk1ltvbRQZIyp63333ma8pDKNAiIIbxZIlSyKlpaWm4MajMYjQffzxx5Gf//znkd12281E0TiRIGq5YMGCLxXTUdjH9+lfFN0999xzkffff99E0yggXL16dWT69OmRN99800STiSoT/aUwL1rEeOLEiZHrrrvORIqJGBM55kSF6KaPxBXf2NBnHnz2Z5xxRuTyyy+PW5DJHBmcJ23Q3yhopbDa7v/B/8V8O3DgQDOvEnH/+9//bvo4Jy7Be83H0zqPwoA3E/EoWBBlI/pLAQn5bxQgkStHEQiasAokkoiiEVkmsmHLCGmUgggy+XZIKNlRE6Tp+D/kbmKgcPrpp5vCQTunmoifCuvr7/K3ErH1zQaIbpE3SBQHK1ckk3gvRBoV5NXSZkTsL7zwQhMhIhJkv4ZIJ1FNXoORhMcXoD+R18v1q1/9yvQ3cphx+yNiRrEfkUZOMnAHpL+R+45kFf2JPEy7WI4oHdE6LnKitWAPMx30YHku6OJSzEqRK9E49Gj5ObJb5Df7CFzxjg2ePTnoFIYC5jy+JicYtzv6VyxzDe2HFK/yuqApB/Ua9POXXnrJvF/qQvTvYGyCHjLReUx+kBOkgI/6kliGTj5a7JFL+HQLj4IGGskU5KFuQaEex9KkZ9ipFqRfcNTHkWk0sChSTILJCYsKCye6yxBdjr05ckXbkwWB11D0xMQOaQnaYwOOzC+77DJDkHIJnA0hZSzyqINwPMwxO+8XoIpgg6/1Z3xkcQwasdiv8YgO2+2Po3g2cthX407HJgyCDJHCIZL+C3mIpybB8yNFAI1w+hWf/+UvfzHatKgHsInj2Bp9XL6HcoYnyMU9NjDZoL9QuAyhh8SymeL9QmBjEWTmPEg0GwbaB03kWCkXbAwITLCBo6/T59F4Jk0F8HMUi1AUoo97x0UPF+FJskdBg0nbzgNEAQNySk6xRnkhwERF1HUquDgQqYNkE4kDLCqASmsIMEQE4xMig0SUyFn+zW9+YxYAqrP5XO+Bj4jeo0QAwQa5WhxYsCZNmmTeP+SMyCVObIogkYqmOBJEIq/xEFP5T34y5IC+QySZzRVGC9dff7057UBqDbnDeG5/tusdkoTnnnuu2QwSpYOQPfLIIyavGfk2cqLJlSX/3qO4xwYna/Sz3//+9+a90Y+o4YDIopASS8mEyO/LL79s1ChoH/qnDX1/WJuzWeN/0B/5vR/96EcmIs88aSNY2+Hh4RJ8uoVHQcO2SAXBCAkklyIdinIofIoWQSFiwvE4BVlAf06EmoInjUAThYEUUwhFNAb9TyTriBRylM4CpPeCcxvEHMJNVDoXINrFYgaIgBMRZ9FUB0JIlm0DTmRcI2gUK1Hgw1GpHTHjNd4mu2lwmkAfILWCo2mbPJESQXQN3Vjk2CAVEGV1+6Mv8TPk5mzXOxwo0Yu1C/iQb+NSmTqKonLV3/IJhT422Dghg0kKCNrXbKLoH8xpIFYkmf6VSLoIRJu/j707wQX7bwX/tisbBw+PaPBbN4+iQrSJn3zPgw8++Es/1wgHOXQcDUKW+R4LKBM9R+UQFhYEwPeIspCXePXVV5tF9PjjjzdRGo4d9TWA3+Go0iXCwnuDTEHGWOj1nm07Yl3kyXfl3u3XEKEk4u4KEXAZEI2bbrpJRo4cGZUk8D3yhjG9IU2CyDKqE5jeoOJCxJicdjZhS5cuNacj9MVYChds4NjQoRfO3/VIDoU4NpiLUJxgowaZJ5WCvqTzYLQTrmRMZOinBBD0b/mIsUc+wkeSPTxiQMkLZAQJLvKOKZQCRORImyC/WV+H1BHyWhRb6WLCAgHBIf8PEDmGsHBUSdQ5XtQmkyBHENJEjjbSVUhVsViSu8r74VievMVBgwaZi88xpyBFAPDeyW/lmJ9NBrnaV111lWkn734VPmhv2p6LlAy+5vIIH8UyNnTOgfiTfsZFjjIRb95TmHOST6XwyFd4kuzhkUB+IlEgO7JCnjGFPEp0lTjzWi4FahqAQheg7mtUwOM0lQuCDCDuHIUS4WJRp9AGEqCpI0TCKSbj2J9jYwodiZJzTKtAu5R0FaxieS3HqhzXesvizEI3ah6ZQbGNDSLhONpRY7F69WqTr0wb4JwHgfbwKGZ4W2oPjyZAusSjjz5qjsY1IoxJBISXPGUluagIUKx34403mq85viSqDBGmclzzjyHcLLgU/JEv6uHh4ZErkBaCggebfOa3N954w8gUknd93XXXNcxhPhrsUYzwkWQPj6YGSXm5KUIhJ1HBcawNcvlwUTv22GMbfQ9ZOCSTgKZlkBdK5TfyXh4eHh65gKptUFtBSggEGTJMLjGpIUSSKTAFniB7FCt84Z6HRwJgMbGLooJyXBT2cVRpF+ZQzIMKAcV7SrYxenjooYfkggsuMCYRHl8AdQaOtilq5CJFhYi9gmI1NRbQC5k0G2xkOC6mOA1TA1QfaHMPN+Gfee7A+CEV5O9//7sxRVIyzNzG+EGVh3nKw6OY4Umyh0cKCCoSaL6yHXEhFYOFBrIGENUnl5k8v+9+97te+igAouvI8U2YMMFcyOidfPLJRp5PQaSeXFG9XnjhhUZ/g6IqjB8otiJChkkMqg+2VraHO/DPPLdAr50CRXSPo81t3uDDo9jhc5I9PLIETAmIakLkKOTxeX5Ng2NgbMVRCyCSzKL+73//O+prSXfBTQ4jDfIoNeUFEgCZdsEO2KNp+Gfu4eHhCnwk2cMjQ7BTMki9QFoJdzV17vN5frFB5JdoMA5dqgwCkOLCpY5UFY6C1fpbFUMojjz66KMbpcFgmEBuuIfb8M88d/ARYw+P6PCFex4eGYJ9bAnRw2QAQxKP2Jg6dappK5RBaCtSJ5Da02LJ008/Xfr06SPz5s0z5hqkZECOURqhGh+jF9vlDOCExs883IR/5rmH37B7eESHJ8keHlkA5I3LIz7QmMZohbQKJKnOOeccE4WHKGsKBSA6jF0whBlr5nhSelrF7+Em/DP38PBwFT7dwsPDwxmwkRg4cKAhwLfccovsueee8vvf/z7qa7H9hiTPmjXLfI1dMMYIGDzYICWDaLKHm/DP3MPDw1V4kuzh4eEsiALb+tQ2kNdbtGiRIcuAdBbMWjBHUKCAgXmLLc3n4Tb8M/fw8HAFPt3Cw8PDCfz0pz81eceoUWzatMkU7lGohyUwUm64f5166qmGFM+fP9+8Hj3Xr33ta+b3sRBGBePKK6+Ujh07GpWEq666yhgjHHnkkbl+ex5R4J+5h4eHy/Ak2cPDwwmsWLFCzj77bBP9hfBiLAJBxsIb0wMKvLAHJ18Zoowz2DPPPCOtW7du+Bt33HGHMW0544wzzO+gJPLwww9LWVlZTt+bR3T4Z+7h4eEyvE6yh4eHh4eHh4eHRwA+J9nDw8PDw8PDw8MjAE+SPTw8PDw8PDw8PALwJNnDw8MjCSBNh+4y9uK2IgOFhTj8NW/eXA499FD55JNPGv0eKh3f//73TbFhy5Yt5Stf+YqxKc828v3+PTw8PLIFT5I9PDw8EsT48ePl/vvvN0WFNm677Tb53e9+J3fddZd5DZrNFByi0qGAlOIgiGrHu+++axQ7TjzxRGPHnC3k+/17eHh4ZBURDw8PD48msWnTpsigQYMir7zySuSQQw6JXHbZZeb7dXV1kW7dukVuvfXWhtdu37490rZt28h9991nvl6/fn2koqIi8vTTTze8ZsmSJZHS0tLIiy++mJXWz/f79/Dw8Mg2fCTZw8PDIwFccsklcsIJJ3xJc3nevHmyfPlyOfrooxu+V1lZKYcccoiMHTvWfP3RRx/Jzp07G72G1AbstfU1mUa+37+Hh4dHtuF1kj08PDyaACkGH3/8sUlFCAKCCYLW13y9YMGChtdgv9y+ffsvvUZ/P5PI9/v38PDwyAU8Sfbw8PCIA6yvL7vsMnn55Zelqqoq5usohrNBMVzwe0Ek8ppiv38PDw+PXMGnW3h4eHjEAakGK1eulFGjRhk3P6633npL/vCHP5jPNQIbjKjyO/ozCuF27Ngh69ati/maTCHf79/Dw8MjV/Ak2cPDwyMOsLbGEnvSpEkN1+jRo+Wss84yn/fv39+QyFdeeaXhdyCUENExY8aYryGoFRUVjV6D/fa0adMaXpMp5Pv9e3h4eOQKPt3Cw8PDIw5at25tCtRsoBPcsWPHhu8jj3bzzTfLoEGDzMXnLVq0kG9+85vm523btpXzzz9frrzySvN7HTp0kKuuukqGDx/+pUK6sJHv9+/h4eGRK3iS7OHh4ZEmrr76atm2bZtcfPHFJiVh3333NTnAEFTFHXfcYdIbzjjjDPNaIrwPP/ywlJWV5bz98/3+PTw8PDKBEnTgMvKXPTw8PDw8PDw8PPIUPifZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8PDw8PDwC8CTZw8PDw8PDw8PDIwBPkj08PDw8/r/dOiYAAIBhGFT/qncvGkAFA+CTZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAICQZAAACEkGAIB9B1Qw0c6w51/hAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "flight.plots.trajectory_3d()" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "aa85c425", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The following attributes were create and are now available to be used: ['time', 'altitude']\n" + ] + }, + { + "data": { + "image/png": "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ZktwJEGSTE77x+FYVUQHf2eMbOWjSlhDV457RHqDARGp2etltvnBgwerSVer8uOPP6qgJzW1pJeQ1p49e9TkrfVRbb906VI1kSzpj6TMPDz95zEsPHBRPQ/2csAP49ur+bfINHOP7ujgr4rUHu2LTlN5RytOlPRcO3AxXZU3156Ct6M1RoSUBEdSi8RRs0mXGBARfvjhBzz55JP47rvvcP78eQQEBNToqnh5eRncnEDGONeRLqyNSMS9vxxQOSQywfq0fs0wc2gw7DheDV2uPeoc4KrKa0ODkZCRh1URCaqn2qoTiUjIzMfcPRdUkala+jXzUMGRBEnNPOv+jyyispjpZuKysrLw66+/4rHHHsPIkSNVLVBVzWf333+/mhxPamqkaEeCliazjz/+uHRbee3rr79W+5PJbmXS2x07duD06dPo16+fqk2SGe3PnDlT7jP+/vtvdOrUCba2tmjatClef/11FBYWln6GuOWWW9T+tc+v9T7t8Xz11Ve4+eab1We/9dZbtXwVTbMH2dRlxzDkm50qGGrp7YgdT/XC+yNbMRiiKnk72eDesEb49d4wJL0xFP8+2l0Nv9Dc0wEFRRqsPZmkahubv7serWZtwPN/H8fmM8koLCrmVaU6xxqiOiBdVLML86EL9pbWNUpeXbx4MYKDg1WZMGGCqil65ZVXrtiHzPwuQc+rr76KiIgItc7R0bHK/b755puYPXu2Ki+88ALuuusuFazIvGJSA/XAAw/giSeewMqVK9X2q1evVp//6aefquY7CZYefvhh9dprr72mmuW8vb0xd+5cDBs2DBYWFtV6n5Ysy0SvH330Uel76fqEx2fgrp/342BMunr+WI/GKnGa021QTacVGRDkqcrsm1vjZGJm6ThHW85eKu3W/8HGMyqJWxKyR7byxrCW3qpZjqi2MSCqAxIMOf78EnQhc8LbcLCq/hgv33//vQoohAQamZmZ+PfffzFo0KBy28ls8S4uJTOO+/qWDPt/NVKbNG7cOLUsAZHUCEmgNXToULXu6aefVttoyUSs06dPx8SJE9VzCZ4kqHr++edVMKNtlpPk7rKff633aUlAJkEY3Zh5ey7gsT8Oq3GFPB2s8f24dhh9eRoIohvRwssRz/aV0kx165fEbAmOJP9IphT55cBFVaRpVrryj7yceyQDRNbkj0CiqjAgMmFS07N7924sWbJEPbe0tMT48eNVTlHFgKim2rZtW7rs4+OjHkNDQ8uty83NRXp6OpydnbFv3z5VCyQBjlZRUZHaJjs7WzW9Vaa67wsLC7uh8zF1+cXA5CVH8d3uaPV8cAtPzLuzAxo4czZ6qn1SIzSuvZ8qRcUa7IpKwT/hJbVHMkCk1CBJeWF5OALd7VVgNKyFBwrYskY3gAFRHTVbSU2Nrj67JrVDkmvj7+9frrlPEo6lq/2NKJu0rP3rrbJ1xcXFpY+S+zN27Ngr9iW5QVWp7vvqoxecsYq8lI0ZB81wJjNadaefOSQYLw8KYndpqhcywrn0WJTyzk0haiqY5eElidnrTyfh3KVsfLb1nCq25mYYmnIAo1v7qoEhfRmwUw0wIKoD8mVfk2YrXZBAaP78+fjwww8xZMiQcq/deuutWLBgAdq0aXNFs5nUvtSFjh07qhqr5s2bV7mNBFQVP78676PrtzI8HncvOICUHDO421th4d0dMbSlNy8p6Uxjd3tM7tlElay8Qvx7Kqmk9uhYPGIrjJgd1sgFo1r5YlQrH7T3d2bTGl0VAyIT9c8//6haoEmTJqncoLJuu+02VXskCchlSc8ubY5Ru3btVHNUVU1ZNSXJ2tIrrVGjRrj99tthbm6Ow4cP48iRI6W9wuTz5bN79uwJGxsbuLm5Vet9VHNSUyijDM9YEQ6NBghy0mDF5B5o7s2pN0h/yOTAksMmJf/mfMz5dSXSPFpgZUSSGvNo74U0VV5bHYGGLrYY1dpHBUf9m3vClkNDUAUMiEyUBDySJ1QxGNLWEL3zzjvYv3//FT3NHn30UZVnlJycrJKWtV3vb5QkW0uQ9sYbb2DWrFmqNqhly5Z48MEHS7eR2qxnn30W3377rWrmi4yMrNb7qOZd6h/+7TDm7y3JF3qoayMMsYpCYyObsLOouBiJuZm4mJ2OqPRkrMmNwc6Da5CQl4XU/BykF+QioyBPFekoUaTRwNLMHPcHdcYr7QfX6LMKiosQn5OBmOx0xGanIz43E0m5WbiYnab272XrgDDPRujn2wwN7Bl0Xm/NfDMn4KZBzfH68BDEpeeWNq2tOZmI6LRcfLk9ShUHaws1EOTo1iVjHslwAEQMiEyUjN1TFWmGkhoCIQFIWV9++aUqZUlgUpb2vVpSs1NxnYxHVHGdBDfaXmiVGTVqlCoVXet9FT+HqiYD5439cY+azVxyNz65uTUe7toQK1ZEGexlKywuwqn0JBxLjcPRlDgcS43HsZQ4ta5QUyEL92j5sbEq8+qB1Wjv7odRAa3V82JNMSIzU9Q+L2SlIrZM4BOTU/KYmJsFDar3c9jK1Qc9vZvAz94ZnjYOsLW0gq2FJVq5+KCdux8szDl8XHVI/tCkrgGq5BYUqXyjv4/F4+/j8biYlos/j8apInlxXQPcVM2RBEitfZ3YtGaiGBARkXIkNh2jvt+NqJQcuNha4rd7wzA42EuN7G1IpCZmR0IUdiREYmfieexJuoCcosrPwQxm8LVzgp+dEywyctEhsDkaOrjB3cYOzta2cLK0gZOVjeqsYGlujjnh2zDv9F6M/ncu7msehuMSXKXGI6sa445J7ZL6LHtn+Ng5wdPWAX52zupzorPSsC3hHA4kx6h9SqmMi7UtenkHYlSjVri7WUc46nmuor6Q5rGbQnxU+UKjwYGLaaXBkUwtIhPSSnlp5Qk0cbdTeUcSHPVp6qHGSyLTwICIiNQUHGPn7UFmXpEaNfjvBzqjpY+TwdQAbU+IxN8XjqsSkZZ4xTYOltZo7eqD1m6+JY+uvghx9Ya/vQsszS1U0LdixQrc1OWmq07r8l6nm/BH5BFkFubhx9N7S9fbWFiipYs3Ah3dVcDTwM655NFeAiAXNLgcAJmbXf3L9VJeNjbEnsahSzGqVikpLwu5RQXILMjH/uSLSMvPxfLocFWe37tcBWVPhPREkIthTZ+j66Y17WS0Mp3IxbQc1awmAZIkaEdeyintteZsa4mhwV6q9kiCKQ8HDghpzBgQEZm4Bfuicd+igygs1qi5pP64L0zvRwJOz8/F6osRKgCS4EACibK1PhL0dPNujO5ejdHduzGCXbyuGYxUh6+9Mxb3m4Cfz+5HkLMnQt180ca1AZo7e6jA6ka529jj1iZtVaks5+ngpRisizmJ70/tVk1+n4ZvxWfh2zCyUQiead1H5SBxkMKa8XexwyPdm6ii7bW27Fi86rkWn5GH3w7FqqIdEFKCIynB3hwQ0tgwICIyYR9uPINpfx9Xy3e098OPd7aHjaV+Tm1yITMVf50/qoKgDXFnVKJy2UDipoYtMbpRawz2awFXm7pLAL+pUYgq9U1yhzp5NlTludB+WBtzCp8d36oCQm3tmOQYTWnVG3c27aBqrej6e60VF2uwNzpV1RxJgHQ4Nr10QMjn/wlXNanSrCbBUa9Ad1hasGnN0On0f4zMLSWjJJ84cQJ2dnaqF9P777+v5tUqmxArA+998803qpt4165d8fnnn6N165KERpGXl4dp06bhl19+QU5ODgYOHIgvvvgCDRs2LN1G3vvUU09h2bJl6vno0aPx2Wefqakgaot2kEEyTKZ0/+SXvQRCH20+q55P6ROID0e11rvBFnMKC/Dn+aP44dRu/BtzulxicgtnL4wOaKXyaXp4N6mVGhpDIbVdQ/2DVYlIS8Anx7fgx1N7VVPb/VsXY/q+FRjXpJ1K/pa8JRszc5wuzEB4WgLcbB3gYGWtmhGtzS1Yo1TVNTY3Q5cAN1XeHN5SDQipmtaOx2PD6WScTsrC7E1nVZGRtW9q6a269ctca/KcDI9OA6JNmzbh8ccfR+fOndVAgS+99JIaJPD48eOlIwtLV2qZIFRmYW/RooUaW2bw4MFqMD4np5IchylTpqheU4sWLYKHhwemTp2qxqaRaR20E3nKXFbR0dFYtWqVei4TgN5zzz1X7W1VXTJgoZAxeq424SnpN7l/wtCSiGsqv7BYNZHJvFDig5GtMLVfU736YpQv+S/Ct2Pemb0qb0arl08gbm7UGqMCWiHYhQNECrkOX3S/FW91HI5vInaqxG/pzv9Z+NYrL+zfB8s9ld5roW4NVPB0V7MOKt+Jqh4Q8vFegapk5BZizckEVXskXfuTsvKx8MBFVSzNzdC7qbsaLVtqj5p5cpR8Q6HTgEgbnGjJTOYyo7kEMn369FG1QzLDugRK2qkZ5s2bp+bBWrhwIR555BGkpaWpMXV++umn0vm3fv75ZzVQ37p161R37PDwcPVZO3fuVDVMQsaykQlHJbAqWyN1PWQOME9PT1y8WPIFI0GRDBBIhlMzJMGQ3L/U1FSjrimS7sfj5u9Tf+XKL+65d7THhE7/1aTqkuTIrIgOV1/oa2JOlq4PcHDFfUGdVQJxoJOHTo9Rn0mz4fS2AzC1TV/VtLg9IUoNMyD5VTLWUXJmGootLVSPuPzLzY25RYWqF56UF/Ytx4AGzXFX0w4Y4hesksL1KUjWJ062lri1rZ8qMtea9FAr6bUWh+PxmaoGScozfx1Tk8+WdOn3RbfGbmo4C9JPetXILMGNcHd3V4/nzp1DXFxcuaklZITivn37Yvv27SogkuBJ/qIvu42fn5+adkK2kYBox44dagBCbTAkunXrptbJNpUFRNIMJ0VLJiEV8lmV1SA0aNBAba8NisjwSLOq/LwJCcbly6A6tUXabfS9Zik7vwi3/7Qfa08lw9bSHL/e0wHDqtGtvq7PL7+oED+d3Y8Pjm3C2cxLpYnRN/m3xGPB3TCoQfPShOi6OgZDuYfVdbN/K1W05LzWrl2ratelF53kX2UXFiAxLxMbYs9gwbkD2J4YhXUxp1QRLla2akykVi7eCHHxVsvSi66hvYte5ifp8h52aeikyptDm+NMcslca1K2nEtBeHymKjLyu6eDlfo/NyLEG0NaeMLJxtJkf0br8/yqu0+9+amWLyAZBLBXr16lc2hpv5y0s6VryfOoqKjSbaTJSqZxqLiN9v3yKDVPFck67TaV5TdJ7lJFa9asqXK6CqlZkCkjpPlPZnBnLZFhkJ+97Oxsdd+E5KHl5+er3LaaTHIrXzj6KqcQeOuYGY6lmcHWXIMXQwpRfGYPVlx7HMI6O798TTHW5cVhSW40kopL/vhwNLPEIBtfDLdpAJ8cWxQePI1VB0+jvujzPayr85OpnZ9HAOJcvLAlPxHb8pNwvigLaQW52JEYpUpZUr/R0MIezS0c0czSEc0tnBBo6QAbM/3I4dKHeygzKz7tD0zyAQ5eAnYnm2F/CpCUVYCf98eoYmmmQRtXoLO7Bp09AO+q57DWu/OrS3VxfvL73aACoieeeELNQbV165Xt3hWrbbV/vV9NxW0q2/5q+5kxY0a5UZqlhkia4aQmSoKdqkgz3MqVKxEbG1vl51YMouLj41UAZ6wBlCGcozR7StE2eUptopTqHG/Fv771TWpOAUbN3YtjaWlwtrHEsvs7oUeT8n9A1Of5yTQY353ajQ+Pb1ajOgsZp2dqqz54MKiLGgSxvun7Payv83vg8mNeUSEi0hNLBolMS8DxtHiEpybgQnaqama7UJStyob8hNJBJzvL1CM+TdHftxm6eQXA1qJ+r6O+3sNxlx8Li4qxPSoV/xxPwD8nEnA6KRsHU4CDKWb49gwQ6uuEESFeGBnijbCGLld0cNDX86stdXl+2hYegwiInnzySdX7a/PmzeV6hvn6+qpHqcWRJimthISE0loj2Ub+mpe/5MvWEsk20mtNu418IVeUmJh4Re1T2aY5KRXJjbrazZLPGjNmjGo6k5qGa5Faib179yIsLKz0C9nYGNI5SgAkTalNmzatcfB2rZ8NXbiUnY9h3+/F/ug0uNlZYc0j3RDW6Pp6Vt7o+cmX7NcRO/D2oX+RkFuSwN7IwRXTQ/vjgaAuaooKXdPHe6iL85NtOtkGoJN3wBV/RMblZGB/cjT2JkVjb7LkH0WXjA5+uTbp3aMbVJNab59APNemH4b431iOprHcQzmkgcE+qsi02REJmaV5R1vPXcKRuAxV3ttwFj5ONhgZ4qN6rQ0K8lTDAej7+dWWuji/6u5Pp99O8p9LgqGlS5di48aNCAwMLPe6PJcAQ6LGDh06qHUS/EjvNOmeLzp16qROVrYZN64kFpfamaNHj6oeatpaG8lP2r17N7p06aLW7dq1S63TBk21SQKzik14V4uKJXiTYMFYf8hN4Rz1UVpOAYZ+s1MFQ16O1lj3SHe09av/iUNlrq9FZw/i5f2rcO5yjpDkosxoOwATm4fBWg/zUahyUuMtk8+OsG+FEY1alf4ej8pMwYa402pohPWxpxGbk16ajzTErwXeDxuB9h7SOEdaMrCjlGn9myE5Kx8rT5T0WpNHGRDy+93nVZF8v4FBnrippSds/0trpTqg099E0uVeeov99ddfqgu9Np9H/kKXcYnkP590qZeZ14OCglSRZcnhkW702m0nTZqkutpLl3tJyJYxiUJDQ0t7nYWEhGDYsGF46KGH8PXXX5d2u5eu+Tfaw4xIH0m34OHf7sLeC2nwsLfC+ke7o02D+g+GZFTl5/b8o0ZYFjKlxcwOQ9SM8VYmNG6QMZPf002c3HG/UxfcH9RFBUgyfYrUBn5+YrvqMbhm2Unc3bQj3uo4TG1L5cmUINLbU4oMi7H5bLLqCSoB0rlL/yVpA+aYE71d9ViTQSE7+LuwJ6CxBETaWdNl5vOK3e/vu+8+tfz888+rpqfJkyeXDswoic3aMYjERx99pJpipIZIOzCjjFukHYNILFiwQA3MqO2NJgMzzpkzp57OlKj+yPQDI7/fhR1RKaqZbJ0OgqFzGcl4dvffalBF4WxlixdC++PpVr3gwAlJjT5AaunqjY+63qzmWZOawUXnDmLB2f34LfIQ7m0epprSWnD+tUrJZLKDWnip8vHNrXEsLkMFR8uOxmHX+RQcuJiuyutrTsLfxRYjL08lMiDIE3ZW/CPjRui8yaw6/7lmzpypSlVsbW3VqNNSqiI1RzI+EZExyykows1z92Dz2UtqYkrJGWrv71KvI0u/d2Q9Zh3ZoJJvLczMMbllD7zafrCa3JRMSzNnT/zSbwKmtemHF/Yux7+xp/DdyV34/uRu3NK4DZ4P7YcungGs5bjK95/8MSNlWp8mWPjnChQ2DMXyiCSsiUjExbRcfL0jShV7awsMDvLEqNa+KkiSPCSqGTbeExkJqWq/9ce9anJKRxsLrHqo63UnUF+PLXFnMWnbr2rSUSGD/H3adYyaYZ5Mm8y/tm7YI9gWfw7vH9mg5l1bEnVElaZOHmpS2o4e/ujg7o8OHv6w04MEe33kag3cFNYQk7oHqkFWN55JxrJjcappLTotF38di1dFOjd3aeSqkrJHtfJFaAMnBp3VwICIyEjmJpv4ywGVkCl/KS6f1BXdm9RPrkZGQS5m7F2h8kWEv70LPu46Grc2bstfwlROT59ALPMJxLGUOHxwdCN+OXsAZzOSVfmhZDxI2FlYqQl6ZXqWkQ1D4Gtf/7lvhsDWykLNmybl87EaHIpJL2laOxancgd3nU9V5eWVEWjsZqea1SRA6tvMQ28ncNY1BkREBk6anp/+8ygWHYxR03EsmRiGPs3qZ4qLtRdP4qHtv6leRuKhFl3xQeeRcLGuu9nmyfBJreGPve/AnG63qF5pMnXIgeSL2Jccrbr1L7twTBXRzMlD5aAVaYpRqClWo2xLL0WZzuW2Jm2ZnH+5aU2axqW8MrgFYtJysTy8JCl77clERKXkYM62SFVkdOyhwV4qOJIJaT0d2bSmxYCIyMC9ve6U+kUn5t/ZAUNb1v2kp6l5OZi65281C71o4uiG73qOw0C/oDr/bDIejlY2GB3QWhVtcH8kJRbLzh9XAZEESmcykq94nzTLrrncg/GR4G64OaA1Wjp66uAM9JOfiy0e6tZYlez8QtWMru21FpeRh98Px6oiYz/2aOJeWnvU0tvRpGt1GRARGbCvd0TilVURavmTMa1xZ8e6H+tlU9wZTNi0ENHZaWrOsSdDeuLtTsPVlxvRjZAv47bufqq83H4QYrPTVUCUWZCnEvStLg+WujHuDL6K2ImL2Wl49cBqVWSOtdZFdvC7FIPOPo15Iy6zt7ZUidZSim/VYF90mhoMctmxeNXMJoNCSnlheTiaezqUBke9At1hZaGfMwvUFQZERAbq90MxeOyPI2r55UFBeKp30zr9vMLiIryx/1+8c/hfFGs0CHL2xA+9xqOXT/kBVYlqiwwCKaWifg2aY0bbgVh87iB+PXdINbtJgB6NNKxe8ZmaPkR6Nsp29B+ZDqRzgKsqbwxrifMp2WoqEQmQ1p9KxumkLHy0+awqrnZWGN7SWwVIw1p6wc2+/qfUqW8MiIgM0Nazybh7wQHIyBUPdwvAG8PqdoDR+KJcDFjzDXYmnVfPZWBF6UHGWiHSFZkeRMY0kiLDPfx7MQKzti7H9oJkbIg7gw2rzqjea0+G9EL/Bs3gZlP5pNymLMDNHpN7NlElI7dQ5RtJ09o/x+ORlJWPXw5cVMXC3Ax9mmqb1nxVTZIxYkBEZGBOJWZizNw9yC8qxpg2vvji1rrtzbU48hCeSd+PbE2RSm79psdtGN+0fZ19HlFNSTf9oX7BKHI8g7l9e2B2+BY13pE0rUkRbtZ2cLa2ha2FJcY1aYdHW3aHn339jdGl75xsLTG2bQNVioo12BWVUtpr7Xh8JjacTlbl2WXHVa6RjJQtAZL0ZpWAyRgwICIyIDLn0YjvdiM5uwCdG7liwd0d6uyXkUzG+szuv/DliR3qeTfPADXIHqdeIH0W4OCKz7uPxfTQAfj4+GY15pEkYafk56gi3jy0Du8d2YB7mnXClFa9EepeMnm41DRlFubB08bBpJOLLczN0CPQXZV3R4TgbHKWSsiWvCOZVuREQqYqszacUVMDjbg8WvaQYC842xruGFIMiIgMRF5hkaoZOpWUhQA3Oyx7oLNKmKwLFzJTcduGediddEElTt9m2xDzhjwMOxvbOvk8otrWyNEVH3YZrYokZZ/LuIScogKVpP3Fie3YGn9O9ZKU4m5jr7rvx+dklI6lJblxvX0C1WMbV19YXE7oNkVNPRzwdJ+mqqTmFGC1TER7PB4rwhPUH2fz90arYmVhhn7NPNRcaxIgNXY3rGZKBkREBkC6Iz+w6JDqDSJTcqx4sCt8nesmOPk35hTu2PgzkvKyVDPDvJ7jUXzoDCw5GSsZKMl109YCdfEKwJ1NO2BHQiT+d3STqkG6lJddbnvpvSYJ21KEi7Uteng1QW9fCZKaortXY5MNkFztrDC+g78qhUXF2BZ5qbT2SP5YW3sySZUnlx5VI2RrgyOp0Zakbn3GgIjIAMxcfRILD1xUAy/+MTEMrX3/m9y4NoMumVbhpf0rVS8ymUbhjwH3oqGtM1YcKsnDIDIW3b2b4I8BTVTtkQwsKnPvBTq5qxyj3YnnsSX+nKpF2p4QhbT8XKy8eEIVEezihRfbDsTdTTuabGAkLC3M0beZpyr/G90aEQmZJcHR8ThsO3cJR2IzVJGx0mRutREh3ipAGhTkCQcb/Qs/9O+IiKicxQcu4o21J9Xyl7eGqlmwa1tWQR4mblmEP6JKuvHf1zwMX3S/VSWrFhQU8I6QUdceVZxvT7rra7vsy3AThy/FlgRICeewLuYUItIS1f+XT45vUaNtS3BFQLC3oyrT+jdT+Y4ylZAkZa86kYj4jDz8sPuCKjaW5hgY5KkSs2UiWn8X/RjZngERkR47EJ2G+xeXVNtP69cMD3ar/QHnLmalYfS/P2B/8kVYm1vgs263qCk4TDmplEhLmoo7ejZU5enWvdXcfV+Eb8e7R9ar/zM9ls9Rw1C81+kmeNvVfs2tofJwsMaETg1VkYmnJRlb22st8lKOyj+SAhxBx4YuuCnYE24ZJTXVusKAiEhPJWTkYcyPe5BTUKwGRntvREitf8b+pGiM+vcHxGSnq541Swfex4EWia7CycoWL7QdoIKg6ftWYO6pPaosjTqKtzsOwyPB3U26Ga0y1pbmqmZbysc3t8axuIzLwVE8dp1Pwf7oNFUAczgERuORnnU7yGxVGBAR6aGComLcPn8vzqfkIMjTAQvvllyF2q2xWRJ5BPdsWYjswgK0cvXB34MeQFOn+pkUlsjQSW2QjNQutamTdyzBwUsxeHznUnwavhXPtemH25u0U+MeUXlS89ymgbMqMwYGqT/8ZCLaP4/EYs2JeAxqobs56RgQEemhKX8ew+azl9TM1H890LnWh83/+NhmPLN7mVoe6h+Mxf0mcIZ6ousg+UN7R03B1xE78NL+VSq/6MFtv+GR7X+gg4cfungGoItnI4xo1AqetsY5wvON8Haywf1dAjChQwP89c8KBLjqLp+IARGRnvlmRxS+2B4JSeGRgRdDfGovL6FYU4zpe1fgg6Mb1fPHW/bAx11vZpd6ohsgTWSTQ3piQrNO+PbkTnwdsVMNBrk3KVqVLwDVe+3eZmF4oEVndPRoCIvLOXrmZmxe07LS8aVgQESkR2S4/CeWlvT0emtYSzVvUG3JLyrEA1t/xYKz+9VzSQJ9PrQ/k6eJaok0kU1t00+V85kp2JkYhd2JF7Au9hQOXYrBNyd3qqIlg57KJMnt3f3Q1SsAfXybqmWO+aUbDIiI9ERSZp7KGyoo0mBsqC9mDKy9mbqlZ8yt6+djbcxJWJqZ4/te49SkmERUNwIc3VQZF9he9ZzaEn8Wn4VvUwOfaqcQ0UCDk+mJqvwaeUitc7KyQU/vJhjRMAR3N+vISWnrEQMiIj0gkylOWHgAF1JzVRL13Dva11rNjUxHcNPa71QXYQdLa/ze/14Ma9iyVvZNRNcm/5f7+DZTRZqtk/OyVe2QzBd4LDUOB5IvqjGOZKwjGQRy1cUIVZ7b+w/6+TZTo2sH2rshoTAdXXKz4GvpwprdOsCAiEgPvLX2JFZHJMLOyhx/3BdWaxMknstIxuDV36j5m7xsHbBi8IMI82xUK/smopqTnCEvW8fS5/4OLhjiH4wX1B9GxTiaGqdqkX48vRdHUmJLgyOtF34/BFdrO9XE1te3Kfr6NEOYZ0NYW/Dr/EbxChLpmEyU+Prlkai/uq0tQhs418p+T6YlYuCqrxCdnaa6068e8hCaO+uuSysRXTs5u527nyrPtO6jAqKNcWdwNCUOp9IScTTxIpKK85Can4PVFyNUEXYWVujh3QSjGrXCfUFh7DF6nRgQEenQhZQc3L1gP2Rw1oe7BeDesNqpvTmaEotBq79RzWUhLt5YN+wR+Nm71Mq+iah+mtnauvupImQKnRUrVqD/kME4k52icpI2xZ3F5rizaiLmf2NPqfLqgdV4P+wmPBzcjT3YaogBEZEOB18c99M+JGcXqKHrPxnTptZGnx6y5huVpyA9VtYMfbhcFT0RGS6ZX7CDh78qT7XqrXKSwlMTVDAk3f2Pp8bjsR1L8MvZg/iwyyg2kdcAB0Ag0pFXV0VgZ1QKXO2s8Pu9YbC1srjhfe5MiMKA1V+pYEhyDNYPe5TBEJGR5yTJ5LQSHB2+eSo+6Xoz7C2tsDn+LDr//YlqNv/t3CEUFBfp+lD1HgMiIh1YdzIR7284rZa/G9cWgR72N7zPPYnnMXTNt6qXSh+fplg79GF22SUysRwkCYyO3/Ic7m3WCeZmZlgfexrjNv6Epr+9g1XRJ3R9iHqNARFRPZO5e+5ZeEDlDT3SvTFubVuSI3AjpNvukDXfIr0gV3XTXTnkQTUJJRGZnsaO7pjX506cu+1FvNxuEHztnFTniuFrv8Oj239Hal7JOEhUHgMionpUXKzBfYsOIi4jD619nTB7dKsb3ueRS5JA/bXqeSIDuskkrfaWtTv3GREZHhkY8s2Ow3D2thfxVEgvtU7yjEKWzsIvZw+o/CP6DwMionr0yZazWHkiAbaW5lg0oSPsrW+sX8Px1DgMXP0VLuVlqwkkZZwhRyubWjteIjKOROxPuo3BhmGPooWzF+JyMnDXpgVo++eHmHdqD7IK8nR9iHqBvcyI6sn+6FS8sDxcLc++uTXa3OB4QyXjDH2NxNwsdHD3x6ohD6m5lIiIKtOvQXMcHjMVs45swP+ObsKx1Hjct3UxHt+5FDcHtFa/R6wtLFRtc3JutuqcIZPSDvQLUlOJGPvvFwZERPUgK68Qd/68X81TdkuoLx7t3viG9heVeUn1HpG/9ELdGjCBmoiqxcbCEq+0H4wnQ3rhixPb8cOp3Wok+4VnD6hSme9P7VaDP97SuA3uadYJg/yCjHICWgZERPXg+X/CcTIxC/4utvhuXLsbmocoMTcTQ1Z/q5IkW8qgi0MfhoetQ60eLxEZN1cbO7zYbiBmtB2AnYlRWBF9AucyLqnu+S7WtvCwcYCnrT3iczLx1/ljagJabdAkSdrSk7VIU4yY7HT1h5nMkzigQXPcF9RZjZFkiBgQEdXD1BxfbI9Uyz/e0R7u9tef8Jyen4vha75Tv5wCHFxVzZC3nVMtHi0RmRL546y7dxNVqvJ+2AjsSbqA+af3YtG5gyoA+jXy0BXbyTxsn4ZvxS0BbfB6h6EIdW8AQ8KAiKgOXcrOx/2LD6rlJ3sFYlALr+veV25hAcb8Oxf7kqPhaeOgRqBu6OBai0dLRFR50NTFK0CV2V1GqznUzmZcgoWZGRrYO6OBnTPiczOw+Nwh/HruEJaeP6rKUP9gPN6yh5q8Vprq9J3+HyGRAZv8xxHEpuch2MsB741oed37KSwuUr1CNsSdgaOljUqgDnbxrtVjJSK6FmsLS4wKaF3pa7c0DsWr7Qdh5oE1+D3ySOkEtE5WNiop+9Ymoejm1RhmMFNjpnnbOupVcz8DIqI6sujARSw+GAMLczP8dNf1d7HXaDRqbiL5i8va3ALLBt2PTp4Na/14iYhuVCtXX/za/16czUjG5+HbVK3Rxew01dQmpSIJiiTnKNTVF7l5cQjNSkVT1+uvSb8RDIiI6sDFtBw89scRtfzKoCB0Drj+pq23D/2L707uUsPwL+o3Af0bNK/FIyUiqn1NnTzwYZfR+KDzSOxOvIAlUUdUicxMgQYaVWsk0wwl5GaW1iSJ/pdiGBARGQup0Zm0+BBScwoQ1sgFLw4Kuu59/XxmH145sEotf97tFlUlTURkSJPPdvNurMqsziPLvSYDQspYSPuTo3EoOQY7zp5AiKvuUgFYQ0RUy+buvoDVEYlqNOqf7uwAK4vrGxB+U9wZPLD1V7X8XJt+eLRlj1o+UiIi3XGwsilN1i4oKMCKJCs0d/LU2fFw6g6iWm4qe3bZMbX85rCWaOlzfV3iw1PjMebfH9WYILc1aYv3wm7ifSIiqkMMiIhqsans0d+PIC23EF0CXPFM36bXtZ/4nAzctPY7NXx+d6/GmN/7TlXtTEREdYe/ZYlqyS8HY/HP8XhYW5jjh/HtVe+ymsouzMfodT+oxMNmTh74a9D9amJGIiKqWwyIiGpBSj7w7N8lE7e+OiQIrX2drquG6b4ti7A76QLcbezVzPVeto68P0RE9YABEVEt+Oa0GS5lF6CDvzOe73993eLfPbwev0UehpW5BZYOmIgWLroZi4OIyBQxICK6QX8cicOOJDNYmpupprLr6VX29/ljeHl/Sff6Od1uQR/fZrwvRET1iAER0Q1Iyc7H038dV8vP92uK9v4uNd7H8dQ43L15oRqs7LGW3fFwcDfeEyKiesaAiOgGTF8ejoTMfDS012DGgJrX6qTkZePmdT8ioyAPfXya4pOuY3g/iIh0gAER0XXadu4Svtl5Xi0/1lwDG0vzGk/YOn7jzzidkYTGjm74fcC9Kn+IiIjqHwMiouuQX1iMR34/rJbvD2uI1tcxVZnkDK2NOQl7Syv8NfB+9igjItIhBkRE1+HDTWdwLC4DXo7WePemFjV+/19RR/H+kQ1qeW6v8Wjn7sf7QESkQwyIiGroTFIW3lhzUi3PHt0a7vbWNXt/ehImbl2klqe06o1xge15D4iIdIwBEVENB0+c/McR5BYWY2CQJ+7u6F+j65dTWIBbN8xHWn4ueng3uWL2ZyIi0g0GREQ1sOhADNacTFQJ1F/eGgozs5pNz/H4ziU4dCkGXrYO+LXfPUyiJiLSEwyIiKopLacAz1yeyf6VwUEI8qrZtBrfn9yFuaf2wNzMDL/0nQB/h5qPWURERHWDARFRNb2+5iTiM/LQwssBz/Wr2fQcB5Iv4vGdS9Xymx2GYaBfEK87EZEeYUBEVA3So+zTrefU8me3tIF1DcYcSs/Pxe0b5iOvqBAjG4Vgetv+vOZERHqGARFRNRKpn1hyBEXFGtwS6oshwd41eu9jO/7AmYxkBDi4Yl7vO2Fuxv92RET6hr+Zia7h14Mx2HgmGbaW5qqbfU3MP70XC88egIWZucobcrex5/UmItJDDIiIriIzrxBT/y6ZvHXGwCA0ca9+QHMyLbE0b+j1DkPQw6cJrzURkZ5iQER0FW+vO4WLabkIdLfHc/2rP3mr5AvdsfFnZBXmo79vM0wPHcDrTESkxxgQEVXhZGKmmqJDfDKmNeysqj/x6gt7l+PApYvwsLHHz33vgoU5/6sREekz/pYmqiIZ+qmlR1FQpMFNId4Y2cqn2tdpxYVwfHJ8i1r+sfcd8LPneENERPpOpwHR5s2bMWrUKPj5+akRf//8889yr993331qfdnSrVu3ctvk5eXhySefhKenJxwcHDB69GhER0eX2yYlJQX33HMPXFxcVJHl1NTUejlHMkz/HI/H6ohEWFuY4+ObW1d7ROqk3Cw8sO1XtfxUSC+MbNSqjo+UiIgMPiDKyspCu3btMGfOnCq3GTZsGGJjY0vLihUryr0+ZcoULF26FIsWLcLWrVuRmZmJkSNHoqioqHSbu+66CwcPHsSqVatUkWUJiogqk19YjGmXE6mf6dO02iNSS63Sw9t+Q3xOBlq5+uC9sBG8wEREBsJSlx8+fPhwVa7GxsYGvr6+lb6WlpaG77//Hj/99BMGDRqk1v38889o1KgR1q1bh6FDhyI8PFwFQTt37kTXrl3VNt9++y26d++OiIgIBAcH18GZkSH7akckTiZmwdvRGi8Oqv6I1D+d3Y+l54+q+cl+7nMX7Cyt6vQ4iYjIhHKINm7cCG9vb7Ro0QIPPfQQEhISSl/bt28fCgoKMGTIkNJ10vzWpk0bbN++XT3fsWOHaibTBkNCmt1knXYbIq1L2fmYufqkWn5zWEs421YvqIkvysUze/8u7WLfwcOfF5WIyIDotIboWqT26Pbbb0fjxo1x7tw5vPLKKxgwYIAKhKTmKC4uDtbW1nBzcyv3Ph8fH/WakEcJqCqSddptKiO5SVK00tPT1aMEYFJqi3ZftblPfWNI5/j6qhNIySlAax9H3NPBt1rHnJuXh0+yIpBRmIfuXo3xTHAvgzhXY7x/18vYz9HYz88UzpHnd/2q+zOh1wHR+PHjS5el1icsLEwFR8uXL8fYsWOvmstRNgm2soTYittU9O677+L111+/Yv2aNWtgb1/7ow2vXbsWxk7fz/FiNvD5PvmZMMPt3ulYs3pVtd63NCcaxwvTYQsLTMz3wupV1XufodH3+1cbjP0cjf38TOEceX41l52dbfgBUUUNGjRQAdGpU6fUc8ktys/PV73IytYSSbNajx49SreJj4+/Yl+JiYmqJqkqM2bMwLPPPluuhkhyk6R5ztnZuVYjV/kBHzx4MKysjDPnxFDO8db5+1GkScDwYC+8eHenar3ncEosfllZ0vQ6u/NIPBBcvhekMTCU+3cjjP0cjf38TOEceX7XT9vCY1QBUXJyMi5cuKACI9GpUyf1gy//CcaNG6fWSU+0o0ePYtasWeq5JE9L8vXu3bvRpUsXtW7Xrl1qnTZoqow0yUmpSD6vLv6z1dV+9Yk+n+OG00n4+3gCLMzN8OHNrat1nDIa9f3bf0N+cRE6W7ljUouuent+xn7/aouxn6Oxn58pnCPPr+aq+/Og04BIusifPn269LnkCUmXeHd3d1VmzpyJW2+9VQVAkZGRePHFF9V4Q7fccovaXhKjJ02ahKlTp8LDw0O9Z9q0aQgNDS3tdRYSEqK67ktC9tdff63WPfzww6prPnuYkZBZ7J/965hafrR7Y4T4OFXrwrx1aB2OpMTCy8YBj9sFVXusIiIi0j86DYj27t2L/v37lz7XNlFNnDgRX375JY4cOYL58+erQRQlKJJtFy9eDCen/76wPvroI1haWqoaopycHAwcOBA//vgjLCz+m2ZhwYIFeOqpp0p7o8ngjVcb+4hMy097o3EwJh0utpaYOaRFtd5zIPki3j28Xi1/1nUMbI+er+OjJCIiow2I+vXrp5Kbq7J69epr7sPW1hafffaZKlWRmiMZn4ioopyCIry86oRafnlQC3g6XtlMWlFBcRHu37oYRZpi3NakLcYGtMEKBkRERAZN78chIqpLn205p2azD3CzwxO9mlTrPe8f3oBDl2LgbmOPOd1Kmm+JiMiwMSAik5WSnY9315fksL0xNBi21ZjN/lhKHN44VNKt99OuY+BjV718IyIi0m8MiMhkvb/+DFJzCtDG1wkTOjW85vaFl5vKpMlsVKNWuKtph3o5TiIiqnsMiMgkXUzLwSdbzqrld0eEqO721/LxsS3Yk3QBLta2+LL7rexVRkRkRBgQkUl6fc1J5BYWo1egO0aEXDm1S0Un0xLxyoGSEahndx4NfweXejhKIiKqLwyIyOSciM/A97tKusm/PyLkmjU90hPy4e2/IbeoEIP9WuD+oM71dKRERFRfGBCRyXlp5QkUa4CbW/ugR6D7Nbefe2oPNsWdhb2lFb7pcRubyoiIjBADIjIpu6JSsORIHCRl6J2bQq65fUJOBqbt+Vstv95+KJo4XTuAIiIiw8OAiEyGNH1NXx6ulieGNUIr32t3mZ+652+k5OegnbsfprTuXQ9HSUREusCAiEzGupNJ2HgmGTaW5nh9aPA1t1978SR+PrMfZjBTTWWW5tcep4iIiAwTAyIymdqhV1dHqOXHejRGIze7q26fU1iAx3b8oZafCOmJLl4B9XKcRERkAHOZRURE4JdffsGWLVvU7PPZ2dnw8vJChw4dMHToUDUzvY3NteeCIqpvK08kYGdUCuyszPFC/+bVmsn+TEYy/O1d8FbHYfVyjEREpOc1RAcOHMDgwYPRrl07bN68GZ07d8aUKVPw5ptvYsKECeqv75deegl+fn54//33kZeXV/dHTlST2qFVJbVDT/QMhK+z7VW3D0+Nx6wjG9TyZ93GwNn66tsTEZGJ1BCNGTMGzz33HBYvXqxmjq/Kjh078NFHH+HDDz/Eiy++WJvHSXTdlh2Lx77oNDhYW+C5/s2uGTw9sXMpCjXFanqOWxqH8soTEZmAagVEp06dgrW19TW36969uyr5+fm1cWxEN6y4WIPXLucOPd07EF6OV2/S/S3yENbHnoathSU+6Xoz7wARkYmoVpNZdYKhG9meqK4sORKLQzHpcLa1xNR+V68dyizIw7O7S8Ycmh46AIFOHrwxREQmokZJ1Vq7d+/Gxo0bkZCQgOLi4nKvzZ49u7aOjeiGFJWpHXqmT1O421tfM5H6YnYaAh3d8Xxof159IiITUuOA6J133sHLL7+M4OBg+Pj4lJvG4FpzQhHVp18PxuB4fCZc7axUQHQ1EWkJmH1ss1qWpjI7S6t6OkoiIjLIgOiTTz7BDz/8gPvuu69ujoioFhQWFWPmmpLaoWn9msLFzuqqidRP7vwTBcVFGNEwBKMCWvMeEBGZmBoPzGhubo6ePXvWzdEQ1ZKFBy7iZGIWPOyt8FSvq9cOLYk6grUxJ2HDRGoiIpNV44DomWeeweeff143R0NUS7lDb687pZaf698cTrZVV4RmFeThmd3L1PLzbfqhmbMn7wERkQmqcZPZtGnTMGLECDRr1gytWrWClVX5poglS5bU5vER1djvh2JU7ZC7vRUm92hy1W3fO7IBF7JS0djRDdPbDuDVJiIyUTUOiJ588kls2LAB/fv3h4eHBxOpSe/GHXrrcu3QlD5Nr1o7FJV5Cf87ulEtf9RlNOwtOVwEEZGpqnFANH/+fPzxxx+qlohI3/x9PB5H4zLUuENP9gq86rbT965AblEh+vk2w5iANvV2jEREZAQ5RDJ1hzSXEekb6S321rqTavmJnk1Ud/uq7EiIxKJzB2EGM1U7xCEjiIhMW40DopkzZ+K1115TM90T6ZPVEYnYeyEN9tYWqrmsKsWa4tJE6vuDOqO9h389HiURERlFk9mnn36KM2fOqEEZmzRpckVS9f79+2vz+IiqXTv05tqS2qFHuze+6pxlv5w9iF2J5+FoaYO3Og7jFSYiopoHRDLzPZG+2XQmGdsjU2BjaY5pV5mzLLswH9P3LlfLM9oOQAN753o8SiIiMpqASJrLiPSNtmfZg10D0MDZtsrtPjy6CdHZaQhwcMUzrfvU4xESEZFR5RBVt/mCqL7siLyEf08lwdLcDM/3r7p26GJWGt47sl4tzwobyfnKiIioZgFRSEgIFi5ciPz8/Ktud+rUKTz22GN4//33q7NbolqhHZV6YlgjBLjZV7ndS/tXIruwAD28m2BcYDtefSIiqlmTmUzV8cILL+Dxxx/HkCFDEBYWBj8/P9ja2iIlJQXHjx/H1q1b1eMTTzyByZMnV2e3RDfscEw6locnwNwMmD6weZXb7UuKxrzTe9Uyu9kTEdF1BUQDBgzAnj17sH37dixevFjVFkVGRiInJweenp7o0KED7r33XkyYMAGurq7V2SVRrfhg42n1eFtbPzT3dKiyCffZy93s727aEV28Anj1iYjo+pOqe/TooQqRPoi6lI1fDsSo5avlDi2PDsfm+LOwtbDEu51uqscjJCIik06qJqoPH285q2a2HxjkiU6NKq+ZLCouLu1m/3Sr3mjkyBpMIiK6EgMiMkiXsvPx7c7z16wd+unMPhxLjYebtR1eCO1fj0dIRESGhAERGaQvtkUiK78I7fycMbiFV6Xb5BQW4JX9q9Tyi20Hws2m6h5oRERk2hgQkcHJKSjCp1vPldYOVTUx6+fh29QgjI0cXPFESM96PkoiIjIkDIjI4MzbcwGJmflo7GaHce38Kt0mJS8b7xz+Vy2/0WEobC3Lz7lHRER0wwGRTO768ssv484770RCQoJat2rVKhw7dux6dkdUbZJE/b+NZ9Ty1L7NYGlR+Y/w+0c2ICU/B61dfXBPs068wkREVLsB0aZNmxAaGopdu3ZhyZIlyMzMVOsPHz7Mec6ozi05Eoszydlwt7fCA10aVbpNdFYqPjm+RS2/FzYCFuasCCUioqur8TfF9OnT8dZbb2Ht2rWwtrYuXd+/f3/s2LGjprsjqjYZYPH99SUDMT7RMxAONpUPozXzwBrkFhWil08gRjQM4RUmIqLaD4iOHDmCW2655Yr1Xl5eSE5OrunuiKpt05lk7ItOg52VOZ7o1aTSbcJT4zH39B61/H6nEVUmXBMREd1QQCRTc8TGxl6x/sCBA/D396/p7oiqbfams+rxvs6N4OVoU+k2rx1YjWKNBjcHtEYPn8qDJiIiohsOiO666y410WtcXJz667u4uBjbtm3DtGnT1HxmRHXhVGIm/gmPV8tP925a6TaHLsXgt8jDMIMZ3uwwjDeCiIjqLiB6++23ERAQoGqDJKG6VatW6NOnj5rjTHqeEdWFT7ecg0YDjAjxRrC3Y6XbvLp/tXocH9gOoe4NeCOIiKhuJncVVlZWWLBgAd544w3VTCY1RDLbfVBQUE13RVQtqTkFmLvnglqe0qfy2qHdieex7MIxmJuZYWaHIbyyRERUtwGRVrNmzVQhqmvf7Tyvpulo4+ukJnKtzKsHSmqHZMyhYBdv3hQiIqr9gOjZZ5+t9g5nz55dsyMguorComJ8tu1cae1QZb3Gtsafw+qLEbA0M8er7QfzehIRUd0ERNI0Vta+fftQVFSE4OBg9fzkyZOwsLBAp04cEZhq19KjcTifkgNPB2vc3dG/0rGJXt6/Ui0/ENQFTZ08eAuIiKhuAqINGzaUqwFycnLCvHnz4ObmptalpKTg/vvvR+/evWt+BERX8fHmkq72j/VoDFsriyteXx97GpvizsLa3AIvtxvEa0lERPXTy+zDDz/Eu+++WxoMCVmW0avlNaLasvt8CrZHpsDKwgyTezSptHbolf2r1PIjwd3RyNGVF5+IiOonIEpPT0d8fMl4MGXJJK8ZGRnXdxRElfh4c0nu0J0d/OHrbHvF6yujT2BHYhTsLKwwo+0AXkMiIqq/gEim7ZDmsd9//x3R0dGqyPKkSZMwduzY6z8SojKiU3Pw26EYtTylkoEYpXbotYMlPcseD+mBBvbOvH5ERFR/3e6/+uorNSr1hAkTUFBQULITS0sVEH3wwQfXfyREZXy+LRKFxRr0beaBDg1drrg20qtsb1I07C2t8Fybfrx2RERUvwGRvb09vvjiCxX8nDlzRv2l3rx5czg4ONzYkRBdllNQhG92RqnlKb0Dr7gu8jP35qF1avnR4O7wtnPitSMiIt0MzCgBUNu2bW/s04kqsfhADC5lFyDAzQ6jWvte8fqG2NPYnhAJGwtLTGPtEBER6SIg6t+/f6WD42mtX7/+Ro+JTNzn20uSqR/r3hgW5lf+rGlrhx5q0ZW5Q0REpJuAqH379uWeSx7RwYMHcfToUUycOLF2jopMuqv93gtpsLYwx6SuAZWOSr0x7gyszC3wfJv+OjlGIiIyPjUOiD766KNK18+cOROZmZm1cUxk4snUYnx7P3g52lzx+psH16rH+5t35rhDRESku273VZFeZz/88ENt7Y5MUGJmHhYdKOlq/3jPJpXOaL8m5iQszMwxvS1rh4iISA8Doh07dsDW9srB84iq6/td55FfVIywRi7oEnDlqNNvHiqpHbqnWUcEcs4yIiLSZZNZxcEXpQt0bGws9u7di1deeaU2j41MSFGxBl/tKOlq/3iPwCsS9w8kX8Q/F8JhbmaGF9sO1NFREhGRsapxQOTs7Fzuy8rc3FzNev/GG29gyJAhtX18ZCKWH49HVEoO3O2tML6D3xWvv3W5Z9kdge0R5OKlgyMkIiJjVuMmsx9//BFz584tLd9//z3ee++96wqGNm/ejFGjRsHPz08FWX/++ecVtU+SrC2v29nZoV+/fjh27Fi5bfLy8vDkk0/C09NTjY00evRoNZ1IWSkpKbjnnnvg4uKiiiynpqbW+Hip7szZVtLVflKXANhVmNX+aEoslkQdgRnM8FI71g4REZEeBERNmzZFcnLyFeslwJDXaiIrKwvt2rXDnDlzKn191qxZmD17tnp9z5498PX1xeDBg8tNIjtlyhQsXboUixYtwtatW1VPt5EjR6KoqKh0m7vuuksNDbBq1SpVZFmCItIPJxMzsfZkEqTi8bFKZrV/+9C/6vHWJqFo5XrlQI1ERET13mQWGRlZLtgoW1Nz8eLFGu1r+PDhqlRGaoc+/vhjvPTSS6V5S/PmzYOPjw8WLlyIRx55BGlpaaqG6qeffsKgQYPUNj///DMaNWqEdevWYejQoQgPD1dB0M6dO9G1a1e1zbfffovu3bsjIiJCNfeRbn1xuav9iBAfBHrYl3stIi0Bi88dUssvtyu5x0RERDoLiJYtW1a6vHr1atX0pCUB0r///osmTa786/56nTt3DnFxceWa4mxsbNC3b19s375dBUT79u1TA0OW3Uaa19q0aaO2kYBIer/JsWqDIdGtWze1TrapKiCSAE+KVnp6unqUz9NOalsbtPuqzX3qm6udY1Z+IX7cc0EtP9K14RXbvHvoX2igwciGIWjl5KWX18nY76Gxn58pnKOxn58pnCPP7/pV92ei2gHRmDFj1KPk+lQckdrKykoFQx9++CFqiwRDQmqEypLnUVFRpdtYW1vDzc3tim2075dHb2/vK/Yv67TbVObdd9/F66+/fsX6NWvWqAlua9vatSVdyo1ZZee4NhZIyzWHr60GBaf3YMWZ/15LKs7DgtT9arlPujVWrFgBfWbs99DYz88UztHYz88UzpHnV3PZ2dm1GxAVFxerx8DAQJXPI0nM9aFi92tpSrvaXGqVbVPZ9tfaz4wZM/Dss8+WqyGSpjipjZKedrUZucoPuORGSWBpjK52jm9/vgNAGp7qH4yRfcvnoL2wbwUKUzXo4x2IKUPugL4y9nto7OdnCudo7OdnCufI87t+2haeWs8hkqas+iAJ1EJqcRo0aFC6PiEhobTWSLbJz89XvcjK1hLJNj169CjdJj4+/or9JyYmXlH7VJY0z0mpSP6j1cV/trrarz6peI4HL6Zhz4U0WFmYYVLXJuVeS8nLxrendqvl6e0GGMS1MfZ7aOznZwrnaOznZwrnyPOruer+PFQrIPr000/x8MMPq5GoZflqnnrqKdQGqYmSYEYi/g4dOqh1Evxs2rQJ77//vnreqVMndaKyzbhx49Q6GSRSJpqVHmpCkqcl+Xr37t3o0qWLWrdr1y61Ths0kW58u/O8ehzTxhfeTuWDzy9P7EBmYR5C3RpgmH9LHR0hERGZCsvqTuh69913q4CoqsldhTRB1SQgki7yp0+fLlf7JF3i3d3dERAQoLrUv/POOwgKClJFliV/R7rRC0mMnjRpEqZOnQoPDw/1vmnTpiE0NLS011lISAiGDRuGhx56CF9//bVaJ8GddM1nDzPdycorxM/7S8aLeqRb43Kv5RQW4JPjW9Ty86H9rtlESkREVC8BUdlmstpsMpPpPvr3/2+STm3OjiRtywCQzz//PHJycjB58mTVLCY9xSSp2cnJqfQ9EqBZWlqqGiLZduDAgeq9Fhb/De63YMECFahpe6PJ4I1VjX1E9WPxwRik5xaimYc9+jcvn4827/QeJORmIsDBFeMD2/OWEBFRnatxDpFM0SG1MBV7Wkkw8sEHH+DVV1+t9r5k5GlJbq6K1AzISNVSqiK1Vp999pkqVZGaIxmfiPTHNztLego+1K0xzM3/qwEqKi7G/45uUstT2/SFlXn5UauJiIj0YqRq6YouTV2VdWurrJs6UUWHYtKw63wqLM3NcF/nRuVe+yPqMM5kJMPDxh6TgkpyvoiIiPQuIKqqu/qhQ4dUTQxRdZOpbwn1hU+ZZGr52Xr/yAa1/ERITzhYXdnLj4iISKdNZtKtXQIhKS1atCgXFMlI1VJr9Oijj9bJQZJxJVP/tK8kmfrhCsnU/8aewv7ki7CzsMITIb10dIRERGSKqh0Qybxi8hf8Aw88oJrGyk7dIaNFy0jV0sWd6Gp+PVSSTN3Uwx4DKiRTa2uHHmzRFZ62DryQRESkfwGRdroOGR9Ixu8x5oGvqO58c7m57KGuAeWSqfclRWNdzClYmJnj2dZ9eAuIiEj/AqKyw17LIInSo0xKZWpzWgsyLkfjMrAzKqXSZOoPjm5Uj+MD26GJE3PRiIhIDwMiV1fXas8fJvlERJWZt7ckd2hUax/4OtuWro/MuITfIg+p5edD/xuXioiISK8Cog0bSnI7iK5XQTGw4ECMWn6gS0C51z4L34pijQaD/ILQzt2PF5mIiPQzIOrbt2+1dibTbhBVZt8lICmrAL5ONhgW7FW6Pj0/F9+e3KWWmTtEREQGMw5RRTJJ6hdffIGOHTuqyVaJKrMurqTJdWJYI1ha/Pdj9/2p3cgoyEOIizeG+gfz4hERkWEFROvXr8eECRPQoEEDNW3GTTfdpOYmI6ooJj0X+y+VLN/f5b9k6sLiotJJXJ9p3QfmZjccnxMREdX9XGbR0dFq4tQffvgBWVlZakLVgoIC/PHHH2jVqtX1HQEZvQX7Y1AMM/Ro7Ipgb8fS9UuijiAqMwWeNg6Y0Iy1i0REpDvV/pNcaoAk6Dl+/LiqEYqJibnqhKpE2t6HP17uXTYxrGG59R9ensR1ckgP2FlyXCsiIjKAGqI1a9bgqaeewmOPPYagoKC6PSoyGjsiU3AqKRs25hrc1tb3v/UJUdiddAHW5haY3LKHTo+RiIio2jVEW7ZsQUZGBsLCwtC1a1fMmTMHiYmJvIJ0VT/svqAee3oBTjb/xd+zj5XUDklTmY+dE68iEREZRkAk85R9++23iI2NxSOPPIJFixbB398fxcXFWLt2rQqWiMrKzCvE4kMX1fJAX03p+rMZyVh6/qhafqZ1b140IiLSuRp367G3t1cTvG7duhVHjhzB1KlT8d5778Hb2xujR4+um6Mkg/T7oVhk5hWhuYc9WpWZ0eXT4yUDMQ7xa4E2bg10eYhERETKDfVzDg4OxqxZs1Tvs19++eVGdkVG6Ic9JRO53hvmD+3ML6l5Ofj+5G61PLVN9Qb8JCIiqmu1MvCLhYUFxowZg2XLltXG7sgInEnKwpazlyAT2k/o6F+6fu7pPcgszENrVx8M9muh02MkIiLS4kh4VCd+2lfS1X5wCy80dCmZyLWouBhzwrep5ada9b7mhMFERET1hQER1ToZY0gbEN3T6b+xh1bGRKiEajdrO9zdtAOvPBER6Q0GRFTrtkem4GxyNhysLTCmzX9jD31+Yrt6fLBFVzhY2fDKExGR3mBARLVOWzt0W9sGcLg89tCFoiz8G3ca5mZmHIiRiIj0DgMiqlW5BUVYfDDmiuay5bkl625u1BpNnNx51YmISK8wIKJatTw8Hqk5BSqRul9zT7UuJS8HG/IS1PJTrXrxihMRkd5hQES16qfLE7ne3bEhLKTPPYAfz+xFHorRxtUXfX2b8YoTEZHeYUBEtSYpMw/Lw0tqgu65PLO9dLX/MmKHWn4iuAe72hMRkV5iQES1RnKHCos16NjQBa19SyZsXR4djsisFDiZWeKOwHa82kREpJcYEFGtmX+5d9m9ZZKpZd4yMdjGF/aW1rzaRESklxgQUa2ISMjE7vOpKm/ozg4lU3UcS4nDv7GnVFf74TacxJWIiPQXAyKqFT9frh0aGuwFb6eSQRc/C99a2tXey6Jk+g4iIiJ9xICIblhx8X9TdWiby1LysjH/9L7SZGoiIiJ9xoCIbtj2yEuISsmBk40lRl+equP7k7uRU1SAtm4N0Mu7Ca8yERHpNQZEdMN+OVAyCvXYUF/YWVmorvafn9DOat+LXe2JiEjvMSCiG1JYVIzfDpcERNpk6pUXTyAyMwXuNva4q2lHXmEiItJ7DIjohvx7KgmJmfnwcrTGwKCSqTq+vDyr/f3NO8PO0opXmIiI9B4DIrohCw9cVI/j2vnB0sIc5zKSsTI6Qq17JLgbry4RERkEBkR03XIKirD0SFy55rKvI3ZCAw2G+LVAkIsXry4RERkEBkR03VaExyMjrxABbnbo3tgNeUWFqneZeKwlu9oTEZHhYEBE123h/pLmsjvb+8Pc3Ay/Rx5GUl4WGtq7YGSjEF5ZIiIyGAyI6Lqk5RSUzmx/Z0e/csnUDwd3g6W5Ba8sEREZDAZEdF3+PBqHvMJihPg4om0DZxy+FINtCZGwNDPHgy268qoSEZFBYUBE1+WXy73L7urgrwZe/PLEDvX8lsZt0MDemVeViIgMCgMiqrGEjDysO5Wklu/o4I/0/Fz8dKZk3jImUxMRkSFiQEQ19tuhGBQVa9C5kSuaezrg5zP7kFWYj5Yu3ujn24xXlIiIDA4DIrru5rI7O/hBo9Hgi8vJ1I+17M55y4iIyCAxIKIauZCSg22RKTAzA8a398fW+HM4lhoPe0sr3NssjFeTiIgMEgMiqpHfL0/k2jvQHX4utvgyoiSZWiZxdbWx49UkIiKDxICIauS3Q7Hq8fZ2fojPyVCDMYrHgrvzShIRkcFiQEQ1ai7bEVXSXHZr2wb44dRuFBQXoYtnI3T0bMgrSUREBosBEV1Xc5m3ozW+ujz20GTOW0ZERAaOARFdV3PZyosncD4rFW7WdhgX2J5XkYiIDBoDIrqu5jJt7dD9QZ1hZ2nFq0hERAaNARHVqLmsV6A7CsxzVA2ReITJ1EREZAQYEFHNmsva+qlk6mKNRo1K3cLFi1eQiIgMHgMiqlFz2ZhQH3x/crda/xBntSciIiPBgIhq1Fx2OCMS0dlpcLexx9jGobx6RERkFBgQUY2ay749uUstT2weBlsmUxMRkZFgQETVbi7rEeSAfy6Eq/VsLiMiImPCgIiq1VzWs4k7VsYdRpGmGL18AhHi6sMrR0RERoMBEVWruey2tg3w3amS5rKHW3TjVSMiIqPCgIiqdDGtpLlMePlmISozBa7WdritSVteNSIiMioMiKhKfx2NV4/dGrvhj+j9avmeZp04MjURERkdBkRUpaVHSprLBoU4Y9n5Y2qZydRERGSM9DogmjlzJszMzMoVX1/f0tc1Go3axs/PD3Z2dujXrx+OHSv54tbKy8vDk08+CU9PTzg4OGD06NGIjo7WwdkYlpTsfGw8k6yWcxwvolBTjG5ejRHq3kDXh0ZERGRaAZFo3bo1YmNjS8uRI0dKX5s1axZmz56NOXPmYM+ePSpYGjx4MDIyMkq3mTJlCpYuXYpFixZh69atyMzMxMiRI1FUVKSjMzIMy8MTUFisQYiPA/68eFCtezi4q64Pi4iIyDQDIktLSxXoaIuXl1dp7dDHH3+Ml156CWPHjkWbNm0wb948ZGdnY+HChWqbtLQ0fP/99/jwww8xaNAgdOjQAT///LMKqtatW6fjM9Nvfx6NU48dWmhwJiMZzla2GNekna4Pi4iIqE5YQs+dOnVKNYnZ2Niga9eueOedd9C0aVOcO3cOcXFxGDJkSOm2sk3fvn2xfft2PPLII9i3bx8KCgrKbSP7kuBJthk6dGiVnytNbVK00tPT1aPsT0pt0e6rNvd5o3IKirAyPEEtJ1pFqsc7A9vBGubXdZz6eI61iedn+HgPDR/voWErqMPvieruU68DIgmA5s+fjxYtWiA+Ph5vvfUWevToofKEJBgSPj7lBwiU51FRUWpZtrG2toabm9sV22jfX5V3330Xr7/++hXr16xZA3t7e9S2tWvXQl/sTgayC8zhbpePjQkn1LqguDysWLHCaM6xLvD8DB/voeHjPTRsa+vge0Jajgw+IBo+fHjpcmhoKLp3745mzZqpprFu3UoGB5RE67KkKa3iuoqqs82MGTPw7LPPlqshatSokaptcnZ2Rm1GrvIDILlPVlZW0AdLf5M8rYto07oIm3M1CHX1xZMjxl3zmhnSOdYmnp/h4z00fLyHhq2gDr8ntC08Bh0QVSS9xCQwkma0MWPGqHVS09OgwX89nxISEkprjSTnKD8/HykpKeVqiWQbqWm6Gml+k1KR3Ki6+FKvq/3WVGFRMf653FwWZ3lePT7QoouqaTOWc6wrPD/Dx3to+HgPDZtVHXxPVHd/ep9UXZbk9ISHh6sAKDAwUAU8ZavXJPjZtGlTabDTqVMndSHKbiM91Y4ePXrNgMhUbT13CcnZBXB2zcXJzDhYmVvg7mYddX1YREREdUqva4imTZuGUaNGISAgQNXqSA6RVH1NnDhRNd9Il3pJsg4KClJFliW/56677lLvd3FxwaRJkzB16lR4eHjA3d1d7VNqmaTXGVXdu8y/cRrSi4BRjVrBy9aRl4qIiIyaXgdEMoDinXfeiaSkJNXdXvKGdu7cicaNG6vXn3/+eeTk5GDy5MmqWUySsCXp2cnJqXQfH330keq6P27cOLXtwIED8eOPP8LCwkKHZ6afJLdqqQREZsW4aFaSmP5AUGddHxYREZFpB0QymOLVSC2RjFQtpSq2trb47LPPVKGrO3AxDedTcmDtloL0whw0sHPGUP9gXjYiIjJ6BpVDRPXTXObuf0k93tu8EyzNWZNGRETGjwERlVp6JA6wzEM8SiZ1vZ/NZUREZCIYEJFyOikLR+MyYOaWAA006OHdBMEu3rw6RERkEhgQkfKn1A5BAzuvkjGImExNRESmhAERKUuPxgJ26cg2z4S9pRXGBXIiVyIiMh0MiAhx6bnYEZUCuJUkVd/epB2crGx5ZYiIyGQwICIsOxYPDYpg7pakrgaTqYmIyNQwIKKS5jLnZBSbFSLQ0R29fQJ5VYiIyKQwIDJx6bkF+PdUEuAar55PaNYR5mb8sSAiItPCbz4TtyI8AQXIAxxT1PN7mnXS9SERERHVOwZEJk6NTu2aAJhp0NUrAEEuXro+JCIionrHgMiE5RUWqRoibXMZa4eIiMhUMSAyYZI7lIE0wC4TlmbmGB/YXteHREREpBMMiEx97rLLtUM3NQyBp62Drg+JiIhIJxgQmaiiYk1Jd3vJH5LmsuYddX1IREREOsOAyERtPZeMZCQAVnlwsbLFyIatdH1IREREOsOAyEQtKdNcJvOW2Vpa6fqQiIiIdIYBkQnSaDT4/Ug04FwyVQd7lxERkaljQGSC9lxIRYzmImBRhMYObujp00TXh0RERKRTDIhM0JLD/zWX3dO8E6fqICIik8eAyASbyxYfiwQcL5XOXUZERGTqGBCZmGNxGYgsigTMgI7uDRHs4q3rQyIiItI5BkQm3LvsvqAwXR8OERGRXmBAZGJ+OhqhpuqwgDnuaMqpOoiIiAQDIhNyJDYdpwvOqeVBfi3gZeuo60MiIiLSCwyITMjC/dGlzWX3s7mMiIioFAMiE+pd9uPxo4B1HuzMrTE6oLWuD4mIiEhvMCAyEbvOpyLO/Lxavj2wLew4VQcREVEpBkQm4qf9UYBLolpmcxkREVF5DIhMQFGxBgtOHVJTdXhZO6OPb1NdHxIREZFeYUBkAjaeTkKa3QW1/GjLrpyqg4iIqAIGRCbg8z3hgGOqWn4wuIuuD4eIiEjvMCAycum5Bfg79pBa7ureFAGObro+JCIiIr3DgMjI/XIgGoXOsWp5atueuj4cIiIivcSAyMh9fGAfYJUPB3Nb3Myxh4iIiCrFgMiIRSRk4kTBKbU8oVknWFtY6vqQiIiI9BIDIiP28Y7jgFOyWn66TQ9dHw4REZHeYkBkpAqLivHTuT2AGRDqFIAQVx9dHxIREZHeYkBkpJYcu4gsh5Kxh17p2E/Xh0NERKTXGBAZqTd2bQMsC+Fs7oCxTdro+nCIiIj0GgMiIxSRkIFjBSfU8qPBPWBhzttMRER0NfymNEKvbd4L2GXCXGOO59v30vXhEBER6T0GREYmO78QS2L3qOWB3q3gYeug60MiIiLSewyIjMyHO46iwCGhZLn7UF0fDhERkUFgQGREios1+PDoJtXVvpV9Y4R6NND1IRERERkEBkRG5McDp5FmG62WP+oxXNeHQ0REZDAYEBmRV/euB8w18LfyxpBGzXV9OERERAaDAZGR+CfiAi5anFHLb4UN1vXhEBERGRQGREbiqa0rAYtieJq7Y2Jwe10fDhERkUFhQGQE/jl5HudwWi2/GzYcZmZmuj4kIiIig8KAyAhM3rwcMC+Gt7kXJrVi7RAREVFNMSAycAuPnsIFs7Nq+eNuI1g7REREdB0YEBkwjUaDJ3f8pXqWNbLww53BnMSViIjoejAgMmCvbt2BS5ZxgMYM8/vfpuvDISIiMlgMiAxUel4+3g9frZa7OrZGv0YBuj4kIiIig8WAyEDd+s9SFFhlwbzIGn8Mv1XXh0NERGTQGBAZoOVnzmJd6l61/HCT/vB3ctL1IRERERk0BkQGJr+wEHdu+EUlUnsV++LzAQN1fUhEREQGjwGRgRnx96/IsEgBiiywbNjdMDfnLSQiIrpR/DY1IJ8d2Id1qfvV8gMNB6GbfwNdHxIREZFRYEBkIHbHX8SU/b+r5UA0x3dDB+n6kIiIiIwGAyIDcD4jFX3/+RrF5gWwyXfBttvu4YjUREREtYgBkZ6LyUpH29/nINc8G2b5dlgxZBIaODno+rCIiIiMiqWuD4Cqdio1GR2XzkEmMoACa3zfdQIGBPrxkhEREdUyk6oh+uKLLxAYGAhbW1t06tQJW7Zsgb5acPIwWi353+VgyAaftrsb97cP1vVhERERGSWTCYgWL16MKVOm4KWXXsKBAwfQu3dvDB8+HOfPn4c+iUy/hF5Lv8OEbfNRaFYA81wn/NztATzZtbWuD42IiMhomUyT2ezZszFp0iQ8+OCD6vnHH3+M1atX48svv8S7776rs+OKzsjA9qx0bN6xEStjIhCeHQWYaQAN4JHbBOvGTEB7P1edHR8REZEpMImAKD8/H/v27cP06dPLrR8yZAi2b99e6Xvy8vJU0UpPT1ePBQUFqtSWtku+QKZFKnDmUMkKM8Ai2w0PN+mLWQM6w8bSvFY/Txe0x2/o51EVnp/h4z00fLyHhq2gDr8nqrtPM41Go4GRi4mJgb+/P7Zt24YePXqUrn/nnXcwb948REREXPGemTNn4vXXX79i/cKFC2Fvb19rx/bgxQgkWabCqsAO3sUu6GXtiTFeDrCzqLWPICIiMlnZ2dm46667kJaWBmdnZ9OuIdIyMzMr91xiwYrrtGbMmIFnn322XA1Ro0aNVK3S1S5oTZ3M6Y/NGzZg8ODBsLKygjGS6Hzt2rVGe448P8PHe2j4eA8NW0Edfk9oW3iuxSQCIk9PT1hYWCAuLq7c+oSEBPj4+FT6HhsbG1UqkhtVmzfLsY72q4+M/Rx5foaP99Dw8R4aNqs6+J6o7v5MopeZtbW16mYv0WdZ8rxsExoRERGZJpOoIRLS/HXPPfcgLCwM3bt3xzfffKO63D/66KO6PjQiIiLSMZMJiMaPH4/k5GS88cYbiI2NRZs2bbBixQo0btxY14dGREREOmYyAZGYPHmyKkREREQml0NEREREdDUMiIiIiMjkMSAiIiIik8eAiIiIiEweAyIiIiIyeQyIiIiIyOQxICIiIiKTx4CIiIiITB4DIiIiIjJ5JjVS9Y3QaDTqMT09vVb3W1BQgOzsbLVfY50J3tjPkedn+HgPDR/voWErqMPvCe33tvZ7vCoMiKopIyNDPTZq1OhG7w0RERHp4HvcxcWlytfNNNcKmUgpLi5GTEwMnJycYGZmVquRqwRZFy5cgLOzs1FebWM/R56f4eM9NHy8h4YtvQ6/JyTMkWDIz88P5uZVZwqxhqia5CI2bNgQdUV+AIwxWDClc+T5GT7eQ8PHe2jYnOvoe+JqNUNaTKomIiIik8eAiIiIiEweAyIds7GxwWuvvaYejZWxnyPPz/DxHho+3kPDZqMH3xNMqiYiIiKTxxoiIiIiMnkMiIiIiMjkMSAiIiIik8eAiIiIiEweAyId++KLLxAYGAhbW1t06tQJW7ZsgTGYOXOmGtG7bPH19YUh27x5M0aNGqVGO5Xz+fPPP68YDVXOW163s7NDv379cOzYMRjL+d13331X3NNu3brBULz77rvo3LmzGm3e29sbY8aMQUREhNHcw+qcn6Hfwy+//BJt27YtHbyve/fuWLlypVHcv+qcn6Hfv8p+ZuUcpkyZohf3kAGRDi1evFj9ILz00ks4cOAAevfujeHDh+P8+fMwBq1bt0ZsbGxpOXLkCAxZVlYW2rVrhzlz5lT6+qxZszB79mz1+p49e1QAOHjw4NJ58Az9/MSwYcPK3dMVK1bAUGzatAmPP/44du7cibVr16KwsBBDhgxR520M97A652fo91BmC3jvvfewd+9eVQYMGICbb7659AvTkO9fdc7P0O9fWXJ/vvnmGxUAlqXTeyhzmZFudOnSRfPoo4+WW9eyZUvN9OnTDf6WvPbaa5p27dppjJX811m6dGnp8+LiYo2vr6/mvffeK12Xm5urcXFx0Xz11VcaQz8/MXHiRM3NN9+sMRYJCQnqPDdt2mSU97Di+RnjPRRubm6a7777zujuX8XzM6b7l5GRoQkKCtKsXbtW07dvX83TTz+t1uv6HrKGSEfy8/Oxb98+9RdcWfJ8+/btMAanTp1S1Z7SJHjHHXfg7NmzMFbnzp1DXFxcufspA4z17dvXaO6n2Lhxo2qOadGiBR566CEkJCTAUKWlpalHd3d3o7yHFc/P2O5hUVERFi1apGrApGnJ2O5fxfMzpvv3+OOPY8SIERg0aFC59bq+h5zcVUeSkpLUD7yPj0+59fJcfiAMXdeuXTF//nz1nzY+Ph5vvfUWevTooap+PTw8YGy096yy+xkVFQVjIM25t99+Oxo3bqx+cb3yyiuqSl8Ce0MbhVwqwZ599ln06tULbdq0Mbp7WNn5Gcs9lKZ3CRByc3Ph6OiIpUuXolWrVqVfmIZ+/6o6P2O5f4sWLcL+/ftVc1hFuv4/yIBIxyShrOIvsorrDJH8x9UKDQ1V/8GbNWuGefPmqV/UxspY76cYP3586bJ8yYaFhalfzMuXL8fYsWNhSJ544gkcPnwYW7duNcp7WNX5GcM9DA4OxsGDB5Gamoo//vgDEydOVPlTxnL/qjo/CYoM/f5duHABTz/9NNasWaM6ElVFV/eQTWY64unpCQsLiytqg6T6s2J0bAwcHBxUYCTNaMZI24POVO6naNCggfplbGj39Mknn8SyZcuwYcMGlcRqbPewqvMzlntobW2N5s2bq2BAeilJR4BPPvnEaO5fVednDPdv37596n5Ij2pLS0tVJNj79NNP1bL2PunqHjIg0uEPvfxQSG+QsuS5NC0Zm7y8PISHh6v/wMZI8qTkF3LZ+yl5YvKf3Rjvp0hOTlZ/8RnKPZW/MqXmZMmSJVi/fr26Z8Z0D691fsZwD6s6b/n9Yuj371rnZwz3b+DAgapJUGrAtEUCv7vvvlstN23aVLf3sM7TtqlKixYt0lhZWWm+//57zfHjxzVTpkzRODg4aCIjIw3+qk2dOlWzceNGzdmzZzU7d+7UjBw5UuPk5GTQ5yY9Iw4cOKCK/NeZPXu2Wo6KilKvS88I6Q2xZMkSzZEjRzR33nmnpkGDBpr09HSNoZ+fvCb3dPv27Zpz585pNmzYoOnevbvG39/fYM7vscceU/dHfi5jY2NLS3Z2duk2hnwPr3V+xnAPZ8yYodm8ebM6/sOHD2tefPFFjbm5uWbNmjUGf/+udX7GcP8qU7aXma7vIQMiHfv88881jRs31lhbW2s6duxYrousIRs/frz6IZaAz8/PTzN27FjNsWPHNIZMfgFJoFCxSFdYbZdRGW5Auo3a2Nho+vTpo/5DG8P5yZfqkCFDNF5eXuqeBgQEqPXnz5/XGIrKzk3K3LlzS7cx5Ht4rfMzhnv4wAMPlP6+lPMYOHBgaTBk6PfvWudnDPevOgGRLu+hmfxT9/VQRERERPqLOURERERk8hgQERERkcljQEREREQmjwERERERmTwGRERERGTyGBARERGRyWNARERERCaPARERERGZPAZERGSwZs6cifbt2+vs81955RU8/PDD1dp22rRpeOqpp+r8mIjo+nCkaiLSS2ZmZld9feLEiZgzZ46a+NLDwwP1LT4+HkFBQTh8+DCaNGlyze1lxu5mzZqp7asz8SoR1S8GRESkl+Li4kqXFy9ejFdffRURERGl6+zs7ODi4qKjowPeeecdNQv36tWrq/2eW2+9Fc2bN8f7779fp8dGRDXHJjMi0ku+vr6lRQIfqTGquK5ik9l9992HMWPGqGDFx8cHrq6ueP3111FYWIjnnnsO7u7uaNiwIX744Ydyn3Xx4kWMHz8ebm5uqrbp5ptvRmRk5FWPb9GiRRg9enS5db///jtCQ0NVsCb7GTRoELKyskpfl+1/+eWXWrtGRFR7GBARkVFZv349YmJisHnzZsyePVsFTSNHjlTBzq5du/Doo4+qcuHCBbV9dnY2+vfvD0dHR/WerVu3quVhw4YhPz+/0s9ISUnB0aNHERYWVrouNjYWd955Jx544AGEh4dj48aNGDt2LMrOn92lSxf1uVFRUfVwJYioJhgQEZFRkVqgTz/9FMHBwSo4kUcJel588UWV8zNjxgxYW1tj27ZtpTU95ubm+O6771TtTkhICObOnYvz58+roKYyEtBIoOPn51cuIJKaKAmCJKdI9jV58mQVXGn5+/urx2vVPhFR/bPUwWcSEdWZ1q1bqwBHS5rO2rRpU/rcwsJCNWdJkrPYt28fTp8+DScnp3L7yc3NxZkzZyr9jJycHPVoa2tbuq5du3YYOHCgCoSGDh2KIUOG4LbbblM1U1rSlCYkQCMi/cKAiIiMipWVVbnnkntU2bri4mK1LI+dOnXCggULrtiXl5dXpZ/h6elZ2nSm3UYCrbVr12L79u1Ys2YNPvvsM7z00kuqmU7bq+zSpUtX3S8R6Q6bzIjIpHXs2BGnTp2Ct7e36gFWtlTVi026zzs7O+P48eNXBFo9e/ZUidwHDhxQTXNLly4tfV3yjiQ4k1osItIvDIiIyKTdfffdqsZHepZt2bIF586dU93pn376aURHR1f6HmmSkx5kkoCtJTVB0rtt7969Kv9oyZIlSExMVDlJWrL/3r17lzadEZH+YEBERCbN3t5e9S4LCAhQCdESwEgytuQJSS1QVWSEaknI1ja9ybayn5tuugktWrTAyy+/jA8//BDDhw8vfY90uX/ooYfq5byIqGY4MCMR0XWQXmbdunXDlClTVHf7a1m+fLkaC0lGqra0ZPomkb5hDRER0XWQfKFvvvlGdbWvDhmgUbrzMxgi0k+sISIiIiKTxxoiIiIiMnkMiIiIiMjkMSAiIiIik8eAiIiIiEweAyIiIiIyeQyIiIiIyOQxICIiIiKTx4CIiIiITB4DIiIiIoKp+z8rWyaWQXWx6QAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "columns_map = {\"time\": \"time\", \"altitude\": \"altitude\"}\n", + "\n", + "cots_altimeter_flight = FlightDataImporter(\n", + " name=\"Altimeter Data\",\n", + " paths=\"../../data/rockets/valkyrie/flightInfo_merged.csv\",\n", + " columns_map=columns_map,\n", + " units=None,\n", + " interpolation=\"linear\",\n", + " extrapolation=\"zero\",\n", + " delimiter=\",\",\n", + " encoding=\"utf-8\",\n", + ")\n", + "Function.compare_plots(\n", + " [\n", + " (flight.altitude, \"RocketPy\"),\n", + " (cots_altimeter_flight.altitude, \"Altimeter\"),\n", + " ],\n", + " title=\"Altitude Comparison\",\n", + " xlabel=\"Time (s)\",\n", + " ylabel=\"Altitude (m)\",\n", + " lower=0,\n", + " upper=40,\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "bisky_main", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/getting_started.ipynb b/docs/notebooks/getting_started.ipynb index a5adf46d2..48154b7cd 100644 --- a/docs/notebooks/getting_started.ipynb +++ b/docs/notebooks/getting_started.ipynb @@ -1246,24 +1246,10 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "File trajectory.kml saved with success!\n" - ] - } - ], - "source": [ - "test_flight.export_kml(\n", - " file_name=\"trajectory.kml\",\n", - " extrude=True,\n", - " altitude_mode=\"relative_to_ground\",\n", - ")" - ] + "outputs": [], + "source": "from rocketpy.simulation import FlightDataExporter\n\nFlightDataExporter(test_flight).export_kml(\n file_name=\"trajectory.kml\",\n extrude=True,\n altitude_mode=\"relative_to_ground\",\n)" }, { "attachments": {}, @@ -1568,6 +1554,9 @@ } ], "metadata": { + "nbsphinx": { + "orphan": true + }, "colab": { "name": "getting_started.ipynb", "provenance": [], @@ -1594,4 +1583,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} +} \ No newline at end of file diff --git a/docs/notebooks/getting_started_colab.ipynb b/docs/notebooks/getting_started_colab.ipynb index ef0f7e711..5f5073aae 100644 --- a/docs/notebooks/getting_started_colab.ipynb +++ b/docs/notebooks/getting_started_colab.ipynb @@ -588,13 +588,19 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "test_flight.export_kml(\n", - " file_name=\"trajectory.kml\",\n", - " extrude=True,\n", - " altitude_mode=\"relative_to_ground\",\n", - ")" - ] + "source": "from rocketpy.simulation import FlightDataExporter\n\nFlightDataExporter(test_flight).export_kml(\n file_name=\"trajectory.kml\",\n extrude=True,\n altitude_mode=\"relative_to_ground\",\n)" + }, + { + "cell_type": "markdown", + "source": "## 3D Flight Animation\n\nRocketPy can render an interactive 3D animation of the rocket's trajectory and attitude using [PyVista](https://pyvista.org/). This feature requires the optional `animation` extra:\n\n```bash\npip install rocketpy[animation]\n```\n\n> **Note:** A local desktop Python or Jupyter environment provides the best interactive experience. Google Colab requires a compatible PyVista Jupyter backend.\n\nTwo animation modes are available:\n\n| Method | What it shows |\n|---|---|\n| `flight.plots.animate_trajectory()` | Simulated and flown paths, scalar coloring, telemetry, velocity and wind, flight events and georeferenced ground imagery |\n| `flight.plots.animate_rotate()` | Centred attitude view with body, velocity and wind vectors, stability markers, an inertial reference sphere and live angular rates |\n\nBoth views provide play/pause, a draggable flight-time control, 0.5x/1x/2x/3x playback speeds, optional kinematic charts, camera presets and deterministic GIF/MP4 export. Day/night colors are selected from local launch time and blend toward near-space navy with altitude. The trajectory defaults to speed coloring and also supports Mach, dynamic pressure, acceleration and altitude. The rotate view can show aerodynamic angles, 3-1-3 Euler angles, body rates, center of mass and center of pressure. Pass `color_scheme` to override the shared palette. The methods accept an optional `file_name` argument pointing to a custom `.stl` model. When omitted, RocketPy uses a built-in default rocket shape.", + "metadata": {} + }, + { + "cell_type": "code", + "source": "# Install the optional animation dependency (skip if already installed)\n!pip install \"rocketpy[animation]\"\n\n# Animate the full trajectory — rocket moves through 3D space\n# Press Escape or close the window to exit the animation\ntest_flight.plots.animate_trajectory(\n start=0,\n stop=test_flight.t_final,\n time_step=0.05,\n color_by=\"speed\",\n show_kinematic_plots=True,\n camera_mode=\"follow\",\n)\n\n# Alternatively, animate attitude and stability diagnostics\n# test_flight.plots.animate_rotate(\n# start=0,\n# stop=test_flight.t_final,\n# time_step=0.05,\n# show_attitude_plots=True,\n# show_cp_cm=True,\n# )\n\n# Deterministic export:\n# test_flight.plots.animate_trajectory(export_file=\"flight.mp4\", export_fps=30)\n\n# To use your own 3D model, pass its path via file_name:\n# test_flight.plots.animate_trajectory(file_name=\"my_rocket.stl\")", + "metadata": {}, + "execution_count": null, + "outputs": [] }, { "attachments": {}, @@ -798,6 +804,9 @@ } ], "metadata": { + "nbsphinx": { + "orphan": true + }, "colab": { "name": "getting_started.ipynb", "provenance": [], diff --git a/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb b/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb index 2fb46fa86..8181c03ba 100644 --- a/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb +++ b/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb @@ -800,6 +800,28 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Alternatively, we can target an attribute using the method `MonteCarlo.simulate_convergence()` such that when the tolerance is met, the flight simulations would terminate early." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "test_dispersion.simulate_convergence(\n", + " target_attribute=\"apogee_time\",\n", + " target_confidence=0.95,\n", + " tolerance=0.5, # in seconds\n", + " max_simulations=1000,\n", + " batch_size=50,\n", + ")" + ] + }, { "attachments": {}, "cell_type": "markdown", diff --git a/docs/reference/classes/aero_surfaces/EllipticalFin.rst b/docs/reference/classes/aero_surfaces/EllipticalFin.rst new file mode 100644 index 000000000..3f1403e8e --- /dev/null +++ b/docs/reference/classes/aero_surfaces/EllipticalFin.rst @@ -0,0 +1,5 @@ +EllipticalFin Class +------------------- + +.. autoclass:: rocketpy.EllipticalFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/Fin.rst b/docs/reference/classes/aero_surfaces/Fin.rst new file mode 100644 index 000000000..b9c70c25e --- /dev/null +++ b/docs/reference/classes/aero_surfaces/Fin.rst @@ -0,0 +1,5 @@ +Fin Class +--------- + +.. autoclass:: rocketpy.Fin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/FreeFormFin.rst b/docs/reference/classes/aero_surfaces/FreeFormFin.rst new file mode 100644 index 000000000..636be2c9e --- /dev/null +++ b/docs/reference/classes/aero_surfaces/FreeFormFin.rst @@ -0,0 +1,5 @@ +FreeFormFin Class +----------------- + +.. autoclass:: rocketpy.FreeFormFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/FreeFormFins.rst b/docs/reference/classes/aero_surfaces/FreeFormFins.rst new file mode 100644 index 000000000..c641ce156 --- /dev/null +++ b/docs/reference/classes/aero_surfaces/FreeFormFins.rst @@ -0,0 +1,5 @@ +FreeFormFins Class +------------------ + +.. autoclass:: rocketpy.FreeFormFins + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/TrapezoidalFin.rst b/docs/reference/classes/aero_surfaces/TrapezoidalFin.rst new file mode 100644 index 000000000..34fa631df --- /dev/null +++ b/docs/reference/classes/aero_surfaces/TrapezoidalFin.rst @@ -0,0 +1,5 @@ +TrapezoidalFin Class +-------------------- + +.. autoclass:: rocketpy.TrapezoidalFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/_BaseFin.rst b/docs/reference/classes/aero_surfaces/_BaseFin.rst new file mode 100644 index 000000000..358668e1f --- /dev/null +++ b/docs/reference/classes/aero_surfaces/_BaseFin.rst @@ -0,0 +1,7 @@ +:orphan: + +_BaseFin Class +-------------- + +.. autoclass:: rocketpy.rocket.aero_surface.fins._base_fin._BaseFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/index.rst b/docs/reference/classes/aero_surfaces/index.rst index c6e6a3efe..a3dad0417 100644 --- a/docs/reference/classes/aero_surfaces/index.rst +++ b/docs/reference/classes/aero_surfaces/index.rst @@ -11,7 +11,12 @@ AeroSurface Classes Fins TrapezoidalFins EllipticalFins + FreeFormFins + Fin + TrapezoidalFin + EllipticalFin + FreeFormFin RailButtons AirBrakes GenericSurface - LinearGenericSurface + LinearGenericSurface \ No newline at end of file diff --git a/docs/reference/classes/exceptions.rst b/docs/reference/classes/exceptions.rst new file mode 100644 index 000000000..f1dfe24b6 --- /dev/null +++ b/docs/reference/classes/exceptions.rst @@ -0,0 +1,14 @@ +Exceptions and Warnings +======================= + +RocketPy raises a small hierarchy of custom exceptions and warnings so that +error handling can be more specific than catching plain ``ValueError`` or +``UserWarning``. All exceptions inherit from :class:`~rocketpy.exceptions.RocketPyError`, +which makes it possible to catch any RocketPy-specific error with a single +``except`` clause while still deriving from the relevant built-in type +(e.g. :class:`~rocketpy.exceptions.InvalidParameterError` is also a +``ValueError``). + +.. automodule:: rocketpy.exceptions + :members: + :show-inheritance: diff --git a/docs/reference/classes/motors/RingClusterMotor.rst b/docs/reference/classes/motors/RingClusterMotor.rst new file mode 100644 index 000000000..5178216e1 --- /dev/null +++ b/docs/reference/classes/motors/RingClusterMotor.rst @@ -0,0 +1,5 @@ +RingClusterMotor Class +---------------------- + +.. autoclass:: rocketpy.RingClusterMotor + :members: diff --git a/docs/reference/classes/motors/index.rst b/docs/reference/classes/motors/index.rst index 3bd6e2d96..b98748afd 100644 --- a/docs/reference/classes/motors/index.rst +++ b/docs/reference/classes/motors/index.rst @@ -10,6 +10,7 @@ Motor Classes HybridMotor LiquidMotor GenericMotor + RingClusterMotor Fluid Tank Classes Tank Geometry Classes diff --git a/docs/reference/classes/utils/index.rst b/docs/reference/classes/utils/index.rst index 05fffd9e9..150f78d03 100644 --- a/docs/reference/classes/utils/index.rst +++ b/docs/reference/classes/utils/index.rst @@ -7,7 +7,8 @@ Utils functions tools units - utilities + utilities vector matrix - + logging + diff --git a/docs/reference/classes/utils/logging.rst b/docs/reference/classes/utils/logging.rst new file mode 100644 index 000000000..c2e933a76 --- /dev/null +++ b/docs/reference/classes/utils/logging.rst @@ -0,0 +1,4 @@ +Logging functions +----------------- + +.. autofunction:: rocketpy.utilities.enable_logging diff --git a/docs/reference/classes/utils/utilities.rst b/docs/reference/classes/utils/utilities.rst index 6376c2aad..b4bde76ca 100644 --- a/docs/reference/classes/utils/utilities.rst +++ b/docs/reference/classes/utils/utilities.rst @@ -11,4 +11,5 @@ At RocketPy projects, utilities are functions that: Below you have a list of all utilities functions available in rocketpy. .. automodule:: rocketpy.utilities - :members: \ No newline at end of file + :members: + :exclude-members: enable_logging \ No newline at end of file diff --git a/docs/reference/index.rst b/docs/reference/index.rst index de3b36ac8..48d54d989 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -18,9 +18,10 @@ This reference manual details functions, modules, methods and attributes include classes/Flight Utilities classes/EnvironmentAnalysis - Monte Carlo Analysis + Monte Carlo Analysis Sensitivity Analysis Multivariate Rejection Sampler + Exceptions and Warnings .. toctree:: :maxdepth: 2 diff --git a/docs/static/rocket/fin_forces.png b/docs/static/rocket/fin_forces.png new file mode 100644 index 000000000..41be9bfa2 Binary files /dev/null and b/docs/static/rocket/fin_forces.png differ diff --git a/docs/static/rocket/individual_fin_frame.png b/docs/static/rocket/individual_fin_frame.png new file mode 100644 index 000000000..67d417cdb Binary files /dev/null and b/docs/static/rocket/individual_fin_frame.png differ diff --git a/docs/technical/aerodynamics/individual_fins.rst b/docs/technical/aerodynamics/individual_fins.rst new file mode 100644 index 000000000..408352e16 --- /dev/null +++ b/docs/technical/aerodynamics/individual_fins.rst @@ -0,0 +1,498 @@ +.. _individual_fins: + +==================== +Individual Fin Model +==================== + +:Author: Mateus Stano Junqueira +:Date: March 2025 + +Introduction +============ + +There are currently three ways to model the aerodynamic effects of fins in RocketPy: + +1. By use of the :class:`rocketpy.GenericSurface` or :class:`rocketpy.LinearGenericSurface` classes, + which are defined by receiving the aerodynamic coefficients directly; +2. By use of the :class:`rocketpy.Fins` classes (:class:`rocketpy.TrapezoidalFins`, :class:`rocketpy.EllipticalFins`, :class:`rocketpy.FreeFormFins`), + which are defined by receiving the geometric parameters of the **fin set** and + calculating the aerodynamic coefficients internally with the Barrowman method; +3. By use of the :class:`rocketpy.Fin` classes (:class:`rocketpy.TrapezoidalFin`, :class:`rocketpy.EllipticalFin`, :class:`rocketpy.FreeFormFin`) + (which are the base classes for the fin set classes), which are defined by receiving the + geometric parameters of **one single fin** and calculating the aerodynamic + coefficients internally with the Barrowman method, with exception of the + moment coefficients, which have been derived in this document. + +This document will focus on all the mathematical derivations and implementations +of the :class:`rocketpy.Fin` classes. + +Fin Coordinate Frame +==================== + +The fin coordinate frame is defined as follows: + +- The origin is at the leading edge of the fin, at the intersection of the + fin's root chord and the fin's leading edge; +- The z-axis is aligned with the fin's root chord, pointing towards the fin's + trailing edge; +- The y-axis is aligned with the fin's span, pointing towards the fin's tip chord; +- The x-axis completes the right-handed coordinate system. + +The fin is postioned at the rocket's body, given a ``rocket_radius`` :math:`r`, +an ``angular_position`` :math:`\Gamma` and a position :math:`p` along the +rocket's body. The angular position is given according to +:ref:`Angular Position Inputs `. + + +The fin can also be rotated by a cant angle :math:`\delta`. + +The image below shows the fin coordinate frame: + +.. image:: ../../static/rocket/individual_fin_frame.png + :width: 800 + :align: center + +.. note:: + A positive cant angle :math:`\delta` produces a negative body axis rolling + moment at zero angle of attack. + +The rotation matrix from the fin coordinate frame to the rocket's body frame is +define by, first a rotation around the y-axis by 180 degrees: + +.. math:: + \mathbf{R}_{y(\pi)} = \begin{bmatrix} + -1 & 0 & 0 \\ + 0 & 1 & 0 \\ + 0 & 0 & -1 + \end{bmatrix} + +Then a rotation around the z-axis by the angle :math:`\Gamma`: + +.. math:: + \mathbf{R}_{z(\Gamma)} = \begin{bmatrix} + \cos(\Gamma) & -\sin(\Gamma) & 0 \\ + \sin(\Gamma) & \cos(\Gamma) & 0 \\ + 0 & 0 & 1 + \end{bmatrix} + +Then a rotation around the y-axis by the cant angle :math:`\delta`: + +.. math:: + \mathbf{R}_{y(\delta)} = \begin{bmatrix} + \cos(\delta) & 0 & \sin(\delta) \\ + 0 & 1 & 0 \\ + -\sin(\delta) & 0 & \cos(\delta) + \end{bmatrix} + +The final rotation matrix is given by: + +.. math:: + \mathbf{R} = \mathbf{R}_{y(\delta)} \cdot \mathbf{R}_{z(\Gamma)} \cdot \mathbf{R}_{y(\pi)} + + +The position of the fin's coordinate frame origin in the rocket's body frame +is calculated by first assuming no a fin frame with no cant angle, then +calculating the position of the fin's leading edge (with cant angle) in this +frame, and finally translating this position to the fin's position in the +rocket's body frame. The position of the fin's real leading edge in this no cant +angle fin frame is given by the point :math:`\mathbf{P}^{\delta}_{le_f}`: + +.. math:: + \mathbf{P}^{\delta}_{le_f} = \begin{bmatrix} + -\frac{Cr}{2} \sin(\delta) \\ + 0 \\ + \frac{Cr}{2} (1 - \cos(\delta)) + \end{bmatrix} + +Then, describing this point to the rocket's body frame orientation (no +translation): + +.. math:: + \mathbf{P}^{\delta}_{le_b} = (\mathbf{R}_{z(\Gamma)} \cdot \mathbf{R}_{y(\pi)}) \cdot \mathbf{P}^{\delta}_{le_f} + +The position of the fin's leading edge with no cant angle in the rocket's body +frame is given by: + +.. math:: + \mathbf{P}^{\overline{\delta}}_{le_b} = \begin{bmatrix} + -r \sin(\Gamma) \\ + r \cos(\Gamma) \\ + p + \end{bmatrix} + +Finally, we add the position of the fin's leading edge with no cant angle to the +position of the fin's leading edge with cant angle in the rocket's body frame: + +.. math:: + \mathbf{P}_{le_b} = \mathbf{P}^{\overline{\delta}}_{le_b} + \mathbf{P}^{\delta}_{le_b} + + +Center of Pressure Position +=========================== + +In the Fin Coordinate Frame, the center of pressure is given by the Barrowman +method, and will here only be defined symbolically: + +.. math:: + \mathbf{cp}_f = \begin{bmatrix} + cp_x \\ + cp_y \\ + cp_z + \end{bmatrix} + +The center of pressure position in the rocket's body frame is given by: + +.. math:: + \mathbf{cp}_{rocket} = \mathbf{R} \cdot \mathbf{cp}_f + \mathbf{P}_{le_b} + +Aerodynamic Forces +================== + +.. note:: + The aerodynamic coefficients are defined according the Barrowman method. + +Given a stream velocity in the fin frame :math:`\mathbf{v}_{0f} = [v_{0x}, v_{0y}, v_{0z}]^{T}`, +the effective angle of attack of the fin is given by: + +.. math:: + \alpha_f = \arctan\left(\frac{v_{0x}}{v_{0z}}\right) + +This can also be seen as the angle between the fin's root chord and the stream +velocity vector in the fin frame. + +The aerodynamic force in the x-direction of the fin is given by: + +.. math:: + F_{x} = \frac{1}{2} \cdot \rho \cdot \|\mathbf{v}_{0f}\|^2 \cdot A_{r} \cdot C_{N}(\alpha_f, Ma) + +Where :math:`A_{r}` is the reference area of the fin, and :math:`C_{N}` is the +normal force coefficient, which is a function of the angle of attack and the +Mach number :math:`Ma`. +This force is then transformed to the rocket's body frame by the rotation matrix: + +.. math:: + \begin{bmatrix} + F_{x} \\ + F_{y} \\ + F_{z} + \end{bmatrix}_{rocket} = \mathbf{R} \cdot \begin{bmatrix} + F_{x} \\ + 0 \\ + 0 + \end{bmatrix}_{fin} + +Then, the moments are calculated by the cross product of the center of pressure +and the aerodynamic force: + +.. math:: + \begin{bmatrix} + M_{x} \\ + M_{y} \\ + M_{z} + \end{bmatrix}_{rocket} = \mathbf{cp}_{rocket} \times \begin{bmatrix} + F_{x} \\ + F_{y} \\ + F_{z} + \end{bmatrix}_{rocket} + +From the Barrowman method, the moment along the center axis of the rocket +(:math:`M_{z}`) is still missing the damping term, which is given by: + +.. math:: + M_{damp} = \frac{1}{2} \cdot \rho \cdot \|v_{0}\| \cdot A_{r} \cdot L_{r}^2 \cdot C_{ld\omega}(Ma) \cdot \frac{1}{2} \cdot \omega_z + +.. math:: + M_{z \, \text{final}} = M_{z} + M_{damp} + +Where :math:`C_{ld}` is the roll moment damping coefficient, :math:`L_{r}` +is the reference length, which is equal to the rocket diameter, and +:math:`\omega_z` is the angular velocity of the rocket around the z-axis. + +Adding Individual Fins to the Rocket +==================================== +.. jupyter-execute:: + :hide-code: + :hide-output: + + from rocketpy import * + env = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + Pro75M1670 = SolidMotor( + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + # IMPORTANT: modify the file paths below to match your own system + + example_rocket = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + + rail_buttons = example_rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + example_rocket.add_motor(Pro75M1670, position=-1.255) + nose_cone = example_rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + tail = example_rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + example_rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + cant_angle=0.0, + position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + +Given a defined ``Rocket`` object, we can add individual fins to the rocket by +using the ``add_surfaces`` method. Here is an example of adding two canards +in the Calisto rocket from the :ref:`First Simulation ` example: + +.. jupyter-execute:: + + canard1 = TrapezoidalFin( + angular_position=0, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=180, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + # Position along the center axis of the rocket is specified here. + # If different positions are desired, the position can be specified as a list. + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + example_rocket.draw(plane="yz") + +.. seealso:: + + There are three classes for defining fins in RocketPy given their geometry: + + - :class:`rocketpy.TrapezoidalFin` - For how to define a trapezoidal fin + - :class:`rocketpy.EllipticalFin` - For how to define an elliptical fin + - :class:`rocketpy.FreeFormFin` - For how to define a free form fin + + + +Fin Force Conventions +===================== + +.. - Explain positive cant angle resultant force +.. - if all fins have positive cant angle then they will all generate a force that will rotate the rocket in the same direction +.. which is negative roll +.. - show what a positive and negative cant angle on oposing fins looks like. (generate pitch moment -> pitch control) +.. - show what a positive and negative cant angle on oposing fins looks like. (generate yaw moment -> yaw control) +.. - example for 4 fins, show how to count the number of fins and how to access each of them + + + +Here we exemplify the fin force conventions relating the cant angle +(deflection angle) of the fins to the pitch, yaw and roll moments. We will +consider a rocket with four fins, to illustrate the concepts. The image below +show the sign convention for the forces acting on the fins, given positive cant +angles: + +.. image:: ../../static/rocket/fin_forces.png + :width: 800 + :align: center + + +Roll +^^^^ + +.. jupyter-execute:: + :hide-code: + :hide-output: + + example_rocket.aerodynamic_surfaces.pop() + example_rocket.aerodynamic_surfaces.pop() + + +A positive cant angle :math:`\delta` produces a negative roll moment at zero +angle of attack. Any fin with a positive cant angle will produce a negative roll +moment, and any fin with a negative cant angle will produce a positive roll +moment. + +Here is a flight of the calisto with canards defined with a positive cant angle: + +.. jupyter-execute:: + + + canard1 = TrapezoidalFin( + angular_position=0, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=180, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + test_flight = Flight( + rocket=example_rocket, + environment=env, rail_length=5.2, inclination=85, heading=0, + terminate_on_apogee=True, + ) + + # Rolling Moment + test_flight.M3() + + # Rolling Speed + test_flight.w3() + + # Angle of attack + test_flight.partial_angle_of_attack.plot(test_flight.out_of_rail_time, 5) + + # Angle of sideslip + test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) + +Pitch +^^^^^ + +.. jupyter-execute:: + :hide-code: + :hide-output: + + example_rocket.aerodynamic_surfaces.pop() + example_rocket.aerodynamic_surfaces.pop() + +Given canards fins at 90 degrees and 270 degrees, having opposite cant angles, +a positive pitch moment will be generated. The following example shows the +effect of this configuration in the non-zero angle of attack flight of the +rocket: + +.. jupyter-execute:: + + + canard1 = TrapezoidalFin( + angular_position=90, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=270, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=-0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + test_flight = Flight( + rocket=example_rocket, + environment=env, rail_length=5.2, inclination=85, heading=0, + terminate_on_apogee=True, + ) + + # Angle of attack + test_flight.partial_angle_of_attack.plot(test_flight.out_of_rail_time, 5) + + # Angle of sideslip + test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) + +Yaw +^^^ + +.. jupyter-execute:: + :hide-code: + :hide-output: + + example_rocket.aerodynamic_surfaces.pop() + example_rocket.aerodynamic_surfaces.pop() + +Given opposing canards at 0 degrees and 180 degrees, having opposite cant angles, +a positive yaw moment will be generated. The following example shows the +effect of this configuration in the non-zero angle of attack flight of the +rocket: + +.. jupyter-execute:: + + + canard1 = TrapezoidalFin( + angular_position=0, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=180, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=-0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + test_flight = Flight( + rocket=example_rocket, + environment=env, rail_length=5.2, inclination=85, heading=0, + terminate_on_apogee=True, + ) + + # Angle of attack + test_flight.partial_angle_of_attack.plot(test_flight.out_of_rail_time, 5) + + # Angle of sideslip + test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) + + + + + diff --git a/docs/technical/index.rst b/docs/technical/index.rst index b96e611ed..73583eba9 100644 --- a/docs/technical/index.rst +++ b/docs/technical/index.rst @@ -13,6 +13,7 @@ in their code. Equations of Motion v0 Equations of Motion v1 Elliptical Fins + Individual Fin Roll Moment Sensitivity Analysis References diff --git a/docs/user/aerodynamics/surfaces.rst b/docs/user/aerodynamics/surfaces.rst new file mode 100644 index 000000000..38f266dca --- /dev/null +++ b/docs/user/aerodynamics/surfaces.rst @@ -0,0 +1,350 @@ +.. _aerodynamic_surfaces: + +==================== +Aerodynamic Surfaces +==================== + +This page provides an overview of the aerodynamic surfaces available in +RocketPy and explains how they connect to the rocket's simulation. + +RocketPy models the aerodynamic forces and moments generated by three types +of surfaces: **nose cones**, **fins**, and **tails**. Each surface is +defined by its geometric parameters and, optionally, by an airfoil profile. +The aerodynamic coefficients are computed internally using the Barrowman +method and are used during the flight simulation to evaluate the rocket's +stability and control. + +.. seealso:: + + For the full mathematical derivations of the Barrowman method applied to + individual fins, see :doc:`Individual Fin Model `. + + For the elliptical fin equations, see + :doc:`Elliptical Fins Equations `. + + For roll dynamics and cant angle conventions, see + :doc:`Roll Equations `. + + For the coordinate systems used by the Rocket class, see + :doc:`Rocket Class Axes Definitions ` and + :doc:`Positions and Coordinate Systems `. + +Nose Cones +========== + +The nose cone is the forward-most aerodynamic surface of the rocket. It +contributes to the normal force and the center of pressure position. + +Available Nose Cone Kinds +------------------------- + +RocketPy supports the following nose cone shapes, specified via the ``kind`` +parameter: + +- ``"conical"``: A simple cone with a straight taper from base to tip. +- ``"ogive"``: A tangent ogive shape, defined by the ratio of the tip + radius to the base radius (the ``bluffness`` parameter). +- ``"elliptical"``: An elliptical cross-section nose cone. +- ``"tangent"``: A tangent ogive with a rounded tip. +- ``"von karman"``: A Von Karman ogive, a common choice for supersonic + rockets. +- ``"parabolic"``: A parabolic nose cone shape. +- ``"powerseries"``: A power series nose cone, controlled by the + ``power`` parameter (must be between 0 and 1). When this kind is used, + the ``bluffness`` parameter must be ``None`` or ``0``. +- ``"lvhaack"``: A LV-Haack series nose cone. + +.. note:: + + The ``bluffness`` parameter controls the ratio between the radius of + the rounded tip and the radius of the base. It is only used for ogive + nose cones. For all other kinds, it should be ``None`` or ``0``. + +.. caution:: + + When ``kind="powerseries"``, the ``power`` parameter is required and + must satisfy ``0 < power <= 1``. A value of 1 produces a conical shape, + while lower values produce blunter shapes. + +Geometric Parameters +-------------------- + +All nose cones share the following parameters: + +- ``length``: The length of the nose cone along the rocket's centerline. +- ``base_radius``: The radius of the nose cone at its base, which must + match the rocket's radius at the nose cone position. +- ``position``: The position of the nose cone along the rocket's + centerline, relative to the coordinate system origin. See + :doc:`Positions and Coordinate Systems ` for details. + +The center of pressure of the nose cone is computed internally and +contributes to the overall aerodynamic model. The normal force coefficient +derivative (CLalpha) depends on the nose cone kind and geometry. + +.. seealso:: + + For the class API, see :class:`rocketpy.NoseCone`. + +Fins +==== + +Fins are the primary stabilizing surfaces of a rocket. RocketPy supports +three fin geometries and two ways to define them: as a **fin set** (the +usual approach) or as an **individual fin**. + +Fin Sets vs. Individual Fins +----------------------------- + +RocketPy distinguishes between two levels of fin definition: + +- **Fin set classes** (:class:`rocketpy.TrapezoidalFins`, + :class:`rocketpy.EllipticalFins`, + :class:`rocketpy.FreeFormFins`): These classes define a *set* of N + fins arranged symmetrically around the rocket. The aerodynamic + coefficients are computed using the Barrowman method, which accounts for + fin-body interference. This is the most common way to define fins. + +- **Individual fin classes** (:class:`rocketpy.TrapezoidalFin`, + :class:`rocketpy.EllipticalFin`, + :class:`rocketpy.FreeFormFin`): These classes define a *single* fin. + The moment coefficients are derived differently and are documented + separately. Individual fins are useful for canards, asymmetric + configurations, or when you need fine-grained control. + +.. seealso:: + + For the mathematical model of individual fins, including the moment + coefficient derivations, see + :doc:`Individual Fin Model `. + +Fin Geometries +-------------- + +Trapezoidal Fins +~~~~~~~~~~~~~~~~ + +Trapezoidal fins are defined by a root chord, tip chord, span, and +optionally a sweep length. They are the most common fin geometry in +model rocketry. + +Parameters: + +- ``root_chord``: The chord length at the fin's root (where it meets the + rocket body). +- ``tip_chord``: The chord length at the fin's tip. Setting this to 0 + produces a triangular fin. +- ``span``: The span of the fin, measured from the rocket body to the + fin tip. +- ``sweep_length`` (optional): The distance from the root chord leading + edge to the tip chord leading edge, measured along the rocket's + centerline. If not provided, it is computed from the root chord, tip + chord, and span. +- ``cant_angle`` (optional): The angle at which the fin is canted + relative to the rocket's centerline, in radians. A positive cant angle + produces a negative roll moment at zero angle of attack. + +.. caution:: + + The ``cant_angle`` convention is important for control surface design. + See :doc:`Roll Equations ` for + details on how cant angles affect roll, pitch, and yaw moments. + +Elliptical Fins +~~~~~~~~~~~~~~~ + +Elliptical fins are defined by a root chord and a span. The chord length +varies elliptically from root to tip. + +Parameters: + +- ``root_chord``: The chord length at the fin's root. +- ``span``: The span of the fin. + +.. seealso:: + + For the full mathematical derivation, see + :doc:`Elliptical Fins Equations `. + +Free Form Fins +~~~~~~~~~~~~~~ + +Free form fins allow arbitrary fin shapes defined by a list of +``(x, y)`` coordinates. This is useful for non-standard fin geometries +that cannot be represented by trapezoidal or elliptical shapes. + +Parameters: + +- ``coordinates``: A list of ``(x, y)`` tuples defining the fin shape + in the fin coordinate frame. + +Common Fin Set Parameters +------------------------- + +In addition to the geometry-specific parameters above, all fin sets +share the following: + +- ``n``: The number of fins in the set (for fin set classes only). +- ``position``: The position of the fin set along the rocket's + centerline, relative to the coordinate system origin. +- ``angular_position`` (optional): The roll angle of the first fin in + the set, in degrees. Measured from the y-axis of the rocket's + coordinate system. +- ``rocket_radius``: The radius of the rocket at the fin's position. + This is usually set automatically when adding fins to a rocket. +- ``airfoil`` (optional): A tuple ``(path, units)`` specifying an + airfoil profile. See :ref:`airfoil_profiles` below. + +.. _airfoil_profiles: + +Airfoil Profiles +---------------- + +Fin aerodynamic coefficients can be enhanced by specifying an airfoil +profile. Without an airfoil, fins are treated as flat plates. + +The ``airfoil`` parameter accepts a tuple of the form ``(path, units)``: + +- ``path``: Path to a CSV file containing the airfoil's lift coefficient + curve. The first column is the angle of attack, the second column is + the lift coefficient (CL). +- ``units``: The unit of the angle of attack in the CSV file. Must be + either ``"radians"`` or ``"degrees"``. + +The CSV file should contain angle of attack points up to the stall point. +The data is used to compute the fin's CLalpha (normal force coefficient +derivative) during the simulation. + +.. note:: + + If ``airfoil`` is not provided or is ``None``, the fin is modeled as + a flat plate. This is a reasonable approximation for many model + rockets but may underestimate the normal force coefficient. + +.. seealso:: + + Airfoil data files can be obtained from + `Airfoil Tools `_. + +Adding Fins to a Rocket +----------------------- + +Fins can be added to a rocket using the ``Rocket`` class methods: + +.. code-block:: python + + from rocketpy import Rocket, TrapezoidalFins + + rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/powerOffDragCurve.csv", + power_on_drag="data/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + + fin_set = rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + position=-1.04956, + cant_angle=0.0, + airfoil=("data/airfoils/NACA0012-radians.txt", "radians"), + ) + +.. seealso:: + + - :meth:`rocketpy.Rocket.add_trapezoidal_fins` + - :meth:`rocketpy.Rocket.add_elliptical_fins` + - :meth:`rocketpy.Rocket.add_free_form_fins` + - :meth:`rocketpy.Rocket.add_surfaces` (for individual fins) + +Tail +==== + +The tail is a transitional surface at the aft end of the rocket, typically +used to reduce base drag. It is defined by a top radius, bottom radius, +and length. + +Parameters: + +- ``top_radius``: The radius of the tail at its top (where it meets the + rocket body). +- ``bottom_radius``: The radius of the tail at its bottom (aft end). +- ``length``: The length of the tail along the rocket's centerline. +- ``position``: The position of the tail along the rocket's centerline. + +The tail contributes to the normal force and center of pressure +calculation. Its effect is generally smaller than that of the nose cone +and fins. + +.. seealso:: + + For the class API, see :class:`rocketpy.Tail`. + +Generic Surfaces +================ + +For advanced use cases, RocketPy provides the +:class:`rocketpy.GenericSurface` and +:class:`rocketpy.LinearGenericSurface` classes. These allow you to +specify aerodynamic force and moment coefficients directly, rather than +relying on the built-in geometric models. + +This is useful when: + +- You have aerodynamic data from CFD simulations or wind tunnel tests. +- You want to model aerodynamic surfaces that do not fit the built-in + geometries (e.g., canards with unusual shapes, air brakes). + +.. seealso:: + + For more information, see + :doc:`Generic Surfaces and Custom Aerodynamic Coefficients `. + +.. note:: + + This section provides a brief overview of generic surfaces. A more + detailed treatment of coordinate frames and coefficient conventions + for generic surfaces is available in the referenced user guide page. + +Reference Area and Length +========================= + +The aerodynamic forces and moments computed by each surface are scaled by +a reference area and a reference length: + +- **Reference area**: The cross-sectional area of the rocket, computed + as :math:`\pi r^2` where :math:`r` is the rocket's radius. +- **Reference length**: The diameter of the rocket (:math:`2r`). + +These values are used consistently across all aerodynamic surfaces and +are set automatically from the rocket's radius. + +.. seealso:: + + For more details on how forces and moments are applied during the + simulation, see :class:`rocketpy.Rocket` and + :class:`rocketpy.Flight`. + +What's Next +============ + +This page covers the available surface types and their parameters. For +deeper understanding of the underlying math: + +- :doc:`Individual Fin Model `: + Full derivation of the Barrowman method for individual fins, including + moment coefficients. +- :doc:`Elliptical Fins Equations `: + Geometric parameters and center of pressure for elliptical fins. +- :doc:`Roll Equations `: + Roll moment and damping equations for high-powered rockets. + +For coordinate system conventions: + +- :doc:`Rocket Class Axes Definitions ` +- :doc:`Positions and Coordinate Systems ` diff --git a/docs/user/airbrakes.rst b/docs/user/airbrakes.rst index 97d39ba59..5d47d4d6b 100644 --- a/docs/user/airbrakes.rst +++ b/docs/user/airbrakes.rst @@ -95,60 +95,23 @@ To create an air brakes model, we essentially need to define the following: ``deployment_level`` attribute. Inside this function, any controller logic, filters, and apogee prediction can be implemented. -- The **sampling rate** of the controller function, in seconds. This is the time - between each call of the controller function, in simulation time. Must be - given in Hertz. +- The **sampling rate** of the controller function, in Hertz. This is how often + the controller function is called in simulation time (a **discrete** + controller). It can also be set to ``None`` to create a **continuous** + controller that is called at every solver step (see + :ref:`discrete-vs-continuous-controllers` below). Defining the Controller Function ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Lets start by defining a very simple controller function. -The ``controller_function`` must take in the following arguments, in this -order: - -1. ``time`` (float): The current simulation time in seconds. -2. ``sampling_rate`` (float): The rate at which the controller - function is called, measured in Hertz (Hz). -3. ``state`` (list): The state vector of the simulation. The state - is a list containing the following values, in this order: - - - ``x``: The x position of the rocket, in meters. - - ``y``: The y position of the rocket, in meters. - - ``z``: The z position of the rocket, in meters. - - ``v_x``: The x component of the velocity of the rocket, in meters per - second. - - ``v_y``: The y component of the velocity of the rocket, in meters per - second. - - ``v_z``: The z component of the velocity of the rocket, in meters per - second. - - ``e0``: The first component of the quaternion representing the rotation - of the rocket. - - ``e1``: The second component of the quaternion representing the rotation - of the rocket. - - ``e2``: The third component of the quaternion representing the rotation - of the rocket. - - ``e3``: The fourth component of the quaternion representing the rotation - of the rocket. - - ``w_x``: The x component of the angular velocity of the rocket, in - radians per second. - - ``w_y``: The y component of the angular velocity of the rocket, in - radians per second. - - ``w_z``: The z component of the angular velocity of the rocket, in - radians per second. - -4. ``state_history`` (list): A record of the rocket's state at each - step throughout the simulation. The state_history is organized as - a list of lists, with each sublist containing a state vector. The - last item in the list always corresponds to the previous state - vector, providing a chronological sequence of the rocket's - evolving states. -5. ``observed_variables`` (list): A list containing the variables that - the controller function returns. The return of each controller - function call is appended to the observed_variables list. The - initial value in the first step of the simulation of this list is - provided by the ``initial_observed_variables`` argument. -6. ``air_brakes`` (AirBrakes): The ``AirBrakes`` instance being controlled. +The ``controller_function`` receives information about the simulation at the +current time step and sets the air brakes' deployment level. See +:ref:`controllers` for the full description of its arguments and call signature. +For air brakes, the controlled object (the ``air_brakes`` argument) is the +:class:`rocketpy.AirBrakes` instance, whose ``deployment_level`` the function +sets. Our example ``controller_function`` will deploy the air brakes when the rocket reaches 1500 meters above the ground. The deployment level will be function of the @@ -224,22 +187,6 @@ Lets define the controller function: .. note:: - - The ``controller_function`` accepts 6, 7, or 8 parameters for backward - compatibility: - - * **6 parameters** (original): ``time``, ``sampling_rate``, ``state``, - ``state_history``, ``observed_variables``, ``air_brakes`` - * **7 parameters** (with sensors): adds ``sensors`` as the 7th parameter - * **8 parameters** (with environment): adds ``sensors`` and ``environment`` - as the 7th and 8th parameters - - - The **environment parameter** provides access to atmospheric conditions - (wind, temperature, pressure, elevation) without relying on global variables. - This enables proper serialization of rockets with air brakes and improves - code modularity. Available methods include ``environment.elevation``, - ``environment.wind_velocity_x(altitude)``, ``environment.wind_velocity_y(altitude)``, - ``environment.speed_of_sound(altitude)``, and others. - - The code inside the ``controller_function`` can be as complex as needed. Anything can be implemented inside the function, including filters, apogee prediction, and any controller logic. @@ -249,15 +196,10 @@ Lets define the controller function: 0 or higher than 1. If you want to disable this feature, set ``clamp`` to ``False`` when defining the air brakes. - - Anything can be returned by the ``controller_function``. The returned - values will be saved in the ``observed_variables`` list at every time step - and can then be accessed by the ``controller_function`` at the next time - step. The saved values can also be accessed after the simulation is - finished. This is useful for debugging and for plotting the results. - - - The ``controller_function`` can also be defined in a separate file and - imported into the simulation script. This includes importing a ``c`` or - ``cpp`` code into Python. + - See :ref:`controllers` for the controller-function signature (including the + 6/7/8-parameter forms and the ``environment`` argument), how returned + values are stored in ``observed_variables``, and discrete vs. continuous + controllers. Defining the Drag Coefficient @@ -372,6 +314,15 @@ controller function. If you want to disable this feature, set ``clamp`` to For more information on the :class:`rocketpy.AirBrakes` class initialization, see :class:`rocketpy.AirBrakes.__init__` section. +.. note:: + + Because our controller uses ``sampling_rate=10``, it is a **discrete** + controller and adds its sampling instants as time nodes to the simulation. + Remember to set ``time_overshoot=False`` in the ``Flight`` (see below) so the + integrator stops exactly at those instants. See + :ref:`discrete-vs-continuous-controllers` for the difference between discrete + and continuous controllers. + Simulating a Flight ------------------- diff --git a/docs/user/compare_flights.rst b/docs/user/compare_flights.rst index 70f5fc7bb..d7f8478c8 100644 --- a/docs/user/compare_flights.rst +++ b/docs/user/compare_flights.rst @@ -26,8 +26,8 @@ This is done following the same steps as in the :ref:`firstsimulation` example. .. jupyter-execute:: - after_tomorrow = datetime.now() + timedelta(days=2) - env = Environment(latitude=-23, longitude=-49, date=after_tomorrow) + tomorrow = datetime.now() + timedelta(days=1) + env = Environment(latitude=-23, longitude=-49, date=tomorrow) env.set_atmospheric_model(type="Forecast", file="GFS") cesaroni_motor = SolidMotor( diff --git a/docs/user/controllers.rst b/docs/user/controllers.rst new file mode 100644 index 000000000..0b866dfb0 --- /dev/null +++ b/docs/user/controllers.rst @@ -0,0 +1,132 @@ +.. _controllers: + +Controllers +=========== + +RocketPy can simulate active, in-flight control systems — such as air brakes or +other actuators — through a *controller function* that you attach to a +controllable object (for example, an :class:`rocketpy.AirBrakes` added with +``Rocket.add_air_brakes``). During the flight simulation, RocketPy calls your +controller function at the appropriate times, letting it read the current +simulation state and command the actuator. + +This page describes the controller-function interface and how its call timing is +governed. For a complete, runnable example that puts these pieces together, see +the :doc:`Air Brakes Example `. + +The Controller Function +----------------------- + +A controller function receives information about the simulation up to the +current time step and sets the state of the controlled object. It must take in +the following arguments, in this order: + +1. ``time`` (float): The current simulation time in seconds. +2. ``sampling_rate`` (float or ``None``): The rate at which the controller + function is called, measured in Hertz (Hz). It is ``None`` for continuous + controllers, so guard any ``1 / sampling_rate`` computation against ``None``. +3. ``state`` (list): The state vector of the simulation. The state + is a list containing the following values, in this order: + + - ``x``: The x position of the rocket, in meters. + - ``y``: The y position of the rocket, in meters. + - ``z``: The z position of the rocket, in meters. + - ``v_x``: The x component of the velocity of the rocket, in meters per + second. + - ``v_y``: The y component of the velocity of the rocket, in meters per + second. + - ``v_z``: The z component of the velocity of the rocket, in meters per + second. + - ``e0``: The first component of the quaternion representing the rotation + of the rocket. + - ``e1``: The second component of the quaternion representing the rotation + of the rocket. + - ``e2``: The third component of the quaternion representing the rotation + of the rocket. + - ``e3``: The fourth component of the quaternion representing the rotation + of the rocket. + - ``w_x``: The x component of the angular velocity of the rocket, in + radians per second. + - ``w_y``: The y component of the angular velocity of the rocket, in + radians per second. + - ``w_z``: The z component of the angular velocity of the rocket, in + radians per second. + +4. ``state_history`` (list): A record of the rocket's state at each + step throughout the simulation. The state_history is organized as + a list of lists, with each sublist containing a state vector. The + last item in the list always corresponds to the previous state + vector, providing a chronological sequence of the rocket's + evolving states. +5. ``observed_variables`` (list): A list containing the variables that + the controller function returns. The return of each controller + function call is appended to the observed_variables list. The + initial value in the first step of the simulation of this list is + provided by the ``initial_observed_variables`` argument. +6. The **controlled object** (e.g. ``air_brakes``): the instance being + controlled, whose attributes the function sets (for example, + ``air_brakes.deployment_level``). + +.. note:: + + - The controller function accepts 6, 7, or 8 parameters for backward + compatibility: + + * **6 parameters** (original): ``time``, ``sampling_rate``, ``state``, + ``state_history``, ``observed_variables``, and the controlled object. + * **7 parameters** (with sensors): adds ``sensors`` as the 7th parameter. + * **8 parameters** (with environment): adds ``sensors`` and ``environment`` + as the 7th and 8th parameters. + + - The **environment parameter** provides access to atmospheric conditions + (wind, temperature, pressure, elevation) without relying on global + variables. This enables proper serialization of rockets with controllers + and improves code modularity. Available methods include + ``environment.elevation``, ``environment.wind_velocity_x(altitude)``, + ``environment.wind_velocity_y(altitude)``, + ``environment.speed_of_sound(altitude)``, and others. + + - Anything can be returned by the controller function. The returned values + are saved in the ``observed_variables`` list at every time step and can + then be accessed by the controller function at the next time step. The + saved values can also be accessed after the simulation is finished, which + is useful for debugging and for plotting the results. + + - The controller function can also be defined in a separate file and + imported into the simulation script. This includes importing ``c`` or + ``cpp`` code into Python. + +.. _discrete-vs-continuous-controllers: + +Discrete vs. Continuous Controllers +----------------------------------- + +The ``sampling_rate`` argument determines *when* the controller function is +called during the flight simulation: + +- **Discrete controller** (``sampling_rate`` set to a number, e.g. ``10``): + the controller function is called at fixed intervals of ``1 / sampling_rate`` + seconds. This mirrors a real flight computer that reads its sensors and + updates its actuators at a fixed frequency, and is the recommended choice + when you want the simulation to reflect the actual control-loop rate of your + hardware. +- **Continuous controller** (``sampling_rate=None``): the controller function + is called at *every* solver step of the numerical integrator. Use this when + you want the control law to act as a continuous function of the state rather + than a sampled one (for example, when validating a control model + analytically). + +.. warning:: + + For continuous controllers, ``sampling_rate`` is passed to your controller + function as ``None``. Any computation such as ``1 / sampling_rate`` (a + common pattern for rate-limiting deployment) must guard against ``None`` to + avoid a ``TypeError``. + +.. note:: + + Discrete controllers add their sampling instants as time nodes to the + simulation, so remember to set ``time_overshoot=False`` in the ``Flight`` + to make the integrator stop exactly at those instants. Continuous + controllers do not add time nodes; they are evaluated on the integrator's + own steps. diff --git a/docs/user/environment/1-atm-models/ensemble.rst b/docs/user/environment/1-atm-models/ensemble.rst index 97c247f68..8dffaac00 100644 --- a/docs/user/environment/1-atm-models/ensemble.rst +++ b/docs/user/environment/1-atm-models/ensemble.rst @@ -1,3 +1,5 @@ +.. _ensemble_atmosphere: + Ensemble ======== @@ -21,7 +23,21 @@ Ensemble Forecast Global Ensemble Forecast System (GEFS) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The ``GEFS`` model is a global ensemble forecast model ... +.. danger:: + + **GEFS unavailable**: ``file="GEFS"`` is currently disabled in + RocketPy because NOMADS OPeNDAP has been deactivated. + +.. note:: + + If you have a GEFS-compatible NetCDF or OPeNDAP dataset from another + provider (or a local copy), you can still load it explicitly by passing the + dataset path/URL in ``file`` and a compatible mapping in ``dictionary``. + + +The ``GEFS`` model is a global ensemble forecast system useful for uncertainty +analysis, but RocketPy's automatic ``file="GEFS"`` shortcut is temporarily +disabled. .. code-block:: python @@ -71,20 +87,16 @@ CMC Ensemble resulted in a change of the model's endpoint. Efforts are underway to \ restore access to the CMC Ensemble model as swiftly as possible. -.. code-block:: python +At the moment, there is no built-in ``file="CMC"`` shortcut in +``Environment.set_atmospheric_model``. - env_cmc = Environment( - date=date_info, - latitude=-21.960641, - longitude=-47.482122, - elevation=640, - ) - env_cmc.set_atmospheric_model(type="Ensemble", file="CMC") - env_cmc.all_info() +If you have a CMC-compatible NetCDF or OPeNDAP dataset, load it explicitly by +passing the dataset path/URL in ``file`` and a matching mapping dictionary in +``dictionary``. Ensemble Reanalysis ------------------- Ensemble reanalyses are also possible with RocketPy. See the -:ref:`reanalysis_ensemble` section for more information. +:ref:`reanalysis_ensemble` section for more information. \ No newline at end of file diff --git a/docs/user/environment/1-atm-models/forecast.rst b/docs/user/environment/1-atm-models/forecast.rst index c88c71ff2..ea81c356a 100644 --- a/docs/user/environment/1-atm-models/forecast.rst +++ b/docs/user/environment/1-atm-models/forecast.rst @@ -6,8 +6,8 @@ Forecasts Weather forecasts can be used to set the atmospheric model in RocketPy. Here, we will showcase how to import global forecasts such as GFS, as well as -local forecasts like NAM and RAP for North America, all available through -OPeNDAP on the `NOAA's NCEP NOMADS `_ website. +local forecasts like NAM, RAP and HRRR for North America, all available through +OPeNDAP on the `UCAR THREDDS `_ server. Other generic forecasts can also be imported. .. important:: @@ -22,9 +22,13 @@ Other generic forecasts can also be imported. Global Forecast System (GFS) ---------------------------- +GFS is NOAA's global numerical weather prediction model. It provides worldwide +atmospheric forecasts and is usually a good default choice when you need broad +coverage, consistent availability, and launch planning several days ahead. + Using the latest forecast from GFS is simple. Set the atmospheric model to ``forecast`` and specify that GFS is the file you want. -Note that since data is downloaded from the NOMADS server, this line of code can +Note that since data is downloaded from a remote OPeNDAP server, this line of code can take longer than usual. .. jupyter-execute:: @@ -48,9 +52,34 @@ take longer than usual. `GFS overview page `_. +Artificial Intelligence Global Forecast System (AIGFS) +------------------------------------------------------ + +AIGFS is a global AI-based forecast product distributed through the same THREDDS +ecosystem used by other RocketPy forecast inputs. It is useful when you want a +global forecast alternative to traditional physics-only models. + +RocketPy supports the latest AIGFS global forecast through THREDDS. + +.. jupyter-execute:: + + env_aigfs = Environment(date=tomorrow) + env_aigfs.set_atmospheric_model(type="forecast", file="AIGFS") + env_aigfs.plots.atmospheric_model() + +.. note:: + + AIGFS is currently available as a global 0.25 degree forecast product on + UCAR THREDDS. + + North American Mesoscale Forecast System (NAM) ---------------------------------------------- +NAM is a regional forecast model focused on North America. It is best suited +for launches inside its coverage area when you want finer regional detail than +global models typically provide. + You can also request the latest forecasts from NAM. Since this is a regional model for North America, you need to specify latitude and longitude points within North America. @@ -78,6 +107,10 @@ We will use **SpacePort America** for this, represented by coordinates Rapid Refresh (RAP) ------------------- +RAP is a short-range, high-frequency regional model for North America. It is +especially useful for near-term operations, where fast update cycles are more +important than long forecast horizon. + The Rapid Refresh (RAP) model is another regional model for North America. It is similar to NAM, but with a higher resolution and a shorter forecast range. The same coordinates for SpacePort America will be used. @@ -111,36 +144,53 @@ The same coordinates for SpacePort America will be used. High Resolution Window (HIRESW) ------------------------------- +HIRESW is a convection-allowing, high-resolution regional system designed to +resolve local weather structure better than coarser grids. It is most useful +for short-range, local analysis where small-scale wind and weather features +matter. + The High Resolution Window (HIRESW) model is a sophisticated weather forecasting system that operates at a high spatial resolution of approximately 3 km. It utilizes two main dynamical cores: the Advanced Research WRF (WRF-ARW) and the Finite Volume Cubed Sphere (FV3), each designed to enhance the accuracy of weather predictions. -You can easily set up HIRESW in RocketPy by specifying the date, latitude, and -longitude of your location. Let's use SpacePort America as an example. +.. danger:: -.. jupyter-execute:: + **HIRESW shortcut unavailable**: ``file="HIRESW"`` is currently disabled in + RocketPy because NOMADS OPeNDAP is deactivated for this endpoint. - env_hiresw = Environment( +If you have a HIRESW-compatible dataset from another provider (or a local copy), +you can still load it explicitly by passing the path/URL in ``file`` and an +appropriate mapping in ``dictionary``. + + +High-Resolution Rapid Refresh (HRRR) +------------------------------------ + +HRRR is a high-resolution, short-range forecast model for North America with +hourly updates. It is generally best for day-of-launch weather assessment and +rapidly changing local conditions. + +RocketPy supports HRRR through a dedicated THREDDS shortcut. +Like NAM and RAP, HRRR is a regional model over North America, so you need to +specify latitude and longitude points within its coverage area. The same +SpacePort America coordinates used above will be reused here. + +.. code-block:: python + + env_hrrr = Environment( date=tomorrow, latitude=32.988528, longitude=-106.975056, ) - - env_hiresw.set_atmospheric_model( - type="Forecast", - file="HIRESW", - dictionary="HIRESW", - ) - - env_hiresw.plots.atmospheric_model() + env_hrrr.set_atmospheric_model(type="forecast", file="HRRR") + env_hrrr.plots.atmospheric_model() .. note:: - The HRES model is updated every 12 hours, providing forecasts with a \ - resolution of 3 km. The model can predict weather conditions up to 48 hours \ - in advance. RocketPy uses the CONUS domain with ARW core. + HRRR is a high-resolution regional model with approximately 2.5 km grid + spacing over CONUS. Availability depends on upstream THREDDS data services. Using Windy Atmosphere @@ -175,6 +225,10 @@ to EuRoC's launch area in Portugal. ECMWF ^^^^^ +ECMWF (HRES) is a global, high-skill forecast model known for strong +medium-range performance. It is often a good choice for mission planning when +you need reliable synoptic-scale forecasts several days ahead. + We can use the ``ECMWF`` model from Windy.com. .. jupyter-execute:: @@ -194,6 +248,10 @@ We can use the ``ECMWF`` model from Windy.com. GFS ^^^ +Windy's GFS option provides NOAA's global model through Windy's interface. It +is a practical baseline for global coverage and for comparing against other +models when assessing forecast uncertainty. + The ``GFS`` model is also available on Windy.com. This is the same model as described in the :ref:`global-forecast-system` section. @@ -207,6 +265,10 @@ described in the :ref:`global-forecast-system` section. ICON ^^^^ +ICON is DWD's global weather model, available in Windy for broad-scale +forecasting. It is useful as an independent global model source to cross-check +wind and temperature trends against GFS or ECMWF. + The ICON model is a global weather forecasting model already available on Windy.com. .. jupyter-execute:: @@ -224,6 +286,10 @@ The ICON model is a global weather forecasting model already available on Windy. ICON-EU ^^^^^^^ +ICON-EU is the regional European configuration of ICON, with higher spatial +detail over Europe than ICON-Global. It is best for European launch sites when +regional structure is important. + The ICON-EU model is a regional weather forecasting model available on Windy.com. .. code-block:: python @@ -248,6 +314,5 @@ Also, the servers may be down or may face high traffic. .. seealso:: - To see a complete list of available models on the NOAA's NOMADS server, visit - `NOMADS `_. - + To browse available NCEP model collections on UCAR THREDDS, visit + `THREDDS NCEP Catalog `_. diff --git a/docs/user/environment/1-atm-models/soundings.rst b/docs/user/environment/1-atm-models/soundings.rst index 9a276477e..39fa57d17 100644 --- a/docs/user/environment/1-atm-models/soundings.rst +++ b/docs/user/environment/1-atm-models/soundings.rst @@ -13,20 +13,34 @@ Wyoming Upper Air Soundings The University of Wyoming - College of Engineering - Department of Atmospheric Sciences has a comprehensive collection of atmospheric soundings on their website, -accessible `here `_. +accessible `here `_. For this example, we will use the sounding from 83779 SBMT Marte Civ Observations -at 04 Feb 2019, which can be accessed using this URL: -http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779 +at 05 Feb 2019, which can be accessed using this URL: +https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05%2012:00:00&id=83779&type=TEXT:LIST + +.. important:: + + The University of Wyoming discontinued the legacy + ``weather.uwyo.edu/cgi-bin/sounding`` endpoint. URLs in that old format no + longer work; use the new ``weather.uwyo.edu/wsgi/sounding`` format shown + above, which RocketPy supports since v1.13.0. Initialize a new Environment instance: -.. jupyter-execute:: +.. note:: + + The example below is shown as static code (it is not executed during the + documentation build) because it requires a live network request to an + external University of Wyoming service. Run it locally to fetch the + sounding and see the resulting atmospheric plots. + +.. code-block:: python from rocketpy import Environment - url = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779" + url = "https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05%2012:00:00&id=83779&type=TEXT:LIST" env = Environment() env.set_atmospheric_model(type="wyoming_sounding", file=url) @@ -57,31 +71,22 @@ This service allows users to download virtual soundings from numerical weather prediction models such as GFS, RAP, and NAM, and also real soundings from the Integrated Global Radiosonde Archive (IGRA). -These options can be retrieved as a text file in GSD format. -By generating such a file through the link above, the file's URL can be used to -import the atmospheric data into RocketPy. - -We will use the same sounding station as we did for the Wyoming Soundings. +These options can be retrieved as a text file in GSD format. However, +RocketPy no longer provides a dedicated ``set_atmospheric_model`` type for +NOAA RUC Soundings, since NOAA has discontinued the OPENDAP service. .. note:: Select ROABs as the initial data source, specify the station through its \ WMO-ID, and opt for the ASCII (GSD format) button. -Initialize a new Environment instance: +If you need to use RUC-sounding-like data in RocketPy, convert it to one of the +supported workflows: -.. code-block:: python - - url = r"https://rucsoundings.noaa.gov/get_raobs.cgi?data_source=RAOB&latest=latest&start_year=2019&start_month_name=Feb&start_mday=5&start_hour=12&start_min=0&n_hrs=1.0&fcst_len=shortest&airport=83779&text=Ascii%20text%20%28GSD%20format%29&hydrometeors=false&start=latest" - - env = Environment() - env.set_atmospheric_model(type="NOAARucSounding", file=url) - env.plots.atmospheric_model() +- Use :ref:`custom_atmosphere` after parsing the text data. +- Use :ref:`reanalysis` or :ref:`forecast` with NetCDF/OPeNDAP sources. .. note:: The leading `r` in the URL string is used to indicate a raw string, which \ - is useful when dealing with backslashes in URLs. - - - + is useful when dealing with backslashes in URLs. \ No newline at end of file diff --git a/docs/user/environment/1-atm-models/standard_atmosphere.rst b/docs/user/environment/1-atm-models/standard_atmosphere.rst index 0c125dfd8..d6c1de782 100644 --- a/docs/user/environment/1-atm-models/standard_atmosphere.rst +++ b/docs/user/environment/1-atm-models/standard_atmosphere.rst @@ -1,3 +1,5 @@ +.. _standard_atmosphere: + Standard Atmosphere =================== @@ -29,4 +31,4 @@ The International Standard Atmosphere can also be reset at any time by using the .. jupyter-execute:: - env.set_atmospheric_model(type="standard_atmosphere") + env.set_atmospheric_model(type="standard_atmosphere") \ No newline at end of file diff --git a/docs/user/environment/3-further/other_apis.rst b/docs/user/environment/3-further/other_apis.rst index c70fd58f7..37a9a0949 100644 --- a/docs/user/environment/3-further/other_apis.rst +++ b/docs/user/environment/3-further/other_apis.rst @@ -1,3 +1,5 @@ +.. _environment_other_apis: + Connecting to other APIs ======================== @@ -25,14 +27,19 @@ the following dimensions and variables: - Latitude - Longitude - Pressure Levels +- Temperature (as a function of Time, Pressure Levels, Latitude and Longitude) - Geopotential Height (as a function of Time, Pressure Levels, Latitude and Longitude) +- or Geopotential (as a function of Time, Pressure Levels, Latitude and Longitude) - Surface Geopotential Height (as a function of Time, Latitude and Longitude) + (optional) - Wind - U Component (as a function of Time, Pressure Levels, Latitude and Longitude) - Wind - V Component (as a function of Time, Pressure Levels, Latitude and Longitude) +Some projected grids also require a ``projection`` key in the mapping. + -For example, let's imagine we want to use the HIRESW model from this endpoint: -`https://nomads.ncep.noaa.gov/dods/hiresw/ `_ +For example, let's imagine we want to use a forecast model available via an +OPeNDAP endpoint. Looking through the variable list in the link above, we find the following correspondence: @@ -72,15 +79,84 @@ Therefore, we can create an environment like this: dictionary=name_mapping, ) +Built-in mapping dictionaries +----------------------------- + +Instead of a custom dictionary, you can pass a built-in mapping name in the +``dictionary`` argument. Common options include: + +- ``"ECMWF"`` +- ``"ECMWF_v0"`` +- ``"NOAA"`` +- ``"GFS"`` +- ``"AIGFS"`` +- ``"NAM"`` +- ``"RAP"`` +- ``"HRRR"`` +- ``"HIRESW"`` (mapping available; latest-model shortcut currently disabled) +- ``"GEFS"`` (mapping available; latest-model shortcut currently disabled) +- ``"MERRA2"`` +- ``"CMC"`` (for compatible datasets loaded explicitly) + +What a mapping name means +^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Base mapping names (for example ``"GFS"``, ``"NAM"`` and ``"RAP"``) map + RocketPy weather keys to the current default variable naming used by the + corresponding provider datasets. +- These defaults are aligned with current shortcut workflows (for example, + THREDDS-backed latest model sources) and may use projected coordinates + (``x``/``y`` plus ``projection``) depending on the model. + +Legacy mapping names +^^^^^^^^^^^^^^^^^^^^ + +If you are loading archived or older NOMADS-style datasets, use the explicit +legacy aliases: + +- ``"GFS_LEGACY"`` +- ``"NAM_LEGACY"`` +- ``"NOAA_LEGACY"`` +- ``"RAP_LEGACY"`` +- ``"GEFS_LEGACY"`` + +Legacy aliases primarily cover older variable naming patterns such as +``lev``, ``tmpprs``, ``hgtprs``, ``ugrdprs`` and ``vgrdprs``. + +.. note:: + + Mapping names are case-insensitive. For example, + ``"gfs_legacy"`` and ``"GFS_LEGACY"`` are equivalent. + +For custom dictionaries, the canonical structure is: + +.. code-block:: python + + mapping = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "temperature": "tmpprs", + "surface_geopotential_height": "hgtsfc", # optional + "geopotential_height": "hgtprs", # or geopotential + "geopotential": None, + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + +.. important:: + + Ensemble datasets require an additional key for member selection: + ``"ensemble": ""``. + .. caution:: - Notice the ``file`` argument were suppressed in the code above. This is because \ - the URL depends on the date you are running the simulation. For example, as \ - it for now, a possible link could be: https://nomads.ncep.noaa.gov/dods/hiresw/hiresw20240803/hiresw_conusfv3_12z \ - (for the 3rd of August, 2024, at 12:00 UTC). \ - You should replace the date in the URL with the date you are running the simulation. \ - Different models may have different URL structures, so be sure to check the \ - documentation of the model you are using. + The ``file`` argument was intentionally omitted in the example above. This is + because the URL depends on the provider, dataset, and date you are running + the simulation. Build the endpoint according to the provider specification + and always validate that the target service is active before running your + simulation workflow. Without OPeNDAP protocol @@ -94,4 +170,3 @@ Environment class, for example: - `Meteomatics `_: `#545 `_ - `Open-Meteo `_: `#520 `_ - diff --git a/docs/user/first_simulation.rst b/docs/user/first_simulation.rst index 364557891..531b84150 100644 --- a/docs/user/first_simulation.rst +++ b/docs/user/first_simulation.rst @@ -587,7 +587,7 @@ Use the dedicated exporter class: .. note:: - The legacy method ``Flight.export_kml`` is deprecated. Use + The legacy method ``Flight.export_kml`` was removed in v1.13.0. Use :meth:`rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_kml`. Manipulating results @@ -687,7 +687,7 @@ by not passing any attribute names: .. note:: - The legacy method ``Flight.export_data`` is deprecated. Use + The legacy method ``Flight.export_data`` was removed in v1.13.0. Use :meth:`rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_data`. Saving and Storing Plots diff --git a/docs/user/flight.rst b/docs/user/flight.rst index 31e7ab588..173541727 100644 --- a/docs/user/flight.rst +++ b/docs/user/flight.rst @@ -274,7 +274,7 @@ During the rail launch phase, RocketPy calculates reaction forces and internal b **Rail Button Forces (N):** - ``rail_button1_normal_force`` : Normal reaction force at upper rail button -- ``rail_button1_shear_force`` : Shear (tangential) reaction force at upper rail button +- ``rail_button1_shear_force`` : Shear (tangential) reaction force at upper rail button - ``rail_button2_normal_force`` : Normal reaction force at lower rail button - ``rail_button2_shear_force`` : Shear (tangential) reaction force at lower rail button @@ -282,7 +282,7 @@ During the rail launch phase, RocketPy calculates reaction forces and internal b - ``rail_button1_bending_moment`` : Time-dependent bending moment at upper rail button attachment - ``max_rail_button1_bending_moment`` : Maximum absolute bending moment at upper rail button -- ``rail_button2_bending_moment`` : Time-dependent bending moment at lower rail button attachment +- ``rail_button2_bending_moment`` : Time-dependent bending moment at lower rail button attachment - ``max_rail_button2_bending_moment`` : Maximum absolute bending moment at lower rail button **Calculation Method:** @@ -454,6 +454,282 @@ Flight Data Plots # Flight path and orientation flight.plots.flight_path_angle_data() +3D Flight Animation +~~~~~~~~~~~~~~~~~~~ + +RocketPy can produce real-time interactive 3D animations of the simulated +flight using `PyVista `_, a scientific visualization +library built on top of VTK. Two complementary animation modes are provided: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Method + - What it shows + * - ``flight.plots.animate_trajectory()`` + - The rocket 3D model moves through space following the simulated + trajectory. The scene includes the simulated and flown paths, live + telemetry, velocity and wind vectors, and flight-event markers. + * - ``flight.plots.animate_rotate()`` + - The rocket 3D model stays centred in the scene; only its attitude + is animated. A reference sphere, inertial horizon, body axes and live + attitude/rate telemetry make the quaternion solution easier to inspect. + Inertial velocity and wind directions are shown alongside the body axes. + +.. note:: + + The animation normally opens in an interactive VTK window. A local desktop + Python or Jupyter environment provides the best experience. A compatible + PyVista Jupyter backend is required for inline notebook rendering. + +**Installation** + +The ``pyvista`` dependency is not installed by default. Add the optional extra +before calling either animation method: + +.. code-block:: bash + + pip install rocketpy[animation] + +If ``pyvista`` is not available when an animation method is called, RocketPy +raises an :class:`ImportError` with the above install command embedded in the +message. + +**animate_trajectory — full 6-DOF trajectory animation** + +.. code-block:: python + + # Quickstart: uses RocketPy's built-in default STL rocket model + flight.plots.animate_trajectory() + + # Customise the time window and frame rate + flight.plots.animate_trajectory( + start=0.0, # start time in seconds (default: 0) + stop=flight.t_final, # end time in seconds (default: t_final) + time_step=0.05, # seconds per frame (default: 0.1) + playback_speed=2.0, # twice real-time playback (default: 1) + background_color="#A8CBE0", # optional launch-background override + color_by="dynamic_pressure", # speed, Mach, q, acceleration or altitude + show_kinematic_plots=True, # altitude, speed and acceleration + camera_mode="follow", # static, follow, ground or body + trajectory_line_width=8, # flown-path width (default: 4) + show_subrocket_point=True, # ground projection (default: True) + ) + + # Provide your own 3D model (any STL file) + flight.plots.animate_trajectory( + file_name="my_rocket.stl", + start=0.0, + stop=flight.t_final, + time_step=0.05, + ) + +**animate_rotate — attitude-only animation** + +Useful for inspecting roll, pitch, and yaw behaviour without the distraction of +the trajectory translation. The rocket mesh remains centred and rotates in an +East-North-Up inertial reference scene according to the quaternion solution. + +.. code-block:: python + + flight.plots.animate_rotate( + start=0.0, + stop=flight.t_final, + time_step=0.05, + show_attitude_plots=True, + show_cp_cm=True, + ) + +Deterministic export uses a fixed simulation-time grid derived from the output +frame rate and ``playback_speed``: + +.. code-block:: python + + flight.plots.animate_trajectory( + export_file="flight.mp4", + export_fps=30, + export_resolution=(1920, 1080), + camera_mode="follow", + ) + +**Parameters** + +Both methods share the same signature: + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Parameter + - Default + - Description + * - ``file_name`` + - ``None`` + - Path to a ``.stl`` model file. When ``None``, the built-in default + rocket shape packaged with RocketPy is used. + * - ``start`` + - ``0`` + - Animation start time in seconds. Must be within + ``[0, flight.t_final]``. + * - ``stop`` + - ``None`` + - Animation end time in seconds. ``None`` defaults to + ``flight.t_final``. + * - ``time_step`` + - ``0.1`` + - Simulation-time interval between frames, in seconds. Smaller values produce + smoother interactive playback at the cost of more rendering work. Must + be > 0. + * - ``playback_speed`` + - ``1.0`` + - Ratio of simulation time to wall-clock time. For example, ``2`` plays at + twice real time. Must be > 0. The animation control strip always provides + ``0.5x``, ``1x``, ``2x`` and ``3x`` choices. + * - ``**kwargs`` + - — + - RocketPy visualization options listed below, plus arguments forwarded to + :class:`pyvista.Plotter`, such as ``window_size``, ``notebook`` or + ``off_screen``. + +The following RocketPy-specific options are supplied through ``**kwargs`` and +are removed before the remaining arguments are forwarded to PyVista: + +.. list-table:: + :header-rows: 1 + :widths: 28 15 57 + + * - Keyword + - Default + - Description + * - ``background_color`` + - ``None`` + - Color-like launch-background override, such as ``"#A8CBE0"``, + ``"lightblue"`` or an RGB tuple. ``None`` selects daylight or night + colors from the environment's local launch time. + * - ``playback_controls`` + - ``True`` + - Show the play/pause button, draggable flight-time bar and playback-speed + selector. + * - ``show_subrocket_point`` + - ``True`` + - In the trajectory view, show the rocket's vertical projection onto the + ground plane. + * - ``ground_image`` + - ``None`` + - Path, :class:`pyvista.Texture`, or mapping with ``image``, ``bounds``, + ``coordinates`` and optional ``flip_y`` entries. Explicit bounds place + the image geographically instead of fitting it to the trajectory. + * - ``ground_image_bounds`` + - ``None`` + - Image bounds ``(west, east, south, north)`` in ENU metres or longitude + and latitude, according to ``ground_image_coordinates``. + * - ``ground_image_coordinates`` + - ``"enu"`` + - Ground-bound coordinate system: ``"enu"`` or ``"latlon"``. Latitude + and longitude bounds use a local equirectangular conversion around the + environment launch coordinates. + * - ``ground_image_flip_y`` + - ``False`` + - Flip the texture vertically when required by the source image's row + orientation. + * - ``color_by`` + - ``"speed"`` + - Color the trajectory by ``"speed"``, ``"mach"``, + ``"dynamic_pressure"``, ``"acceleration"`` or ``"altitude"``. Use + ``False`` or ``None`` for fixed path colors. + * - ``show_kinematic_plots`` + - ``False`` + - Show synchronized altitude AGL, speed and acceleration histories in SI + units in either animation. + * - ``camera_mode`` + - ``"static"`` + - Camera preset: ``"static"``, ``"follow"``, ``"ground"`` or ``"body"``. + ``True`` selects ``"follow"`` and ``False`` selects ``"static"``. + * - ``camera_path`` + - ``None`` + - Callable receiving flight time and returning a PyVista camera position, + or two or more camera positions interpolated uniformly over the selected + time interval. Overrides ``camera_mode``. + * - ``show_attitude_plots`` + - ``False`` + - In ``animate_rotate``, show synchronized angle-of-attack and sideslip, + 3-1-3 Euler-angle, and body-angular-rate histories. + * - ``show_cp_cm`` + - ``False`` + - In ``animate_rotate``, show dynamic center-of-mass and center-of-pressure + markers plus their physical stations and static margin in telemetry. + * - ``export_file`` + - ``None`` + - Deterministically export to a ``.gif`` or ``.mp4`` instead of opening an + interactive animation. + * - ``export_fps`` + - ``30`` + - Export frame rate. Simulation time advances by ``playback_speed / fps`` + between output frames. + * - ``export_resolution`` + - ``None`` + - Output ``(width, height)`` in pixels. The normal window size is used when + omitted. + * - ``transparent_background`` + - ``False`` + - Request an alpha background for GIF export. MP4 does not support this + option. + * - ``color_scheme`` + - ``None`` + - Mapping of keys from ``_animation_color_scheme()`` to replacement colors + or colormap names. Overrides are merged into the default scheme. + * - ``backend`` + - ``"auto"`` + - Visualization backend: ``"auto"``, ``"none"``, ``"trame"`` or + ``"client"``. + * - ``force_external`` + - ``False`` + - Force an external rendering window. This sets the plotter's ``notebook`` + and ``off_screen`` options to ``False`` and overrides ``backend`` with + ``"none"``. + * - ``shadows`` + - ``False`` + - Enable scene shadows. Colored paths, event points and direction vectors + remain unlit so their colors do not darken as the camera moves. + * - ``trajectory_line_width`` + - ``7`` + - Width of the flown trajectory line. The simulated-path width scales + proportionally from this value. + +**Tips** + +- A ``time_step`` of ``0.05`` (20 fps) is a good balance between smoothness + and performance for flights lasting tens of seconds. +- Use the mouse to orbit, pan and zoom; press **q** or close the PyVista window + to exit. +- Use **PLAY / PAUSE** to stop or resume the animation, drag **FLIGHT TIME** to + inspect any simulated instant, and use the speed selector to change playback + between ``0.5x``, ``1x``, ``2x`` and ``3x``. Replaying after the end restarts + at ``start``. +- The trajectory view display-scales the rocket so it remains visible while all + coordinates, paths and telemetry retain their physical SI values. +- Scalar trajectory coloring uses one fixed color range over the selected time + interval, so colors remain comparable while scrubbing and exporting. +- Fixed-size markers identify the selected interval's start and end, motor + burnout, apogee, and each parachute trigger and fully-open time when present. +- The cyan ground-projection point tracks the rocket's horizontal position; + set ``show_subrocket_point=False`` to hide it. +- Wind arrows point toward the direction in which the air mass is moving. +- With no background override, launch times from 06:00 through 19:59 local time + use a light sky palette and other launch times use a night palette. If the + environment has no launch date, RocketPy assumes daylight. The background + blends linearly toward near-space navy between launch altitude and 50 km AGL; + this is a visual cue rather than an atmospheric or solar model. +- Telemetry and legends use compact bordered annotation boxes so their values + remain readable over both daylight and night backgrounds. +- Center-of-mass and center-of-pressure markers preserve their physical axial + ordering and separation but are mapped onto the display model, whose geometry + may not match the simulated rocket exactly. +- Both methods validate ``start``, ``stop``, ``time_step``, and the STL path + before any rendering begins, raising a :class:`ValueError` with a descriptive + message on invalid input. + Forces and Moments ~~~~~~~~~~~~~~~~~~ diff --git a/docs/user/index.rst b/docs/user/index.rst index 5afaee3a6..6f9d9effa 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -24,7 +24,10 @@ RocketPy's User Guide :caption: Special Case Simulations Compare Flights Class + Flight Comparator Class + Parachute Triggers (Acceleration-Based) Deployable Payload + Controllers Air Brakes Example ../notebooks/sensors.ipynb ../matlab/matlab.rst @@ -46,4 +49,6 @@ RocketPy's User Guide :caption: Further Analysis Function - Utilities \ No newline at end of file + Utilities + Aerodynamic Surfaces + Logging diff --git a/docs/user/installation.rst b/docs/user/installation.rst index 72ba1f42d..4325b390f 100644 --- a/docs/user/installation.rst +++ b/docs/user/installation.rst @@ -19,7 +19,7 @@ If you want to choose a specific version to guarantee compatibility, you may ins .. code-block:: shell - pip install rocketpy==1.11.0 + pip install rocketpy==1.13.0 Optional Installation Method: ``conda`` @@ -138,22 +138,41 @@ To update Scipy and install netCDF4 using Conda, the following code is used: Optional Packages ^^^^^^^^^^^^^^^^^ -The EnvironmentAnalysis class requires a few extra packages to be installed. -In case you want to use this class, you will need to install the following packages: +RocketPy has several optional feature sets that can be installed individually. -- `timezonefinder` : to allow for automatic timezone detection, -- `windrose` : to allow for windrose plots, -- `ipywidgets` : to allow for GIFs generation, -- `jsonpickle` : to allow for saving and loading of class instances. +**Environment Analysis** — extra plots and tools for the +:class:`rocketpy.EnvironmentAnalysis` class: -You can install all these packages by simply running the following lines in your preferred terminal: +- `timezonefinder` : automatic timezone detection +- `windrose` : windrose plots +- `ipywidgets` : GIF generation +- `jsonpickle` : saving and loading class instances .. code-block:: shell pip install rocketpy[env_analysis] +**3D Flight Animation** — interactive 3D animations of rocket trajectory and +attitude using `PyVista `_ (a desktop environment is +recommended): -Alternatively, you can instal all extra packages by running the following line in your preferred terminal: +.. code-block:: shell + + pip install rocketpy[animation] + +Once installed, you can render animations from a :class:`rocketpy.Flight` object: + +.. code-block:: python + + # Animate rocket moving through 3D space + flight.plots.animate_trajectory(start=0, stop=flight.t_final, time_step=0.05) + + # Animate attitude changes only (rocket stays centred) + flight.plots.animate_rotate(start=0, stop=flight.t_final, time_step=0.05) + +See :ref:`flightusage` for full details and parameter descriptions. + +**All extras** — install every optional dependency at once: .. code-block:: shell diff --git a/docs/user/logging.rst b/docs/user/logging.rst new file mode 100644 index 000000000..469175bde --- /dev/null +++ b/docs/user/logging.rst @@ -0,0 +1,75 @@ +Logging +======= + +RocketPy uses Python's built-in `logging `_ +module to report internal runtime events, such as simulation progress, +warnings and errors. By design, RocketPy is **silent by default**: unless +you configure a handler, no log records are printed to the console. This +follows the best practices recommended for Python libraries and keeps +RocketPy from cluttering the output of applications that embed it. + +.. note:: + This only affects internal runtime events. Output that you explicitly + request, such as ``flight.info()`` or ``rocket.all_info()``, continues + to print directly to the terminal regardless of the logging + configuration described here. + +Enabling logging +----------------- + +The easiest way to see RocketPy's internal log messages is the +:func:`rocketpy.utilities.enable_logging` helper, which attaches a console +handler to RocketPy's logger hierarchy: + +.. jupyter-execute:: + + import rocketpy + + rocketpy.utilities.enable_logging(level="INFO") + +Once enabled, operations such as saving a file or completing a simulation +will emit messages to the console, for example: + +.. code-block:: text + + INFO | rocketpy.simulation.flight | Simulation completed successfully. + +Log levels +---------- + +The ``level`` parameter controls the minimum severity of messages that are +shown: + +- ``DEBUG``: high-frequency, low-level detail (e.g. solver iterations), + useful when troubleshooting a simulation. +- ``INFO``: confirmations of completed operations (e.g. simulation + completed, file saved). +- ``WARNING`` (default): unexpected but recoverable situations (e.g. a + missing motor, an automatically corrected geometry parameter). +- ``ERROR``: a feature is broken or an optional dependency is missing. + +.. jupyter-execute:: + + # Show every internal runtime message, including solver ticks + rocketpy.utilities.enable_logging(level="DEBUG") + +Filtering by module +-------------------- + +Because each RocketPy module exposes its own logger (e.g. +``rocketpy.simulation.flight``, ``rocketpy.environment.environment``), you +can rely on the standard ``logging`` module to filter or redirect messages +from specific modules, without using :func:`rocketpy.utilities.enable_logging` +at all: + +.. code-block:: python + + import logging + + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(levelname)s | %(name)s | %(message)s")) + + # Only show logs coming from the Flight simulation module + flight_logger = logging.getLogger("rocketpy.simulation.flight") + flight_logger.addHandler(handler) + flight_logger.setLevel(logging.INFO) diff --git a/docs/user/motors/motors.rst b/docs/user/motors/motors.rst index b690cf9e8..2058ed677 100644 --- a/docs/user/motors/motors.rst +++ b/docs/user/motors/motors.rst @@ -33,6 +33,12 @@ Motors Usage Generic Motor Usage +.. toctree:: + :maxdepth: 3 + :caption: Ring Cluster Motors + + Ring Cluster Motor Usage + .. toctree:: :maxdepth: 3 :caption: Tanks and Fluid diff --git a/docs/user/motors/ringclustermotor.rst b/docs/user/motors/ringclustermotor.rst new file mode 100644 index 000000000..07ec0842a --- /dev/null +++ b/docs/user/motors/ringclustermotor.rst @@ -0,0 +1,93 @@ +.. _ringclustermotor: + +RingClusterMotor Class Usage +============================ + +Here we explore different features of the ``RingClusterMotor`` class. + +``RingClusterMotor`` models a ring (annular) configuration of ``N`` identical +motors arranged symmetrically around a circular perimeter of a given radius, +with no central motor along the rocket's longitudinal axis. It is a thin +wrapper around a single, already-defined motor: all thrust-, mass- and +inertia-related quantities are derived automatically from that base motor, +its ``number`` of copies and their ``radius`` from the rocket's central axis. + +Key Assumptions +--------------- + +- ``number`` must be an integer ``>= 2``; +- ``radius`` must be non-negative; +- The base motor is currently expected to be a ``SolidMotor``, since + ``RingClusterMotor`` reuses its grain properties directly; +- Motors are assumed identical and evenly spaced around the ring + (``360 / number`` degrees apart); +- The transverse inertia (``I11``/``I22``) contribution of each motor is + computed explicitly via the parallel axis (Steiner) theorem for every + angular position, which keeps the result accurate even for the + asymmetric ``number=2`` case. + +Creating a Ring Cluster Motor +------------------------------ + +To define a ``RingClusterMotor``, we first need a fully defined base motor +(typically a ``SolidMotor``), then wrap it with the number of motors in the +cluster and their radial distance from the rocket's central axis: + +.. jupyter-execute:: + + from rocketpy import SolidMotor, RingClusterMotor + + base_motor = SolidMotor( + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + + cluster_motor = RingClusterMotor( + motor=base_motor, + number=4, + radius=0.1, + ) + + # Print the cluster (and underlying single-motor) information + cluster_motor.info() + +.. note:: + + ``RingClusterMotor`` does not read a thrust curve file directly. Instead, + it scales the ``thrust``, ``dry_mass``, ``propellant_mass`` and inertia + properties of the ``motor`` passed in by ``number``, so any changes to + ``base_motor`` must be made before it is wrapped. + +Since a ``RingClusterMotor`` is a ``Motor`` like any other, it can be attached +to a ``Rocket`` with :meth:`Rocket.add_motor` exactly like a ``SolidMotor``, +``HybridMotor`` or ``LiquidMotor`` would be. + +Visualizing the Cluster Layout +------------------------------- + +The ``draw_cluster_layout`` method plots the position of each motor around +the ring, which is useful to double-check the ``number`` and ``radius`` +parameters before running a simulation: + +.. jupyter-execute:: + + cluster_motor.draw_cluster_layout(rocket_radius=0.15) + +.. tip:: + + Passing ``rocket_radius`` draws the rocket's outer tube as a dashed + circle, which helps confirm that the motors fit inside the airframe. diff --git a/docs/user/motors/tanks.rst b/docs/user/motors/tanks.rst index 2c63cabaf..fe7da2fcb 100644 --- a/docs/user/motors/tanks.rst +++ b/docs/user/motors/tanks.rst @@ -104,7 +104,13 @@ The predefined ``CylindricalTank`` class is easy to use and is defined as such: .. jupyter-execute:: - cylindrical_geometry = CylindricalTank(radius=0.1, height=2.0, spherical_caps=False) + cylindrical_geometry = CylindricalTank(radius_function=0.1, height=2.0, spherical_caps=False) + +.. deprecated:: 1.13.0 + The ``radius`` keyword argument of ``CylindricalTank`` and ``SphericalTank`` + has been renamed to ``radius_function``. Passing ``radius=`` still works but + now raises a ``DeprecationWarning`` and will be removed in v2.0.0. Use + ``radius_function=`` instead (it accepts the same constant radius value). .. note:: The ``spherical_caps`` parameter is optional and defaults to ``False``. If set @@ -116,7 +122,7 @@ The predefined ``SphericalTank`` is defined with: .. jupyter-execute:: - spherical_geometry = SphericalTank(radius=0.1) + spherical_geometry = SphericalTank(radius_function=0.1) .. seealso:: :class:`rocketpy.CylindricalTank` and :class:`rocketpy.SphericalTank` diff --git a/docs/user/parachute_triggers.rst b/docs/user/parachute_triggers.rst new file mode 100644 index 000000000..2799ea00d --- /dev/null +++ b/docs/user/parachute_triggers.rst @@ -0,0 +1,279 @@ +Acceleration-Based Parachute Triggers +====================================== + +RocketPy lets parachute trigger functions access the state derivative +``u_dot`` (which holds the accelerations at indices ``[3:6]``) in addition to +pressure, height and the state vector. This enables avionics-style deployment +logic that mimics how real flight computers use accelerometer (IMU) data to +detect flight phases such as burnout, free-fall or liftoff. + +Overview +-------- + +Built-in string and numeric triggers rely on altitude and vertical velocity. +By writing a **custom trigger function**, you can additionally use acceleration +to implement mission-specific logic, for example: + +- Motor burnout detection (sudden drop in acceleration) +- Apogee detection combining near-zero velocity with downward acceleration +- Free-fall / ballistic coast detection (low total acceleration) +- Liftoff detection (high total acceleration) + +For realistic noisy measurements, attach an :doc:`Accelerometer sensor +` to the rocket and read it inside the +trigger instead of feeding the ideal ``u_dot`` directly. + +Trigger function signatures +--------------------------- + +A custom trigger callable may take **3, 4, or 5** arguments. RocketPy detects +the signature automatically and only computes ``u_dot`` when a trigger asks for +it (so legacy triggers pay no performance cost): + +- ``(pressure, height, state_vector)`` — the classic signature. +- ``(pressure, height, state_vector, u_dot)`` — name the 4th argument + ``u_dot`` (or ``udot``/``acc``/``acceleration``) to receive the derivative; + any other name receives the ``sensors`` list instead. +- ``(pressure, height, state_vector, sensors, u_dot)`` — receive both. + +``state_vector`` is ``[x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3]`` and +``u_dot`` is ``[vx, vy, vz, ax, ay, az, ...]``. + +Built-in apogee trigger +----------------------- + +Deploys when the rocket starts descending (vertical velocity becomes negative): + +.. code-block:: python + + rocket.add_parachute( + name="Main", + cd_s=10.0, + trigger="apogee", + sampling_rate=100, + lag=0.5, + ) + +Numeric altitude trigger +------------------------ + +Pass a number to deploy at a fixed height above ground level while descending: + +.. code-block:: python + + rocket.add_parachute( + name="Main", + cd_s=10.0, + trigger=400, # meters above ground level + sampling_rate=100, + lag=0.5, + ) + +Custom trigger: motor burnout +----------------------------- + +Burnout is highly mission-dependent, so it is best expressed as a custom +trigger with user-defined thresholds. + +Logic: detect a drop in vertical or total acceleration once the rocket is above +a minimum height and still ascending. + +.. code-block:: python + + def burnout_trigger_factory( + min_height=5.0, + min_vz=0.5, + az_threshold=-8.0, + total_acc_threshold=2.0, + ): + def burnout_trigger(_pressure, height, state_vector, u_dot): + ax, ay, az = u_dot[3], u_dot[4], u_dot[5] + total_acc = (ax**2 + ay**2 + az**2) ** 0.5 + vz = state_vector[5] + if height < min_height or vz <= min_vz: + return False + return az < az_threshold or total_acc < total_acc_threshold + + return burnout_trigger + +Attach it to a rocket: + +.. code-block:: python + + rocket.add_parachute( + name="Drogue", + cd_s=1.0, + trigger=burnout_trigger_factory( + min_height=10.0, + min_vz=2.0, + az_threshold=-10.0, + total_acc_threshold=3.0, + ), + sampling_rate=100, + lag=1.5, + ) + +Custom trigger: apogee by acceleration +-------------------------------------- + +Logic: near-zero vertical velocity together with downward acceleration. + +.. code-block:: python + + def apogee_acc_trigger(_pressure, _height, state_vector, u_dot): + vz = state_vector[5] + az = u_dot[5] + return abs(vz) < 1.0 and az < -0.1 + +.. code-block:: python + + rocket.add_parachute( + name="Main", + cd_s=10.0, + trigger=apogee_acc_trigger, + sampling_rate=100, + lag=0.5, + ) + +Custom trigger: free-fall +------------------------- + +Logic: low total acceleration while descending above a small height. + +.. code-block:: python + + def freefall_trigger(_pressure, height, state_vector, u_dot): + ax, ay, az = u_dot[3], u_dot[4], u_dot[5] + total_acc = (ax**2 + ay**2 + az**2) ** 0.5 + vz = state_vector[5] + return height > 5.0 and vz < -0.2 and total_acc < 11.5 + +.. code-block:: python + + rocket.add_parachute( + name="Drogue", + cd_s=1.0, + trigger=freefall_trigger, + sampling_rate=100, + lag=1.5, + ) + +Custom trigger: liftoff +----------------------- + +Logic: detect motor ignition by high total acceleration. + +.. code-block:: python + + def liftoff_trigger(_pressure, _height, _state_vector, u_dot): + ax, ay, az = u_dot[3], u_dot[4], u_dot[5] + total_acc = (ax**2 + ay**2 + az**2) ** 0.5 + return total_acc > 15.0 + +.. code-block:: python + + rocket.add_parachute( + name="Lift", + cd_s=0.5, + trigger=liftoff_trigger, + sampling_rate=100, + lag=0.1, + ) + +Custom trigger: using sensor measurements +----------------------------------------- + +A 5-argument trigger receives both the ``sensors`` list and ``u_dot``, so you +can cross-check a noisy accelerometer reading against the ideal derivative. + +.. code-block:: python + + def advanced_trigger(_pressure, _height, _state_vector, sensors, u_dot): + if not sensors: + return False + acc_reading = sensors[0].measurement + if acc_reading is None or len(acc_reading) < 3: + return False + meas_az = acc_reading[2] + az = u_dot[5] + return az < -5.0 and meas_az < -5.0 + +.. code-block:: python + + rocket.add_parachute( + name="Advanced", + cd_s=1.5, + trigger=advanced_trigger, + sampling_rate=100, + ) + +.. note:: + + For realistic IMU behavior, attach a RocketPy sensor with its own noise + model and read it inside the trigger via ``sensors``, instead of relying on + the ideal ``u_dot``. See the :doc:`Sensor Classes + ` for available sensors. + +Full example: dual deployment +----------------------------- + +In RocketPy only one parachute is active at a time, so a dual-deploy avionics +can be reproduced with two custom triggers — a drogue at burnout and a main at +a lower altitude: + +.. code-block:: python + + from rocketpy import Rocket, Flight, Environment + + # Environment and rocket setup + env = Environment(latitude=32.99, longitude=-106.97, elevation=1400) + env.set_atmospheric_model(type="standard_atmosphere") + + rocket = Rocket(...) # configure your rocket (motor, fins, etc.) + + # Drogue: deploy shortly after burnout (acceleration drop while ascending) + def drogue_burnout_trigger(_pressure, height, state_vector, u_dot): + az = u_dot[5] + vz = state_vector[5] + return height > 10 and vz > 1 and az < -8.0 + + rocket.add_parachute( + name="Drogue", + cd_s=1.0, + trigger=drogue_burnout_trigger, + sampling_rate=100, + lag=1.5, + noise=(0, 8.3, 0.5), # pressure-signal noise + ) + + # Main: deploy below 800 m while descending + def main_deploy_trigger(_pressure, height, state_vector, u_dot): + vz = state_vector[5] + az = u_dot[5] + return height < 800 and vz < -5 and az > -15 + + rocket.add_parachute( + name="Main", + cd_s=10.0, + trigger=main_deploy_trigger, + sampling_rate=100, + lag=0.5, + noise=(0, 8.3, 0.5), + ) + + # Flight simulation + flight = Flight( + rocket=rocket, + environment=env, + rail_length=5.2, + inclination=85, + heading=0, + ) + flight.all_info() + +See Also +-------- + +- :doc:`Parachute Class Reference ` +- :doc:`Flight Simulation ` +- :doc:`Sensors ` diff --git a/docs/user/rocket/rocket_axes.rst b/docs/user/rocket/rocket_axes.rst index 56fe17cad..aa99cb231 100644 --- a/docs/user/rocket/rocket_axes.rst +++ b/docs/user/rocket/rocket_axes.rst @@ -61,6 +61,8 @@ The following figure shows the two possibilities for the user input coordinate s the same as the **Body Axes Coordinate System**. The origin of the coordinate \ system may still be different. +.. _angular_position: + Angular Position Inputs ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/user/rocket/rocket_usage.rst b/docs/user/rocket/rocket_usage.rst index 3496da6fd..287a04b6f 100644 --- a/docs/user/rocket/rocket_usage.rst +++ b/docs/user/rocket/rocket_usage.rst @@ -431,6 +431,18 @@ First, lets guarantee that the rocket is stable, by plotting the static margin: If it is unreasonably **high**, your rocket is **super stable** and the simulation will most likely **fail**. +.. note:: + + RocketPy helps you catch this automatically: if the static margin is + **negative at motor ignition**, an + :class:`~rocketpy.exceptions.UnstableRocketWarning` is issued when the + rocket is used in a simulation. The check is skipped when the rocket has + any ``GenericSurface`` aerodynamic surfaces, since their lift coefficient + derivative is not accounted for in the center of pressure calculation, + which makes the static margin unreliable in that case. See + :doc:`/reference/classes/exceptions` for the full list of RocketPy + exceptions and warnings. + The lets check all the information available about the rocket: .. jupyter-execute:: diff --git a/pyproject.toml b/pyproject.toml index 789fc0858..c5b7f3d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "activerocketpy" -version = "1.12.0" +version = "1.13.0" description="Advanced 6-DOF Rocket GNC simulation" dynamic = ["dependencies"] readme = "README.md" @@ -33,6 +33,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = { find = { where = ["."], include = ["rocketpy*"] } } +[tool.setuptools.package-data] +"rocketpy.plots" = ["assets/*.stl"] + [tool.setuptools.dynamic] dependencies = { file = ["requirements.txt"] } @@ -60,13 +63,18 @@ env-analysis = [ ] monte-carlo = [ - "imageio", + "imageio", "multiprocess>=0.70", "statsmodels", "prettytable", "contextily>=1.0.0; python_version < '3.14'", ] +animation = [ + "pyvista>=0.45", + "imageio-ffmpeg>=0.5" +] + all = ["activerocketpy[env-analysis]", "activerocketpy[monte-carlo]"] diff --git a/requirements-optional.txt b/requirements-optional.txt index 58ed1030b..2961946ca 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -6,4 +6,5 @@ timezonefinder imageio multiprocess>=0.70 statsmodels -prettytable \ No newline at end of file +prettytable +vedo>=2024.5.1 \ No newline at end of file diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index 852a16aef..d8720db4c 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -1,5 +1,11 @@ +from . import utilities from .control import _Controller from .environment import Environment, EnvironmentAnalysis +from .exceptions import ( + InvalidInertiaError, + InvalidParameterError, + UnstableRocketWarning, +) from .mathutils import ( Function, PiecewiseFunction, @@ -18,6 +24,7 @@ MassFlowRateBasedTank, Motor, PointMassMotor, + RingClusterMotor, SolidMotor, SphericalTank, Tank, @@ -29,8 +36,11 @@ AeroSurface, AirBrakes, Components, + EllipticalFin, EllipticalFins, + Fin, Fins, + FreeFormFin, FreeFormFins, GenericSurface, LinearGenericSurface, @@ -40,6 +50,7 @@ RailButtons, Rocket, Tail, + TrapezoidalFin, TrapezoidalFins, ) from .sensitivity import SensitivityModel diff --git a/rocketpy/_encoders.py b/rocketpy/_encoders.py index 58eeae809..b6730f65c 100644 --- a/rocketpy/_encoders.py +++ b/rocketpy/_encoders.py @@ -1,6 +1,7 @@ """Defines a custom JSON encoder for RocketPy objects.""" import json +import warnings from datetime import datetime from importlib import import_module @@ -133,7 +134,13 @@ def object_hook(self, obj): if hash_ is not None: setattr(new_obj, "__rpy_hash", hash_) return new_obj - except (ImportError, AttributeError): + except (ImportError, AttributeError) as exc: + warnings.warn( + "Could not reconstruct a RocketPy object from the stored " + f"signature {signature!r}: {exc}. Returning the raw data " + "dictionary instead; the loaded object may be incomplete.", + stacklevel=2, + ) return obj else: return obj diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index e81e70915..266389fa3 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -17,7 +17,7 @@ def __init__( self, interactive_objects, controller_function, - sampling_rate, + sampling_rate=None, initial_observed_variables=None, name="Controller", ): @@ -38,16 +38,19 @@ def __init__( This function is expected to take the following arguments, in order: 1. `time` (float): The current simulation time in seconds. - 2. `sampling_rate` (float): The rate at which the controller - function is called, measured in Hertz (Hz). + 2. `sampling_rate` (float or None): The rate at which the controller + function is called, measured in Hertz (Hz). It is None for + continuous controllers (called every solver step), so any + `1 / sampling_rate` computation must guard against None. 3. `state` (list): The state vector of the simulation, structured as `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`. 4. `state_history` (list): A record of the rocket's state at each - step throughout the simulation. The state_history is organized as - a list of lists, with each sublist containing a state vector. The - last item in the list always corresponds to the previous state - vector, providing a chronological sequence of the rocket's - evolving states. + step throughout the simulation. It is organized as a list of + lists, ordered oldest to newest, where each sublist is a + *time-prefixed* state row `[t, x, y, z, vx, vy, vz, e0, e1, e2, + e3, wx, wy, wz]` (i.e. the same layout as `Flight.solution`, one + leading `time` element ahead of the `state` layout in item 3). + The last item corresponds to the most recent recorded step. 5. `observed_variables` (list): A list containing the variables that the controller function returns. The return of each controller function call is appended to the observed_variables list. The @@ -71,12 +74,14 @@ def __init__( objects as needed. The function return statement can be used to save relevant information in the `observed_variables` list. - .. note:: The function will be called according to the sampling rate - specified. - sampling_rate : float + .. note:: The function will be called according to the sampling + rate specified. If `sampling_rate` is None, the controller + function is called at every solver step of the simulation. + sampling_rate : float, optional The sampling rate of the controller function in Hertz (Hz). This means that the controller function will be called every - `1/sampling_rate` seconds. + `1/sampling_rate` seconds. If None, it is treated as a + continuous controller and called at every solver step. initial_observed_variables : list, optional A list of the initial values of the variables that the controller function returns. This list is used to initialize the @@ -178,10 +183,12 @@ def __call__(self, time, state_vector, state_history, sensors, environment): `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`. state_history : list - A list containing the state history of the simulation. The state - history is a list of every state vector of every step of the - simulation. The state history is a list of lists, where each - sublist is a state vector and is ordered from oldest to newest. + A list containing the state history of the simulation, ordered + oldest to newest. Each sublist is a *time-prefixed* state row + `[t, x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]` (the same + layout as `Flight.solution`, with a leading `time` element ahead of + the `state_vector` layout). The last item is the most recent + recorded step. sensors : list A list of sensors that are attached to the rocket. The most recent measurements of the sensors are provided with the @@ -208,7 +215,15 @@ def __call__(self, time, state_vector, state_history, sensors, environment): if observed_variables is not None: self.observed_variables.append(observed_variables) + @property + def is_continuous(self): + """bool: True if the controller runs at every solver step (i.e. + ``sampling_rate`` is None), False if it is sampled at a fixed rate.""" + return self.sampling_rate is None + def __str__(self): + if self.is_continuous: + return f"Controller '{self.name}' with continuous sampling." return f"Controller '{self.name}' with sampling rate {self.sampling_rate} Hz." def info(self): diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 6743b06ae..3f253427d 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -1,6 +1,7 @@ # pylint: disable=too-many-public-methods, too-many-instance-attributes import bisect import json +import logging import re import warnings from collections import namedtuple @@ -11,10 +12,12 @@ import pytz from rocketpy.environment.fetchers import ( + fetch_aigfs_file_return_dataset, fetch_atmospheric_data_from_windy, fetch_gefs_ensemble, fetch_gfs_file_return_dataset, fetch_hiresw_file_return_dataset, + fetch_hrrr_file_return_dataset, fetch_nam_file_return_dataset, fetch_open_elevation, fetch_rap_file_return_dataset, @@ -27,6 +30,7 @@ find_latitude_index, find_longitude_index, find_time_index, + geodesic_to_lambert_conformal, geodesic_to_utm, get_elevation_data_from_dataset, get_final_date_from_time_array, @@ -44,6 +48,8 @@ geopotential_height_to_geometric_height, ) +logger = logging.getLogger(__name__) + class Environment: """Keeps all environment information stored, such as wind and temperature @@ -138,15 +144,15 @@ class Environment: Environment.atmospheric_model_type : string Describes the atmospheric model which is being used. Can only assume the following values: ``standard_atmosphere``, ``custom_atmosphere``, - ``wyoming_sounding``, ``Forecast``, ``Reanalysis``, - ``Ensemble``. + ``wyoming_sounding``, ``windy``, ``forecast``, ``reanalysis``, + ``ensemble``. Environment.atmospheric_model_file : string Address of the file used for the atmospheric model being used. Only - defined for ``wyoming_sounding``, ``Forecast``, - ``Reanalysis``, ``Ensemble`` + defined for ``wyoming_sounding``, ``windy``, ``forecast``, + ``reanalysis``, ``ensemble`` Environment.atmospheric_model_dict : dictionary Dictionary used to properly interpret ``netCDF`` and ``OPeNDAP`` files. - Only defined for ``Forecast``, ``Reanalysis``, ``Ensemble``. + Only defined for ``forecast``, ``reanalysis``, ``ensemble``. Environment.atmospheric_model_init_date : datetime Datetime object instance of first available date in ``netCDF`` and ``OPeNDAP`` files when using ``Forecast``, ``Reanalysis`` or @@ -177,14 +183,14 @@ class Environment: ``Ensemble``. Environment.lat_array : array Defined if ``netCDF`` or ``OPeNDAP`` file is used, for Forecasts, - Reanalysis and Ensembles. 2x2 matrix for each pressure level of - latitudes corresponding to the vertices of the grid cell which - surrounds the launch site. + Reanalysis and Ensembles. Two-element list ``[x1, x2]`` containing + the latitude coordinates of the grid-cell vertices that bracket the + launch site and are used in bilinear interpolation. Environment.lon_array : array Defined if ``netCDF`` or ``OPeNDAP`` file is used, for Forecasts, - Reanalysis and Ensembles. 2x2 matrix for each pressure level of - longitudes corresponding to the vertices of the grid cell which - surrounds the launch site. + Reanalysis and Ensembles. Two-element list ``[y1, y2]`` containing + the longitude coordinates of the grid-cell vertices that bracket the + launch site and are used in bilinear interpolation. Environment.lon_index : int Defined if ``netCDF`` or ``OPeNDAP`` file is used, for Forecasts, Reanalysis and Ensembles. Index to a grid longitude which @@ -222,7 +228,8 @@ class Environment: surrounds the launch site. Environment.time_array : array Defined if ``netCDF`` or ``OPeNDAP`` file is used, for Forecasts, - Reanalysis and Ensembles. Array of dates available in the file. + Reanalysis and Ensembles. Two-element list with the first and last + values from the dataset time variable in the dataset native units. Environment.height : array Defined if ``netCDF`` or ``OPeNDAP`` file is used, for Forecasts, Reanalysis and Ensembles. List of geometric height corresponding to @@ -295,21 +302,21 @@ def __init__( - :attr:`Environment.datetime_date`: UTC time of launch. - Must be given if a Forecast, Reanalysis - or Ensemble, will be set as an atmospheric model. + Must be given if a ``windy``, ``forecast``, ``reanalysis`` + or ``ensemble`` atmospheric model will be used. Default is None. See :meth:`Environment.set_date` for more information. latitude : float, optional Latitude in degrees (ranging from -90 to 90) of rocket - launch location. Must be given if a Forecast, Reanalysis - or Ensemble will be used as an atmospheric model or if + launch location. Must be given if a ``windy``, ``forecast``, + ``reanalysis`` or ``ensemble`` atmospheric model will be used or if Open-Elevation will be used to compute elevation. Positive values correspond to the North. Default value is 0, which corresponds to the equator. longitude : float, optional Longitude in degrees (ranging from -180 to 180) of rocket - launch location. Must be given if a Forecast, Reanalysis - or Ensemble will be used as an atmospheric model or if + launch location. Must be given if a ``windy``, ``forecast``, + ``reanalysis`` or ``ensemble`` atmospheric model will be used or if Open-Elevation will be used to compute elevation. Positive values correspond to the East. Default value is 0, which corresponds to the Greenwich Meridian. @@ -367,9 +374,11 @@ def __initialize_constants(self): self.__weather_model_map = WeatherModelMapping() self.__atm_type_file_to_function_map = { "forecast": { + "AIGFS": fetch_aigfs_file_return_dataset, "GFS": fetch_gfs_file_return_dataset, "NAM": fetch_nam_file_return_dataset, "RAP": fetch_rap_file_return_dataset, + "HRRR": fetch_hrrr_file_return_dataset, "HIRESW": fetch_hiresw_file_return_dataset, }, "ensemble": { @@ -489,7 +498,7 @@ def __set_barometric_height_function(self, source): interpolation="linear", extrapolation="natural", ) - if callable(self.barometric_height.source): + if not self.barometric_height.is_array_source(): # discretize to speed up flight simulation self.barometric_height.set_discrete( 0, @@ -531,26 +540,48 @@ def __set_wind_speed_function(self, source): interpolation="linear", ) + def __set_wind_angle_function(self, source, attribute, output): + """Set ``attribute`` (e.g. ``wind_direction``) as a Function of height. + For 2D-array sources the angles are unwrapped across the 360/0 boundary + before linear interpolation, avoiding spurious spikes near the wrap.""" + if isinstance(source, (np.ndarray, list, tuple)) and np.ndim(source) == 2: + array = np.asarray(source) + unwrapped_deg = np.rad2deg(np.unwrap(np.deg2rad(array[:, 1]))) + unwrapped = Function( + np.column_stack((array[:, 0], unwrapped_deg)), + inputs="Height Above Sea Level (m)", + outputs=output, + interpolation="linear", + ) + setattr(self, f"{attribute}_unwrapped", unwrapped) + source = Function( + lambda h: unwrapped(h) % 360, + inputs="Height Above Sea Level (m)", + outputs=output, + ) + else: + source = Function( + source, + inputs="Height Above Sea Level (m)", + outputs=output, + interpolation="linear", + ) + setattr(self, attribute, source) + def __set_wind_direction_function(self, source): - self.wind_direction = Function( - source, - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", + self.__set_wind_angle_function( + source, "wind_direction", "Wind Direction (Deg True)" ) def __set_wind_heading_function(self, source): - self.wind_heading = Function( - source, - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", + self.__set_wind_angle_function( + source, "wind_heading", "Wind Heading (Deg True)" ) def __reset_barometric_height_function(self): # NOTE: this assumes self.pressure and max_expected_height are already set. self.barometric_height = self.pressure.inverse_function() - if callable(self.barometric_height.source): + if not self.barometric_height.is_array_source(): # discretize to speed up flight simulation self.barometric_height.set_discrete( 0, @@ -605,13 +636,82 @@ def __set_earth_rotation_vector(self): # Validators (used to verify an attribute is being set correctly.) + @staticmethod + def __dictionary_matches_dataset(dictionary, dataset): + """Check whether a mapping dictionary is compatible with a dataset.""" + variables = dataset.variables + required_keys = ( + "time", + "latitude", + "longitude", + "level", + "temperature", + "u_wind", + "v_wind", + ) + + for key in required_keys: + variable_name = dictionary.get(key) + if variable_name is None or variable_name not in variables: + return False + + projection_name = dictionary.get("projection") + if projection_name is not None and projection_name not in variables: + return False + + geopotential_height_name = dictionary.get("geopotential_height") + geopotential_name = dictionary.get("geopotential") + has_geopotential_height = ( + geopotential_height_name is not None + and geopotential_height_name in variables + ) + has_geopotential = ( + geopotential_name is not None and geopotential_name in variables + ) + + return has_geopotential_height or has_geopotential + + def __resolve_dictionary_for_dataset(self, dictionary, dataset): + """Resolve a compatible mapping dictionary for the loaded dataset. + + If the provided mapping is incompatible with the dataset variables, + this method tries built-in mappings and falls back to the first + compatible one. + """ + if self.__dictionary_matches_dataset(dictionary, dataset): + return dictionary + + for model_name, candidate in self.__weather_model_map.all_dictionaries.items(): + if self.__dictionary_matches_dataset(candidate, dataset): + warnings.warn( + "Provided weather mapping does not match dataset variables. " + f"Falling back to built-in mapping '{model_name}'." + ) + return candidate + + return dictionary + def __validate_dictionary(self, file, dictionary): # removed CMC until it is fixed. - available_models = ["GFS", "NAM", "RAP", "HIRESW", "GEFS", "ERA5", "MERRA2"] + available_models = [ + "AIGFS", + "GFS", + "NAM", + "RAP", + "HRRR", + "HIRESW", + "GEFS", + "MERRA2", + ] if isinstance(dictionary, str): dictionary = self.__weather_model_map.get(dictionary) - elif file in available_models: - dictionary = self.__weather_model_map.get(file) + elif isinstance(file, str): + matching_model = next( + (model for model in available_models if model.lower() == file.lower()), + None, + ) + if matching_model is not None: + dictionary = self.__weather_model_map.get(matching_model) if not isinstance(dictionary, dict): raise TypeError( "Please specify a dictionary or choose a valid model from the " @@ -800,7 +900,7 @@ def set_gravity_model(self, gravity=None): >>> g_0 = 9.80665 >>> env_cte_g = Environment(gravity=g_0) >>> env_cte_g.gravity([0, 100, 1000]) - [np.float64(9.80665), np.float64(9.80665), np.float64(9.80665)] + array([9.80665, 9.80665, 9.80665]) It's also possible to variate the gravity acceleration by defining its function of height: @@ -901,7 +1001,7 @@ def set_elevation(self, elevation="Open-Elevation"): self.elevation = elevation else: self.elevation = fetch_open_elevation(self.latitude, self.longitude) - print(f"Elevation received: {self.elevation} m") + logger.info("Elevation received: %.2f m", self.elevation) def set_topographic_profile( # pylint: disable=redefined-builtin, unused-argument self, type, file, dictionary="netCDF4", crs=None @@ -940,14 +1040,13 @@ def set_topographic_profile( # pylint: disable=redefined-builtin, unused-argume # crsArray = nasa_dem.variables['crs'][:].tolist(). self.topographic_profile_activated = True - print("Region covered by the Topographical file: ") - print( - f"Latitude from {self.elev_lat_array[-1]:.6f}° to " - f"{self.elev_lat_array[0]:.6f}°" - ) - print( - f"Longitude from {self.elev_lon_array[0]:.6f}° to " - f"{self.elev_lon_array[-1]:.6f}°" + logger.debug( + "Topographical file coverage: lat [%.6f°, %.6f°], " + "lon [%.6f°, %.6f°]", + self.elev_lat_array[-1], + self.elev_lat_array[0], + self.elev_lon_array[0], + self.elev_lon_array[-1], ) def get_elevation_from_topographic_profile(self, lat, lon): @@ -1035,6 +1134,51 @@ def get_elevation_from_topographic_profile(self, lat, lon): return elevation + def __determine_pressure_conversion_factor( + self, pressure_conversion_factor, input_dict, input_file + ): + """Determine the numeric conversion factor (pressure -> Pa) based on + either the user's explicit input or auto-detection. + + Parameters + ---------- + pressure_conversion_factor : string, int, float or None + The user-supplied pressure conversion factor. + input_dict : string or None + The upper-case string name of the dictionary. + input_file : string or None + The upper-case string name of the file/model shortcut. + + Returns + ------- + conversion_factor : float, int or None + The numeric conversion factor to Pascal, or None if it needs to be + read from the file's units attribute. + """ + if pressure_conversion_factor is not None: + # User explicitly supplied a value — honour it. + if isinstance(pressure_conversion_factor, str): + return ( + 100 if pressure_conversion_factor.lower() in ("mbar", "hpa") else 1 + ) + return pressure_conversion_factor + + # Auto-detect. Primary source: known-model lookup table. + # Fallback: units attribute inside the file. + # THREDDS (UCAR) models expose pressure on the 'isobaric' coordinate in + # Pa; NOMADS-GrADS models (GEFS, HIRESW) expose it on the 'lev' + # coordinate in hPa/millibars and must be scaled by 100. + _hpa_dicts = {"ECMWF", "ECMWF_V0", "MERRA2"} + _hpa_files = {"GEFS", "HIRESW"} + _pa_files = {"GFS", "NAM", "RAP", "HRRR", "AIGFS"} + if input_dict in _hpa_dicts or input_file in _hpa_dicts: + return 100 + if input_file in _hpa_files: + return 100 + if input_file in _pa_files: + return 1 + return None + def set_atmospheric_model( # pylint: disable=too-many-statements self, type, # pylint: disable=redefined-builtin @@ -1044,188 +1188,57 @@ def set_atmospheric_model( # pylint: disable=too-many-statements temperature=None, wind_u=0, wind_v=0, + pressure_conversion_factor=None, ): - """Defines an atmospheric model for the Environment. Supported - functionality includes using data from the `International Standard - Atmosphere`, importing data from weather reanalysis, forecasts and - ensemble forecasts, importing data from upper air soundings and - inputting data as custom functions, arrays or csv files. + """Define the atmospheric model for this Environment. Parameters ---------- type : string - One of the following options: - - - ``standard_atmosphere``: sets pressure and temperature profiles - corresponding to the International Standard Atmosphere defined by - ISO 2533 and ranging from -2 km to 80 km of altitude above sea - level. Note that the wind profiles are set to zero when this type - is chosen. - - - ``wyoming_sounding``: sets pressure, temperature, wind-u - and wind-v profiles and surface elevation obtained from - an upper air sounding given by the file parameter through - an URL. This URL should point to a data webpage given by - selecting plot type as text: list, a station and a time at - `weather.uwyo`_. - An example of a valid link would be: - - http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0200&TO=0200&STNM=82599 - - .. _weather.uwyo: http://weather.uwyo.edu/upperair/sounding.html - - - ``windy_atmosphere``: sets pressure, temperature, wind-u and - wind-v profiles and surface elevation obtained from the Windy API. - See file argument to specify the model as either ``ECMWF``, - ``GFS`` or ``ICON``. - - - ``Forecast``: sets pressure, temperature, wind-u and wind-v - profiles and surface elevation obtained from a weather forecast - file in ``netCDF`` format or from an ``OPeNDAP`` URL, both given - through the file parameter. When this type is chosen, the date - and location of the launch should already have been set through - the date and location parameters when initializing the - Environment. The ``netCDF`` and ``OPeNDAP`` datasets must contain - at least geopotential height or geopotential, temperature, wind-u - and wind-v profiles as a function of pressure levels. If surface - geopotential or geopotential height is given, elevation is also - set. Otherwise, elevation is not changed. Profiles are - interpolated bi-linearly using supplied latitude and longitude. - The date used is the nearest one to the date supplied. - Furthermore, a dictionary must be supplied through the dictionary - parameter in order for the dataset to be accurately read. Lastly, - the dataset must use a rectangular grid sorted in either ascending - or descending order of latitude and longitude. - - - ``Reanalysis``: sets pressure, temperature, wind-u and wind-v - profiles and surface elevation obtained from a weather forecast - file in ``netCDF`` format or from an ``OPeNDAP`` URL, both given - through the file parameter. When this type is chosen, the date and - location of the launch should already have been set through the - date and location parameters when initializing the Environment. - The ``netCDF`` and ``OPeNDAP`` datasets must contain at least - geopotential height or geopotential, temperature, wind-u and - wind-v profiles as a function of pressure levels. If surface - geopotential or geopotential height is given, elevation is also - set. Otherwise, elevation is not changed. Profiles are - interpolated bi-linearly using supplied latitude and longitude. - The date used is the nearest one to the date supplied. - Furthermore, a dictionary must be supplied through the dictionary - parameter in order for the dataset to be accurately read. Lastly, - the dataset must use a rectangular grid sorted in either ascending - or descending order of latitude and longitude. - - - ``Ensemble``: sets pressure, temperature, wind-u and wind-v - profiles and surface elevation obtained from a weather forecast - file in ``netCDF`` format or from an ``OPeNDAP`` URL, both given - through the file parameter. When this type is chosen, the date and - location of the launch should already have been set through the - date and location parameters when initializing the Environment. - The ``netCDF`` and ``OPeNDAP`` datasets must contain at least - geopotential height or geopotential, temperature, wind-u and - wind-v profiles as a function of pressure levels. If surface - geopotential or geopotential height is given, elevation is also - set. Otherwise, elevation is not changed. Profiles are - interpolated bi-linearly using supplied latitude and longitude. - The date used is the nearest one to the date supplied. - Furthermore, a dictionary must be supplied through the dictionary - parameter in order for the dataset to be accurately read. Lastly, - the dataset must use a rectangular grid sorted in either ascending - or descending order of latitude and longitude. By default the - first ensemble forecast is activated. - - .. seealso:: - - To activate other ensemble forecasts see - :meth:`rocketpy.Environment.select_ensemble_member`. - - - ``custom_atmosphere``: sets pressure, temperature, wind-u and - wind-v profiles given though the pressure, temperature, wind-u and - wind-v parameters of this method. If pressure or temperature is - not given, it will default to the `International Standard - Atmosphere`. If the wind components are not given, it will default - to 0. - - file : string, optional - String that must be given when type is either ``wyoming_sounding``, - ``Forecast``, ``Reanalysis``, ``Ensemble`` or ``Windy``. It - specifies the location of the data given, either through a local - file address or a URL. If type is ``Forecast``, this parameter can - also be either ``GFS``, ``FV3``, ``RAP`` or ``NAM`` for latest of - these forecasts. - - .. note:: - - Time reference for the Forecasts are: - - - ``GFS``: `Global` - 0.25deg resolution - Updates every 6 - hours, forecast for 81 points spaced by 3 hours - - ``RAP``: `Regional USA` - 0.19deg resolution - Updates hourly, - forecast for 40 points spaced hourly - - ``NAM``: `Regional CONUS Nest` - 5 km resolution - Updates - every 6 hours, forecast for 21 points spaced by 3 hours - - If type is ``Ensemble``, this parameter can also be ``GEFS`` - for the latest of this ensemble. - - .. note:: - - Time referece for the Ensembles are: - - - GEFS: Global, bias-corrected, 0.5deg resolution, 21 forecast - members, Updates every 6 hours, forecast for 65 points spaced - by 4 hours - - CMC (currently not available): Global, 0.5deg resolution, 21 \ - forecast members, Updates every 12 hours, forecast for 65 \ - points spaced by 4 hours - - If type is ``Windy``, this parameter can be either ``GFS``, - ``ECMWF``, ``ICON`` or ``ICONEU``. Default in this case is ``ECMWF``. - dictionary : dictionary, string, optional - Dictionary that must be given when type is either ``Forecast``, - ``Reanalysis`` or ``Ensemble``. It specifies the dictionary to be - used when reading ``netCDF`` and ``OPeNDAP`` files, allowing the - correct retrieval of data. Acceptable values include ``ECMWF``, - ``NOAA``, ``UCAR`` and ``MERRA2`` for default dictionaries which can generally - be used to read datasets from these institutes. Alternatively, a - dictionary structure can also be given, specifying the short names - used for time, latitude, longitude, pressure levels, temperature - profile, geopotential or geopotential height profile, wind-u and - wind-v profiles in the dataset given in the file parameter. - Additionally, ensemble dictionaries must have the ensemble as well. - An example is the following dictionary, used for ``NOAA``: - - .. code-block:: python - - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "ensemble": "ens", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - + Atmospheric model selector (case-insensitive). Accepted values are + ``"standard_atmosphere"``, ``"wyoming_sounding"``, ``"windy"``, + ``"forecast"``, ``"reanalysis"``, ``"ensemble"`` and + ``"custom_atmosphere"``. + file : string | netCDF4.Dataset, optional + Data source or model shortcut. Meaning depends on ``type``: + + - ``"standard_atmosphere"`` and ``"custom_atmosphere"``: ignored. + - ``"wyoming_sounding"``: URL of the sounding text page. + - ``"windy"``: one of ``"ECMWF"``, ``"GFS"``, ``"ICON"`` or + ``"ICONEU"``. + - ``"forecast"``: local path, OPeNDAP URL, open + ``netCDF4.Dataset``, or one of ``"AIGFS"``, ``"GFS"``, + ``"NAM"``, ``"RAP"``, ``"HRRR"`` or ``"HIRESW"`` for the + latest available forecast. + - ``"reanalysis"``: local path, OPeNDAP URL, or open + ``netCDF4.Dataset``. + - ``"ensemble"``: local path, OPeNDAP URL, open + ``netCDF4.Dataset``, or ``"GEFS"`` for the latest available + forecast. + dictionary : dict | str, optional + A dictionary mapping variables in the netCDF4 file to standard + names. Meaning depends on ``type``: + + - ``"standard_atmosphere"``, ``"custom_atmosphere"`` and + ``"wyoming_sounding"``: ignored. + - ``"windy"``: ignored. + - ``"forecast"``, ``"reanalysis"`` and ``"ensemble"``: local + dictionary, or one of the built-in mappings (e.g. ``"ECMWF"``, + ``"GFS"``, ``"MERRA2"``, ``"RAP"``, ``"HRRR"``, etc.) corresponding + to the file structure. pressure : float, string, array, callable, optional - This defines the atmospheric pressure profile. - Should be given if the type parameter is ``custom_atmosphere``. If not, - than the the ``Standard Atmosphere`` pressure will be used. - If a float is given, it will define a constant pressure - profile. The float should be in units of Pa. - If a string is given, it should point to a `.CSV` file - containing at most one header line and two columns of data. - The first column must be the geometric height above sea level in - meters while the second column must be the pressure in Pa. - If an array is given, it is expected to be a list or array - of coordinates (height in meters, pressure in Pa). - Finally, a callable or function is also accepted. The - function should take one argument, the height above sea - level in meters and return a corresponding pressure in Pa. + This defines the atmospheric pressure profile. Should be given if + the type parameter is ``custom_atmosphere``. If not, than the the + ``Standard Atmosphere`` pressure will be used. If a float is given, + it will define a constant pressure profile. The float should be in + units of Pa. If a string is given, it should point to a `.CSV` file + containing at most one header line and two columns of data. The first + column must be the geometric height above sea level in meters while + the second column must be the pressure in Pa. If an array is given, + it is expected to be a list or array of coordinates (height in + meters, pressure in Pa). Finally, a callable or function is also + accepted. The function should take one argument, the height above + sea level in meters and return a corresponding pressure in Pa. temperature : float, string, array, callable, optional This defines the atmospheric temperature profile. Should be given if the type parameter is ``custom_atmosphere``. If not, than the the @@ -1268,10 +1281,50 @@ def set_atmospheric_model( # pylint: disable=too-many-statements m/s). Finally, a callable or function is also accepted. The function should take one argument, the height above sea level in meters and return a corresponding wind-v in m/s. + pressure_conversion_factor : string, int, float, optional + This defines the pressure conversion factor to Pa when type is + ``forecast``, ``reanalysis``, or ``ensemble``. The pressure unit + from the data may not be in Pascal, so the correction is necessary. + Valid strings are ``"mbar"``, ``"hPa"``, or ``"Pa"``, or a strictly + positive number if using a custom pressure unit. If None (the default), + the conversion factor will be automatically detected based on the + model name (e.g. ERA5/ECMWF/MERRA2 reanalysis files commonly use hPa, + while online GFS/NAM/RAP/HRRR forecast models use Pa) or, if + unavailable, by reading the pressure unit attribute from the file. Returns ------- None + + Raises + ------ + ValueError + If ``type`` is unknown, if required launch date/time information is + missing for date-dependent models, if Windy model names are invalid, + or if required atmospheric variables cannot be read from the input + dataset. + TypeError + If ``dictionary`` is invalid for ``"forecast"``, ``"reanalysis"`` + or ``"ensemble"``. + KeyError + If a built-in mapping name passed in ``dictionary`` is unknown. + + See Also + -------- + :ref:`atmospheric_models` + Overview of all atmospheric-model workflows in the user guide. + :ref:`forecast` + Forecast and Windy usage details, including latest-model shortcuts. + :ref:`reanalysis` + Reanalysis and MERRA-2 examples. + :ref:`soundings` + Wyoming sounding workflow and RUC migration notes. + :ref:`custom_atmosphere` + Defining pressure, temperature and wind profiles directly. + :ref:`ensemble_atmosphere` + Ensemble forecasts and member-selection workflow. + :ref:`environment_other_apis` + Building custom mapping dictionaries for NetCDF/OPeNDAP APIs. """ # Save atmospheric model type self.atmospheric_model_type = type @@ -1287,7 +1340,72 @@ def set_atmospheric_model( # pylint: disable=too-many-statements case "windy": self.process_windy_atmosphere(file) case "forecast" | "reanalysis" | "ensemble": + # Capture the user-supplied names before __validate_dictionary + # converts them to dicts, so they can drive auto-detection. + _input_dict = ( + dictionary.upper() if isinstance(dictionary, str) else None + ) + _input_file = file.upper() if isinstance(file, str) else None + + # Validate format of user-supplied value (if any). + # When None, auto-detection runs after dictionary resolution. + if pressure_conversion_factor is not None: + if not isinstance(pressure_conversion_factor, (float, int, str)): + raise ValueError( + "Argument 'pressure_conversion_factor' must be numeric or a standard pressure unit ('mbar', 'hPa', 'Pa')!" + ) + if isinstance(pressure_conversion_factor, (float, int)): + if pressure_conversion_factor <= 0: + raise ValueError( + "Argument 'pressure_conversion_factor' must be strictly positive!" + ) + if isinstance(pressure_conversion_factor, str): + if pressure_conversion_factor.lower() not in ( + "mbar", + "hpa", + "pa", + ): + raise ValueError( + "Argument 'pressure_conversion_factor' unit must be a standard pressure unit ('mbar', 'hPa', 'Pa')!" + ) + + if isinstance(file, str): + shortcut_map = self.__atm_type_file_to_function_map.get(type, {}) + matching_shortcut = next( + ( + shortcut + for shortcut in shortcut_map + if shortcut.lower() == file.lower() + ), + None, + ) + if matching_shortcut is not None: + file = matching_shortcut + + if isinstance(file, str): + file_upper = file.upper() + if type == "forecast" and file_upper == "HIRESW": + raise ValueError( + "The HIRESW latest-model shortcut is currently " + "unavailable because NOMADS OPeNDAP is deactivated. " + "Please use another forecast source or provide a " + "compatible dataset path/URL explicitly." + ) + if type == "ensemble" and file_upper == "GEFS": + raise ValueError( + "The GEFS latest-model shortcut is currently " + "unavailable because NOMADS OPeNDAP is deactivated. " + "Please use another ensemble source or provide a " + "compatible dataset path/URL explicitly." + ) + dictionary = self.__validate_dictionary(file, dictionary) + + # Determine the numeric conversion factor (pressure → Pa). + conversion_factor = self.__determine_pressure_conversion_factor( + pressure_conversion_factor, _input_dict, _input_file + ) + try: fetch_function = self.__atm_type_file_to_function_map[type][file] except KeyError: @@ -1297,9 +1415,35 @@ def set_atmospheric_model( # pylint: disable=too-many-statements dataset = fetch_function() if fetch_function is not None else file if type in ["forecast", "reanalysis"]: - self.process_forecast_reanalysis(dataset, dictionary) + self.process_forecast_reanalysis( + dataset, dictionary, conversion_factor=conversion_factor + ) else: - self.process_ensemble(dataset, dictionary) + self.process_ensemble(dataset, dictionary, conversion_factor) + + ground_pressure = self.pressure(self.elevation) + if not 30000 <= ground_pressure <= 120_000: + if pressure_conversion_factor is None: + hint = ( + "The unit was auto-detected from the file's pressure " + "level variable or model name, but the result is still out of range. " + "Override by passing pressure_conversion_factor explicitly " + "('hPa' for ERA5/ECMWF/MERRA2 files, 'Pa' for online " + "forecast models such as GFS, NAM, RAP, HRRR)." + ) + else: + hint = ( + f"pressure_conversion_factor='{pressure_conversion_factor}' " + f"may be wrong. ERA5/ECMWF/MERRA2 reanalysis files store pressure " + f"in hPa — use 'hPa'. Online forecast models " + f"(GFS, NAM, RAP, HRRR) store pressure in Pa — use 'Pa'." + ) + warnings.warn( + f"Ground-level pressure is {ground_pressure:.0f} Pa, which is " + f"outside the expected range [30 000 Pa, 120 000 Pa]. {hint}", + UserWarning, + stacklevel=2, + ) case _: # pragma: no cover raise ValueError(f"Unknown model type '{type}'.") @@ -1428,7 +1572,7 @@ def process_custom_atmosphere( self.__reset_barometric_height_function() # Check maximum height of custom pressure input - if not callable(self.pressure.source): + if self.pressure.is_array_source(): max_expected_height = max(self.pressure[-1, 0], max_expected_height) # Save temperature profile @@ -1438,14 +1582,14 @@ def process_custom_atmosphere( else: self.__set_temperature_function(temperature) # Check maximum height of custom temperature input - if not callable(self.temperature.source): + if self.temperature.is_array_source(): max_expected_height = max(self.temperature[-1, 0], max_expected_height) # Save wind profile self.__set_wind_velocity_x_function(wind_u) self.__set_wind_velocity_y_function(wind_v) # Check maximum height of custom wind input - if not callable(self.wind_velocity_x.source): + if self.wind_velocity_x.is_array_source(): max_expected_height = max(self.wind_velocity_x[-1, 0], max_expected_height) def wind_heading_func(h): # TODO: create another custom reset for heading @@ -1471,6 +1615,12 @@ def process_windy_atmosphere(self, model="ECMWF"): # pylint: disable=too-many-s ``ECMWF`` for the `ECMWF-HRES` model, ``GFS`` for the `GFS` model, ``ICON`` for the `ICON-Global` model or ``ICONEU`` for the `ICON-EU` model. + + Raises + ------ + ValueError + If ``model`` is not one of ``ECMWF``, ``GFS``, ``ICON`` or + ``ICONEU``. """ if model.lower() not in ["ecmwf", "gfs", "icon", "iconeu"]: @@ -1604,11 +1754,11 @@ def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements Example: - http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0200&TO=0200&STNM=82599 + https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05%2012:00:00&id=83779&type=TEXT:LIST Notes ----- - More can be found at: http://weather.uwyo.edu/upperair/sounding.html. + More can be found at: https://weather.uwyo.edu/upperair/sounding.shtml. Returns ------- @@ -1620,7 +1770,9 @@ def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements # Process Wyoming Sounding by finding data table and station info response_split_text = re.split("(<.{0,1}PRE>)", response.text) data_table = response_split_text[2] - station_info = response_split_text[6] + # Legacy CGI pages had extra
 blocks with station information;
+        # current WSGI pages have a single block with the data table only.
+        station_info = response_split_text[6] if len(response_split_text) > 6 else None
 
         # Transform data table into np array
         data_array = []
@@ -1642,8 +1794,10 @@ def process_wyoming_sounding(self, file):  # pylint: disable=too-many-statements
         self.__set_temperature_function(data_array[:, (1, 2)])
 
         # Retrieve wind-u and wind-v from data array
-        ## Converts Knots to m/s
-        data_array[:, 7] = data_array[:, 7] * 1.852 / 3.6
+        ## Legacy pages report wind speed as SKNT (knots); current WSGI pages
+        ## report it as SPED (m/s) and need no conversion.
+        if "SKNT" in data_table.split("\n")[2]:
+            data_array[:, 7] = data_array[:, 7] * 1.852 / 3.6  # Knots to m/s
         ## Convert wind direction to wind heading
         data_array[:, 5] = (data_array[:, 6] + 180) % 360
         data_array[:, 3] = data_array[:, 7] * np.sin(data_array[:, 5] * np.pi / 180)
@@ -1661,18 +1815,21 @@ def process_wyoming_sounding(self, file):  # pylint: disable=too-many-statements
         self.__set_wind_direction_function(data_array[:, (1, 6)])
         self.__set_wind_speed_function(data_array[:, (1, 7)])
 
-        # Retrieve station elevation from station info
-        station_elevation_text = station_info.split("\n")[6]
-
-        # Convert station elevation text into float value
-        self.elevation = float(
-            re.findall(r"[0-9]+\.[0-9]+|[0-9]+", station_elevation_text)[0]
-        )
+        # Retrieve station elevation
+        if station_info is not None:
+            # Legacy pages: read it from the station information block
+            station_elevation_text = station_info.split("\n")[6]
+            self.elevation = float(
+                re.findall(r"[0-9]+\.[0-9]+|[0-9]+", station_elevation_text)[0]
+            )
+        else:
+            # Current WSGI pages: use the surface (first) level height
+            self.elevation = float(data_array[0, 1])
 
         # Save maximum expected height
         self._max_expected_height = data_array[-1, 1]
 
-    def process_forecast_reanalysis(self, file, dictionary):  # pylint: disable=too-many-locals,too-many-statements
+    def process_forecast_reanalysis(self, file, dictionary, conversion_factor):  # pylint: disable=too-many-locals,too-many-statements
         """Import and process atmospheric data from weather forecasts
         and reanalysis given as ``netCDF`` or ``OPeNDAP`` files.
         Sets pressure, temperature, wind-u and wind-v
@@ -1724,10 +1881,20 @@ def process_forecast_reanalysis(self, file, dictionary):  # pylint: disable=too-
                     "u_wind": "ugrdprs",
                     "v_wind": "vgrdprs",
                 }
+        conversion_factor : float, int
+            Specifies the factor by which the pressure will be multiplied
+            in order to transform it to Pascal.
 
         Returns
         -------
         None
+
+        Raises
+        ------
+        ValueError
+            If launch date/time was not set before loading date-dependent data,
+            or if required geopotential/geopotential-height, temperature,
+            wind-u, or wind-v variables cannot be read from the dataset.
         """
         # Check if date, lat and lon are known
         self.__validate_datetime()
@@ -1735,23 +1902,41 @@ def process_forecast_reanalysis(self, file, dictionary):  # pylint: disable=too-
         # Read weather file
         if isinstance(file, str):
             data = netCDF4.Dataset(file)
-            if dictionary["time"] not in data.variables.keys():
-                dictionary = self.__weather_model_map.get("ECMWF_v0")
         else:
             data = file
 
+        dictionary = self.__resolve_dictionary_for_dataset(dictionary, data)
+
         # Get time, latitude and longitude data from file
         time_array = data.variables[dictionary["time"]]
-        lon_list = data.variables[dictionary["longitude"]][:].tolist()
-        lat_list = data.variables[dictionary["latitude"]][:].tolist()
+        lon_array = data.variables[dictionary["longitude"]]
+        lat_array = data.variables[dictionary["latitude"]]
+
+        # Some THREDDS datasets use projected x/y coordinates.
+        if dictionary.get("projection") is not None:
+            projection_variable = data.variables[dictionary["projection"]]
+            if dictionary.get("projection") == "LambertConformal_Projection":
+                x_units = getattr(lon_array, "units", "m")
+                target_lon, target_lat = geodesic_to_lambert_conformal(
+                    self.latitude,
+                    self.longitude,
+                    projection_variable,
+                    x_units=x_units,
+                )
+            else:
+                target_lon = self.longitude
+                target_lat = self.latitude
+        else:
+            target_lon = self.longitude
+            target_lat = self.latitude
 
         # Find time, latitude and longitude indexes
         time_index = find_time_index(self.datetime_date, time_array)
-        lon, lon_index = find_longitude_index(self.longitude, lon_list)
-        _, lat_index = find_latitude_index(self.latitude, lat_list)
+        lon, lon_index = find_longitude_index(target_lon, lon_array)
+        _, lat_index = find_latitude_index(target_lat, lat_array)
 
         # Get pressure level data from file
-        levels = get_pressure_levels_from_file(data, dictionary)
+        levels = get_pressure_levels_from_file(data, dictionary, conversion_factor)
 
         # Get geopotential data from file
         try:
@@ -1806,9 +1991,9 @@ def process_forecast_reanalysis(self, file, dictionary):  # pylint: disable=too-
             ) from e
 
         # Prepare for bilinear interpolation
-        x, y = self.latitude, lon
-        x1, y1 = lat_list[lat_index - 1], lon_list[lon_index - 1]
-        x2, y2 = lat_list[lat_index], lon_list[lon_index]
+        x, y = target_lat, lon
+        x1, y1 = float(lat_array[lat_index - 1]), float(lon_array[lon_index - 1])
+        x2, y2 = float(lat_array[lat_index]), float(lon_array[lon_index])
 
         # Determine properties in lat, lon
         height = bilinear_interpolation(
@@ -1860,6 +2045,17 @@ def process_forecast_reanalysis(self, file, dictionary):  # pylint: disable=too-
             wind_vs[:, 1, 1],
         )
 
+        # Some datasets expose different level counts between fields
+        # (e.g., temperature on isobaric1 and geopotential on isobaric).
+        min_profile_length = min(
+            len(levels), len(height), len(temper), len(wind_u), len(wind_v)
+        )
+        levels = levels[:min_profile_length]
+        height = height[:min_profile_length]
+        temper = temper[:min_profile_length]
+        wind_u = wind_u[:min_profile_length]
+        wind_v = wind_v[:min_profile_length]
+
         # Determine wind speed, heading and direction
         wind_speed = calculate_wind_speed(wind_u, wind_v)
         wind_heading = calculate_wind_heading(wind_u, wind_v)
@@ -1917,14 +2113,14 @@ def process_forecast_reanalysis(self, file, dictionary):  # pylint: disable=too-
             )
         else:
             self.atmospheric_model_interval = 0
-        self.atmospheric_model_init_lat = lat_list[0]
-        self.atmospheric_model_end_lat = lat_list[-1]
-        self.atmospheric_model_init_lon = lon_list[0]
-        self.atmospheric_model_end_lon = lon_list[-1]
+        self.atmospheric_model_init_lat = float(lat_array[0])
+        self.atmospheric_model_end_lat = float(lat_array[len(lat_array) - 1])
+        self.atmospheric_model_init_lon = float(lon_array[0])
+        self.atmospheric_model_end_lon = float(lon_array[len(lon_array) - 1])
 
         # Save debugging data
-        self.lat_array = lat_list
-        self.lon_array = lon_list
+        self.lat_array = [x1, x2]
+        self.lon_array = [y1, y2]
         self.lon_index = lon_index
         self.lat_index = lat_index
         self.geopotentials = geopotentials
@@ -1932,13 +2128,16 @@ def process_forecast_reanalysis(self, file, dictionary):  # pylint: disable=too-
         self.wind_vs = wind_vs
         self.levels = levels
         self.temperatures = temperatures
-        self.time_array = time_array[:].tolist()
+        self.time_array = [
+            float(time_array[0]),
+            float(time_array[time_array.shape[0] - 1]),
+        ]
         self.height = height
 
         # Close weather data
         data.close()
 
-    def process_ensemble(self, file, dictionary):  # pylint: disable=too-many-locals,too-many-statements
+    def process_ensemble(self, file, dictionary, conversion_factor):  # pylint: disable=too-many-locals,too-many-statements
         """Import and process atmospheric data from weather ensembles
         given as ``netCDF`` or ``OPeNDAP`` files. Sets pressure, temperature,
         wind-u and wind-v profiles and surface elevation obtained from a weather
@@ -1989,11 +2188,21 @@ def process_ensemble(self, file, dictionary):  # pylint: disable=too-many-locals
                     "u_wind": "ugrdprs",
                     "v_wind": "vgrdprs",
                 }
+        conversion_factor : float, int
+            Specifies the factor by which the pressure will be multiplied
+            in order to transform it to Pascal.
 
         See also
         --------
-        See the :class:``rocketpy.environment.weather_model_mapping`` for some
-        dictionary examples.
+        See the :class:`rocketpy.environment.weather_model_mapping.WeatherModelMapping`
+        class for some dictionary examples.
+
+        Raises
+        ------
+        ValueError
+            If launch date/time was not set before loading date-dependent data,
+            or if required geopotential/geopotential-height, temperature,
+            wind-u, or wind-v variables cannot be read from the dataset.
         """
         # Check if date, lat and lon are known
         self.__validate_datetime()
@@ -2004,26 +2213,49 @@ def process_ensemble(self, file, dictionary):  # pylint: disable=too-many-locals
         else:
             data = file
 
+        dictionary = self.__resolve_dictionary_for_dataset(dictionary, data)
+
         # Get time, latitude and longitude data from file
         time_array = data.variables[dictionary["time"]]
-        lon_list = data.variables[dictionary["longitude"]][:].tolist()
-        lat_list = data.variables[dictionary["latitude"]][:].tolist()
+        lon_array = data.variables[dictionary["longitude"]]
+        lat_array = data.variables[dictionary["latitude"]]
+
+        # Some THREDDS datasets use projected x/y coordinates. When a
+        # "projection" variable is provided in the mapping dictionary, convert
+        # the launch site's geodesic coordinates to the model's projected
+        # coordinate system before locating the nearest grid cell.
+        if dictionary.get("projection") is not None:
+            projection_variable = data.variables[dictionary["projection"]]
+            if dictionary.get("projection") == "LambertConformal_Projection":
+                x_units = getattr(lon_array, "units", "m")
+                target_lon, target_lat = geodesic_to_lambert_conformal(
+                    self.latitude,
+                    self.longitude,
+                    projection_variable,
+                    x_units=x_units,
+                )
+            else:
+                target_lon = self.longitude
+                target_lat = self.latitude
+        else:
+            target_lon = self.longitude
+            target_lat = self.latitude
 
         # Find time, latitude and longitude indexes
         time_index = find_time_index(self.datetime_date, time_array)
-        lon, lon_index = find_longitude_index(self.longitude, lon_list)
-        _, lat_index = find_latitude_index(self.latitude, lat_list)
+        lon, lon_index = find_longitude_index(target_lon, lon_array)
+        _, lat_index = find_latitude_index(target_lat, lat_array)
 
         # Get ensemble data from file
+        has_ensemble_dimension = True
         try:
             num_members = len(data.variables[dictionary["ensemble"]][:])
-        except KeyError as e:
-            raise ValueError(
-                "Unable to read ensemble data from file. Check file and dictionary."
-            ) from e
+        except KeyError:
+            has_ensemble_dimension = False
+            num_members = 1
 
         # Get pressure level data from file
-        levels = get_pressure_levels_from_file(data, dictionary)
+        levels = get_pressure_levels_from_file(data, dictionary, conversion_factor)
 
         inverse_dictionary = {v: k for k, v in dictionary.items()}
         param_dictionary = {
@@ -2079,10 +2311,16 @@ def process_ensemble(self, file, dictionary):  # pylint: disable=too-many-locals
                 "Unable to read wind-v component. Check file and dictionary."
             ) from e
 
+        if not has_ensemble_dimension:
+            geopotentials = np.expand_dims(geopotentials, axis=0)
+            temperatures = np.expand_dims(temperatures, axis=0)
+            wind_us = np.expand_dims(wind_us, axis=0)
+            wind_vs = np.expand_dims(wind_vs, axis=0)
+
         # Prepare for bilinear interpolation
-        x, y = self.latitude, lon
-        x1, y1 = lat_list[lat_index - 1], lon_list[lon_index - 1]
-        x2, y2 = lat_list[lat_index], lon_list[lon_index]
+        x, y = target_lat, lon
+        x1, y1 = float(lat_array[lat_index - 1]), float(lon_array[lon_index - 1])
+        x2, y2 = float(lat_array[lat_index]), float(lon_array[lon_index])
 
         # Determine properties in lat, lon
         height = bilinear_interpolation(
@@ -2134,6 +2372,19 @@ def process_ensemble(self, file, dictionary):  # pylint: disable=too-many-locals
             wind_vs[:, :, 1, 1],
         )
 
+        min_profile_length = min(
+            len(levels),
+            height.shape[1],
+            temper.shape[1],
+            wind_u.shape[1],
+            wind_v.shape[1],
+        )
+        levels = levels[:min_profile_length]
+        height = height[:, :min_profile_length]
+        temper = temper[:, :min_profile_length]
+        wind_u = wind_u[:, :min_profile_length]
+        wind_v = wind_v[:, :min_profile_length]
+
         # Determine wind speed, heading and direction
         wind_speed = calculate_wind_speed(wind_u, wind_v)
         wind_heading = calculate_wind_heading(wind_u, wind_v)
@@ -2166,14 +2417,14 @@ def process_ensemble(self, file, dictionary):  # pylint: disable=too-many-locals
         self.atmospheric_model_init_date = get_initial_date_from_time_array(time_array)
         self.atmospheric_model_end_date = get_final_date_from_time_array(time_array)
         self.atmospheric_model_interval = get_interval_date_from_time_array(time_array)
-        self.atmospheric_model_init_lat = lat_list[0]
-        self.atmospheric_model_end_lat = lat_list[-1]
-        self.atmospheric_model_init_lon = lon_list[0]
-        self.atmospheric_model_end_lon = lon_list[-1]
+        self.atmospheric_model_init_lat = float(lat_array[0])
+        self.atmospheric_model_end_lat = float(lat_array[len(lat_array) - 1])
+        self.atmospheric_model_init_lon = float(lon_array[0])
+        self.atmospheric_model_end_lon = float(lon_array[len(lon_array) - 1])
 
         # Save debugging data
-        self.lat_array = lat_list
-        self.lon_array = lon_list
+        self.lat_array = [x1, x2]
+        self.lon_array = [y1, y2]
         self.lon_index = lon_index
         self.lat_index = lat_index
         self.geopotentials = geopotentials
@@ -2181,7 +2432,10 @@ def process_ensemble(self, file, dictionary):  # pylint: disable=too-many-locals
         self.wind_vs = wind_vs
         self.levels = levels
         self.temperatures = temperatures
-        self.time_array = time_array[:].tolist()
+        self.time_array = [
+            float(time_array[0]),
+            float(time_array[time_array.shape[0] - 1]),
+        ]
         self.height = height
 
         # Close weather data
@@ -2520,9 +2774,10 @@ def export_environment(self, filename="environment"):
 
         with open(filename + ".json", "w") as f:
             json.dump(export_env_dictionary, f, sort_keys=False, indent=4, default=str)
-        print(
-            f"Your Environment file was saved at '{filename}.json'. You can use "
-            "it in the future by using the custom_atmosphere atmospheric model."
+        logger.info(
+            "Your Environment file was saved at '%s.json'. "
+            "You can use it in the future by using the custom_atmosphere atmospheric model.",
+            filename,
         )
 
     def set_earth_geometry(self, datum):
@@ -2669,6 +2924,27 @@ def to_dict(self, **kwargs):
             "timezone": self.timezone,
             "max_expected_height": self.max_expected_height,
             "atmospheric_model_type": self.atmospheric_model_type,
+            "atmospheric_model_init_date": getattr(
+                self, "atmospheric_model_init_date", None
+            ),
+            "atmospheric_model_end_date": getattr(
+                self, "atmospheric_model_end_date", None
+            ),
+            "atmospheric_model_interval": getattr(
+                self, "atmospheric_model_interval", None
+            ),
+            "atmospheric_model_init_lat": getattr(
+                self, "atmospheric_model_init_lat", None
+            ),
+            "atmospheric_model_end_lat": getattr(
+                self, "atmospheric_model_end_lat", None
+            ),
+            "atmospheric_model_init_lon": getattr(
+                self, "atmospheric_model_init_lon", None
+            ),
+            "atmospheric_model_end_lon": getattr(
+                self, "atmospheric_model_end_lon", None
+            ),
             "pressure": self.pressure,
             "temperature": self.temperature,
             "wind_velocity_x": wind_velocity_x,
@@ -2676,6 +2952,15 @@ def to_dict(self, **kwargs):
             "wind_heading": wind_heading,
             "wind_direction": wind_direction,
             "wind_speed": wind_speed,
+            "level_ensemble": getattr(self, "level_ensemble", None),
+            "height_ensemble": getattr(self, "height_ensemble", None),
+            "temperature_ensemble": getattr(self, "temperature_ensemble", None),
+            "wind_u_ensemble": getattr(self, "wind_u_ensemble", None),
+            "wind_v_ensemble": getattr(self, "wind_v_ensemble", None),
+            "wind_heading_ensemble": getattr(self, "wind_heading_ensemble", None),
+            "wind_direction_ensemble": getattr(self, "wind_direction_ensemble", None),
+            "wind_speed_ensemble": getattr(self, "wind_speed_ensemble", None),
+            "num_ensemble_members": getattr(self, "num_ensemble_members", None),
         }
 
         if kwargs.get("include_outputs", False):
@@ -2699,6 +2984,7 @@ def from_dict(cls, data):  # pylint: disable=too-many-statements
             max_expected_height=data["max_expected_height"],
         )
         atmospheric_model = data["atmospheric_model_type"]
+        env.atmospheric_model_type = atmospheric_model
 
         match atmospheric_model:
             case "standard_atmosphere":
@@ -2755,6 +3041,6 @@ def from_dict(cls, data):  # pylint: disable=too-many-statements
 
     results = doctest.testmod()
     if results.failed < 1:
-        print(f"All the {results.attempted} tests passed!")
+        logger.debug("All the %d tests passed!", results.attempted)
     else:
-        print(f"{results.failed} out of {results.attempted} tests failed.")
+        logger.error("%d out of %d tests failed.", results.failed, results.attempted)
diff --git a/rocketpy/environment/environment_analysis.py b/rocketpy/environment/environment_analysis.py
index 13589078e..e4dcbd021 100644
--- a/rocketpy/environment/environment_analysis.py
+++ b/rocketpy/environment/environment_analysis.py
@@ -2,6 +2,7 @@
 import copy
 import datetime
 import json
+import logging
 import warnings
 from collections import defaultdict
 from functools import cached_property
@@ -24,6 +25,8 @@
 from ..units import convert_units
 from .environment import Environment
 
+logger = logging.getLogger(__name__)
+
 # TODO: the average_wind_speed_profile_by_hour and similar methods could be more abstract than currently are
 
 
@@ -236,13 +239,15 @@ def __check_requirements(self):
                 check_requirement_version(module_name, version)
             except (ValueError, ImportError) as e:
                 has_error = True
-                print(
-                    f"The following error occurred while importing {module_name}: {e}"
+                logger.error(
+                    "The following error occurred while importing %s: %s",
+                    module_name,
+                    e,
                 )
         if has_error:
-            print(
+            logger.error(
                 "Given the above errors, some methods may not work. Please run "
-                + "'pip install rocketpy[env_analysis]' to install extra requirements."
+                "'pip install rocketpy[env_analysis]' to install extra requirements."
             )
 
     def __init_surface_dictionary(self):
@@ -460,8 +465,6 @@ def __init_data_parsing_units(self):
             "height_ASL": "m",
             "pressure": "hPa",
             "temperature": "K",
-            "wind_direction": "deg",
-            "wind_heading": "deg",
             "wind_speed": "m/s",
             "wind_velocity_x": "m/s",
             "wind_velocity_y": "m/s",
@@ -508,8 +511,9 @@ def __init_unit_system(self):
             }
         else:
             # Default to SI
-            print(
-                f"Defaulting to SI unit system, the {self.unit_system_string} was not found."
+            logger.warning(
+                "Defaulting to SI unit system, the '%s' unit system was not found.",
+                self.unit_system_string,
             )
             self.unit_system = {
                 "length": "m",
@@ -570,8 +574,6 @@ def __parse_pressure_level_data(self):
         Must compute the following for each date and hour available in the dataset:
         - pressure = Function(..., inputs="Height Above Ground Level (m)", outputs="Pressure (Pa)")
         - temperature = Function(..., inputs="Height Above Ground Level (m)", outputs="Temperature (K)")
-        - wind_direction = Function(..., inputs="Height Above Ground Level (m)", outputs="Wind Direction (Deg True)")
-        - wind_heading = Function(..., inputs="Height Above Ground Level (m)", outputs="Wind Heading (Deg True)")
         - wind_speed = Function(..., inputs="Height Above Ground Level (m)", outputs="Wind Speed (m/s)")
         - wind_velocity_x = Function(..., inputs="Height Above Ground Level (m)", outputs="Wind Velocity X (m/s)")
         - wind_velocity_y = Function(..., inputs="Height Above Ground Level (m)", outputs="Wind Velocity Y (m/s)")
@@ -722,39 +724,6 @@ def __parse_pressure_level_data(self):
             )
             dictionary[date_string][hour_string]["wind_speed"] = wind_speed_function
 
-            # Create function for wind heading levels
-            wind_heading_array = (
-                np.arctan2(wind_velocity_x_array, wind_velocity_y_array)
-                * (180 / np.pi)
-                % 360
-            )
-
-            wind_heading_points_array = np.array(
-                [height_above_ground_level_array, wind_heading_array]
-            ).T
-            wind_heading_function = Function(
-                wind_heading_points_array,
-                inputs="Height Above Ground Level (m)",
-                outputs="Wind Heading (Deg True)",
-                extrapolation="constant",
-            )
-            dictionary[date_string][hour_string]["wind_heading"] = wind_heading_function
-
-            # Create function for wind direction levels
-            wind_direction_array = (wind_heading_array - 180) % 360
-            wind_direction_points_array = np.array(
-                [height_above_ground_level_array, wind_direction_array]
-            ).T
-            wind_direction_function = Function(
-                wind_direction_points_array,
-                inputs="Height Above Ground Level (m)",
-                outputs="Wind Direction (Deg True)",
-                extrapolation="constant",
-            )
-            dictionary[date_string][hour_string]["wind_direction"] = (
-                wind_direction_function
-            )
-
         return (dictionary, lat0, lat1, lon0, lon1)
 
     @property
@@ -985,8 +954,6 @@ def converted_pressure_level_data(self):
         conversion_dict = {
             "pressure": self.unit_system["pressure"],
             "temperature": self.unit_system["temperature"],
-            "wind_direction": self.unit_system["angle"],
-            "wind_heading": self.unit_system["angle"],
             "wind_speed": self.unit_system["wind_speed"],
             "wind_velocity_x": self.unit_system["wind_speed"],
             "wind_velocity_y": self.unit_system["wind_speed"],
@@ -2843,9 +2810,10 @@ def export_mean_profiles(self, filename="export_env_analysis"):
                     self.export_dictionary, sort_keys=False, indent=4, default=str
                 )
             )
-        print(
-            f"Your Environment Analysis file was saved, check it out: {filename}.json\n"
-            "You can use it to set a `customAtmosphere` atmospheric model"
+        logger.info(
+            "Your Environment Analysis file was saved: %s.json. "
+            "You can use it to set a customAtmosphere atmospheric model.",
+            filename,
         )
 
     @classmethod
@@ -2884,7 +2852,7 @@ def save(self, filename="env_analysis_dict"):
         file = open(filename, "w")  # pylint: disable=consider-using-with
         file.write(encoded_class)
         file.close()
-        print("Your Environment Analysis file was saved, check it out: " + filename)
+        logger.info("Your Environment Analysis file was saved: %s", filename)
 
     def create_environment_object(
         self, gravity=None, date=None, datum="SIRGAS2000", max_expected_height=80000.0
diff --git a/rocketpy/environment/fetchers.py b/rocketpy/environment/fetchers.py
index d5ac2a1df..740e9818a 100644
--- a/rocketpy/environment/fetchers.py
+++ b/rocketpy/environment/fetchers.py
@@ -3,6 +3,7 @@
 functions may be changed without notice in future feature releases.
 """
 
+import logging
 import re
 import time
 from datetime import datetime, timedelta, timezone
@@ -12,6 +13,10 @@
 
 from rocketpy.tools import exponential_backoff
 
+logger = logging.getLogger(__name__)
+
+MAX_RETRY_DELAY_SECONDS = 600
+
 
 @exponential_backoff(max_attempts=3, base_delay=1, max_delay=60)
 def fetch_open_elevation(lat, lon):
@@ -35,7 +40,9 @@ def fetch_open_elevation(lat, lon):
     RuntimeError
         If there is a problem reaching the Open-Elevation API servers.
     """
-    print(f"Fetching elevation from open-elevation.com for lat={lat}, lon={lon}...")
+    logger.debug(
+        "Fetching elevation from open-elevation.com for lat=%s, lon=%s", lat, lon
+    )
     request_url = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}"
     try:
         response = requests.get(request_url)
@@ -93,8 +100,8 @@ def fetch_atmospheric_data_from_windy(lat, lon, model):
 
 
 def fetch_gfs_file_return_dataset(max_attempts=10, base_delay=2):
-    """Fetches the latest GFS (Global Forecast System) dataset from the NOAA's
-    GrADS data server using the OpenDAP protocol.
+    """Fetches the latest GFS (Global Forecast System) dataset from the UCAR
+    THREDDS data server using the OPeNDAP protocol.
 
     Parameters
     ----------
@@ -113,38 +120,23 @@ def fetch_gfs_file_return_dataset(max_attempts=10, base_delay=2):
     RuntimeError
         If unable to load the latest weather data for GFS.
     """
-    time_attempt = datetime.now(tz=timezone.utc)
+    file_url = (
+        "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/GFS/Global_0p25deg/Best"
+    )
     attempt_count = 0
-    dataset = None
-
-    # TODO: the code below is trying to determine the hour of the latest available
-    # forecast by trial and error. This is not the best way to do it. We should
-    # actually check the NOAA website for the latest forecast time. Refactor needed.
     while attempt_count < max_attempts:
-        time_attempt -= timedelta(hours=6)  # GFS updates every 6 hours
-        file_url = (
-            f"https://nomads.ncep.noaa.gov/dods/gfs_0p25/gfs"
-            f"{time_attempt.year:04d}{time_attempt.month:02d}"
-            f"{time_attempt.day:02d}/"
-            f"gfs_0p25_{6 * (time_attempt.hour // 6):02d}z"
-        )
         try:
-            # Attempts to create a dataset from the file using OpenDAP protocol.
-            dataset = netCDF4.Dataset(file_url)
-            return dataset
+            return netCDF4.Dataset(file_url)
         except OSError:
             attempt_count += 1
-            time.sleep(base_delay**attempt_count)
+            time.sleep(min(base_delay**attempt_count, MAX_RETRY_DELAY_SECONDS))
 
-    if dataset is None:
-        raise RuntimeError(
-            "Unable to load latest weather data for GFS through " + file_url
-        )
+    raise RuntimeError("Unable to load latest weather data for GFS through " + file_url)
 
 
 def fetch_nam_file_return_dataset(max_attempts=10, base_delay=2):
-    """Fetches the latest NAM (North American Mesoscale) dataset from the NOAA's
-    GrADS data server using the OpenDAP protocol.
+    """Fetches the latest NAM (North American Mesoscale) dataset from the UCAR
+    THREDDS data server using the OPeNDAP protocol.
 
     Parameters
     ----------
@@ -163,33 +155,21 @@ def fetch_nam_file_return_dataset(max_attempts=10, base_delay=2):
     RuntimeError
         If unable to load the latest weather data for NAM.
     """
-    # Attempt to get latest forecast
-    time_attempt = datetime.now(tz=timezone.utc)
+    file_url = "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/NAM/CONUS_12km/Best"
     attempt_count = 0
-    dataset = None
-
     while attempt_count < max_attempts:
-        time_attempt -= timedelta(hours=6)  # NAM updates every 6 hours
-        file = (
-            f"https://nomads.ncep.noaa.gov/dods/nam/nam{time_attempt.year:04d}"
-            f"{time_attempt.month:02d}{time_attempt.day:02d}/"
-            f"nam_conusnest_{6 * (time_attempt.hour // 6):02d}z"
-        )
         try:
-            # Attempts to create a dataset from the file using OpenDAP protocol.
-            dataset = netCDF4.Dataset(file)
-            return dataset
+            return netCDF4.Dataset(file_url)
         except OSError:
             attempt_count += 1
-            time.sleep(base_delay**attempt_count)
+            time.sleep(min(base_delay**attempt_count, MAX_RETRY_DELAY_SECONDS))
 
-    if dataset is None:
-        raise RuntimeError("Unable to load latest weather data for NAM through " + file)
+    raise RuntimeError("Unable to load latest weather data for NAM through " + file_url)
 
 
 def fetch_rap_file_return_dataset(max_attempts=10, base_delay=2):
-    """Fetches the latest RAP (Rapid Refresh) dataset from the NOAA's GrADS data
-    server using the OpenDAP protocol.
+    """Fetches the latest RAP (Rapid Refresh) dataset from the UCAR THREDDS
+    data server using the OPeNDAP protocol.
 
     Parameters
     ----------
@@ -208,28 +188,88 @@ def fetch_rap_file_return_dataset(max_attempts=10, base_delay=2):
     RuntimeError
         If unable to load the latest weather data for RAP.
     """
-    # Attempt to get latest forecast
-    time_attempt = datetime.now(tz=timezone.utc)
+    file_url = "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/RAP/CONUS_13km/Best"
     attempt_count = 0
-    dataset = None
+    while attempt_count < max_attempts:
+        try:
+            return netCDF4.Dataset(file_url)
+        except OSError:
+            attempt_count += 1
+            time.sleep(min(base_delay**attempt_count, MAX_RETRY_DELAY_SECONDS))
+
+    raise RuntimeError("Unable to load latest weather data for RAP through " + file_url)
+
+
+def fetch_hrrr_file_return_dataset(max_attempts=10, base_delay=2):
+    """Fetches the latest HRRR (High-Resolution Rapid Refresh) dataset from
+    the NOAA's GrADS data server using the OpenDAP protocol.
+
+    Parameters
+    ----------
+    max_attempts : int, optional
+        The maximum number of attempts to fetch the dataset. Default is 10.
+    base_delay : int, optional
+        The base delay in seconds between attempts. Default is 2.
+
+    Returns
+    -------
+    netCDF4.Dataset
+        The HRRR dataset.
 
+    Raises
+    ------
+    RuntimeError
+        If unable to load the latest weather data for HRRR.
+    """
+    file_url = "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/HRRR/CONUS_2p5km/Best"
+    attempt_count = 0
     while attempt_count < max_attempts:
-        time_attempt -= timedelta(hours=1)  # RAP updates every hour
-        file = (
-            f"https://nomads.ncep.noaa.gov/dods/rap/rap{time_attempt.year:04d}"
-            f"{time_attempt.month:02d}{time_attempt.day:02d}/"
-            f"rap_{time_attempt.hour:02d}z"
-        )
         try:
-            # Attempts to create a dataset from the file using OpenDAP protocol.
-            dataset = netCDF4.Dataset(file)
-            return dataset
+            return netCDF4.Dataset(file_url)
         except OSError:
             attempt_count += 1
-            time.sleep(base_delay**attempt_count)
+            time.sleep(min(base_delay**attempt_count, MAX_RETRY_DELAY_SECONDS))
 
-    if dataset is None:
-        raise RuntimeError("Unable to load latest weather data for RAP through " + file)
+    raise RuntimeError(
+        "Unable to load latest weather data for HRRR through " + file_url
+    )
+
+
+def fetch_aigfs_file_return_dataset(max_attempts=10, base_delay=2):
+    """Fetches the latest AIGFS (Artificial Intelligence GFS) dataset from
+    the NOAA's GrADS data server using the OpenDAP protocol.
+
+    Parameters
+    ----------
+    max_attempts : int, optional
+        The maximum number of attempts to fetch the dataset. Default is 10.
+    base_delay : int, optional
+        The base delay in seconds between attempts. Default is 2.
+
+    Returns
+    -------
+    netCDF4.Dataset
+        The AIGFS dataset.
+
+    Raises
+    ------
+    RuntimeError
+        If unable to load the latest weather data for AIGFS.
+    """
+    file_url = (
+        "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/AIGFS/Global_0p25deg/Best"
+    )
+    attempt_count = 0
+    while attempt_count < max_attempts:
+        try:
+            return netCDF4.Dataset(file_url)
+        except OSError:
+            attempt_count += 1
+            time.sleep(min(base_delay**attempt_count, MAX_RETRY_DELAY_SECONDS))
+
+    raise RuntimeError(
+        "Unable to load latest weather data for AIGFS through " + file_url
+    )
 
 
 def fetch_hiresw_file_return_dataset(max_attempts=10, base_delay=2):
@@ -280,7 +320,7 @@ def fetch_hiresw_file_return_dataset(max_attempts=10, base_delay=2):
             return dataset
         except OSError:
             attempt_count += 1
-            time.sleep(base_delay**attempt_count)
+            time.sleep(min(base_delay**attempt_count, MAX_RETRY_DELAY_SECONDS))
 
     if dataset is None:
         raise RuntimeError(
@@ -359,7 +399,7 @@ def fetch_gefs_ensemble():
             return dataset
         except OSError:
             attempt_count += 1
-            time.sleep(2**attempt_count)
+            time.sleep(min(2**attempt_count, MAX_RETRY_DELAY_SECONDS))
     if not success:
         raise RuntimeError(
             "Unable to load latest weather data for GEFS through " + file
@@ -401,6 +441,6 @@ def fetch_cmc_ensemble():
             return dataset
         except OSError:
             attempt_count += 1
-            time.sleep(2**attempt_count)
+            time.sleep(min(2**attempt_count, MAX_RETRY_DELAY_SECONDS))
     if not success:
         raise RuntimeError("Unable to load latest weather data for CMC through " + file)
diff --git a/rocketpy/environment/tools.py b/rocketpy/environment/tools.py
index 1239ee6b9..5bb25c6ec 100644
--- a/rocketpy/environment/tools.py
+++ b/rocketpy/environment/tools.py
@@ -5,14 +5,35 @@
 future to improve their performance and usability.
 """
 
-import bisect
+import logging
+import math
 import warnings
+from datetime import datetime
 
 import netCDF4
 import numpy as np
 
 from rocketpy.tools import bilinear_interpolation
 
+logger = logging.getLogger(__name__)
+
+
+def _to_datetime(date):
+    """Convert netCDF/cftime date-like values to standard datetimes."""
+    if isinstance(date, datetime):
+        return date
+
+    return datetime(
+        date.year,
+        date.month,
+        date.day,
+        date.hour,
+        getattr(date, "minute", 0),
+        getattr(date, "second", 0),
+        getattr(date, "microsecond", 0),
+    )
+
+
 ## Wind data functions
 
 
@@ -109,10 +130,67 @@ def calculate_wind_speed(u, v, w=0.0):
     return np.sqrt(u**2 + v**2 + w**2)
 
 
+def geodesic_to_lambert_conformal(lat, lon, projection_variable, x_units="m"):
+    """Convert geodesic coordinates to Lambert conformal projected coordinates.
+
+    Parameters
+    ----------
+    lat : float
+        Latitude in degrees, ranging from -90 to 90
+    lon : float
+        Longitude in degrees, ranging from -180 to 180.
+    projection_variable : netCDF4.Variable
+        Projection variable containing Lambert conformal metadata.
+    x_units : str, optional
+        Units used by the dataset x coordinate. Supported values are meters
+        and kilometers. Default is "m".
+
+    Returns
+    -------
+    tuple[float, float]
+        Projected coordinates ``(x, y)`` in the same units as ``x_units``.
+    """
+    lat_radians = math.radians(lat)
+    lon_radians = math.radians(lon % 360)
+
+    lat_origin = math.radians(float(projection_variable.latitude_of_projection_origin))
+    lon_origin = math.radians(float(projection_variable.longitude_of_central_meridian))
+
+    standard_parallel = projection_variable.standard_parallel
+    if np.ndim(standard_parallel) == 0:
+        standard_parallels = [float(standard_parallel)]
+    else:
+        standard_parallels = np.asarray(standard_parallel, dtype=float).tolist()
+
+    if len(standard_parallels) >= 2:
+        phi_1 = math.radians(standard_parallels[0])
+        phi_2 = math.radians(standard_parallels[1])
+        n = math.log(math.cos(phi_1) / math.cos(phi_2)) / math.log(
+            math.tan(math.pi / 4 + phi_2 / 2) / math.tan(math.pi / 4 + phi_1 / 2)
+        )
+    else:
+        phi_1 = math.radians(standard_parallels[0])
+        n = math.sin(phi_1)
+
+    earth_radius = float(getattr(projection_variable, "earth_radius", 6371229.0))
+    f_const = (math.cos(phi_1) * math.tan(math.pi / 4 + phi_1 / 2) ** n) / n
+
+    rho = earth_radius * f_const / (math.tan(math.pi / 4 + lat_radians / 2) ** n)
+    rho_origin = earth_radius * f_const / (math.tan(math.pi / 4 + lat_origin / 2) ** n)
+    theta = n * (lon_radians - lon_origin)
+
+    x_meters = rho * math.sin(theta)
+    y_meters = rho_origin - rho * math.cos(theta)
+
+    if str(x_units).lower().startswith("km"):
+        return x_meters / 1000.0, y_meters / 1000.0
+    return x_meters, y_meters
+
+
 ## These functions are meant to be used with netcdf4 datasets
 
 
-def get_pressure_levels_from_file(data, dictionary):
+def get_pressure_levels_from_file(data, dictionary, conversion_factor):
     """Extracts pressure levels from a netCDF4 dataset and converts them to Pa.
 
     Parameters
@@ -121,6 +199,11 @@ def get_pressure_levels_from_file(data, dictionary):
         The netCDF4 dataset containing the pressure level data.
     dictionary : dict
         A dictionary mapping variable names to dataset keys.
+    conversion_factor : float, int, or None
+        Specifies the factor by which the pressure will be multiplied to
+        transform it to Pascal. If ``None``, the factor is auto-detected from
+        the ``units`` attribute of the pressure level variable in the dataset
+        (e.g. ``"millibars"`` or ``"hPa"`` → 100; ``"Pa"`` → 1).
 
     Returns
     -------
@@ -133,8 +216,14 @@ def get_pressure_levels_from_file(data, dictionary):
         If the pressure levels cannot be read from the file.
     """
     try:
-        # Convert mbar to Pa
-        levels = 100 * data.variables[dictionary["level"]][:]
+        level_var = data.variables[dictionary["level"]]
+        if conversion_factor is None:
+            raw_units = getattr(level_var, "units", "").lower().strip()
+            if raw_units in ("hpa", "mbar", "millibars", "hectopascal", "hectopascals"):
+                conversion_factor = 100
+            else:
+                conversion_factor = 1
+        levels = conversion_factor * level_var[:]
     except KeyError as e:
         raise ValueError(
             "Unable to read pressure levels from file. Check file and dictionary."
@@ -168,6 +257,118 @@ def mask_and_clean_dataset(*args):
     return data_array
 
 
+def _normalize_longitude_value(longitude, lon_start, lon_end):
+    """Normalize longitude based on grid format [-180, 180] or [0, 360].
+
+    Parameters
+    ----------
+    longitude : float
+        The longitude to normalize.
+    lon_start : float
+        The first longitude value in the grid.
+    lon_end : float
+        The last longitude value in the grid.
+
+    Returns
+    -------
+    float
+        The normalized longitude value.
+    """
+    # Determine if file uses geographic longitudes in [-180, 180] or [0, 360].
+    # Do not remap projected x coordinates.
+    is_geographic_longitude = abs(lon_start) <= 360 and abs(lon_end) <= 360
+    if is_geographic_longitude:
+        if lon_start < 0 or lon_end < 0:
+            return longitude if longitude < 180 else -180 + longitude % 180
+        return longitude % 360
+    return longitude
+
+
+def _binary_search_coordinate_index(target_value, coord_list, is_ascending):
+    """Find insertion index for target value using binary search.
+
+    Parameters
+    ----------
+    target_value : float
+        The coordinate value to locate.
+    coord_list : list of float
+        The list of coordinate values.
+    is_ascending : bool
+        Whether the coordinate list is in ascending order.
+
+    Returns
+    -------
+    int
+        The insertion index such that coord_list[index-1] and coord_list[index]
+        bracket the target value.
+    """
+    low = 0
+    high = len(coord_list)
+    while low < high:
+        mid = (low + high) // 2
+        mid_value = float(coord_list[mid])
+        if (mid_value < target_value) if is_ascending else (mid_value > target_value):
+            low = mid + 1
+        else:
+            high = mid
+    return low
+
+
+def _adjust_boundary_coordinate_index(index, coord_list, coord_value):
+    """Adjust index for exact matches at grid boundaries.
+
+    Parameters
+    ----------
+    index : int
+        The current index from binary search.
+    coord_list : list of float
+        The list of coordinate values.
+    coord_value : float
+        The coordinate value being matched.
+
+    Returns
+    -------
+    int
+        The adjusted index after boundary handling.
+    """
+    coord_len = len(coord_list)
+    if index == 0 and math.isclose(float(coord_list[0]), coord_value):
+        return 1
+    if index == coord_len and float(coord_list[coord_len - 1]) == coord_value:
+        return index - 1
+    return index
+
+
+def _validate_coordinate_index_in_range(
+    index, coord_len, coord_start, coord_end, coord_name
+):
+    """Validate that coordinate index is within valid interpolation range.
+
+    Parameters
+    ----------
+    index : int
+        The coordinate index to validate.
+    coord_len : int
+        The length of the coordinate list.
+    coord_start : float
+        The first coordinate value in the grid.
+    coord_end : float
+        The last coordinate value in the grid.
+    coord_name : str
+        The name of the coordinate (e.g., "Longitude", "Latitude").
+
+    Raises
+    ------
+    ValueError
+        If the index is out of valid range (0 or coord_len).
+    """
+    if index in (0, coord_len):
+        raise ValueError(
+            f"{coord_name} not inside region covered by file, which is "
+            f"from {coord_start} to {coord_end}."
+        )
+
+
 def find_longitude_index(longitude, lon_list):
     """Finds the index of the given longitude in a list of longitudes.
 
@@ -188,31 +389,19 @@ def find_longitude_index(longitude, lon_list):
     ValueError
         If the longitude is not within the range covered by the list.
     """
-    # Determine if file uses -180 to 180 or 0 to 360
-    if lon_list[0] < 0 or lon_list[-1] < 0:
-        # Convert input to -180 - 180
-        lon = longitude if longitude < 180 else -180 + longitude % 180
-    else:
-        # Convert input to 0 - 360
-        lon = longitude % 360
-    # Check if reversed or sorted
-    if lon_list[0] < lon_list[-1]:
-        # Deal with sorted lon_list
-        lon_index = bisect.bisect(lon_list, lon)
-    else:
-        # Deal with reversed lon_list
-        lon_list.reverse()
-        lon_index = len(lon_list) - bisect.bisect_left(lon_list, lon)
-        lon_list.reverse()
-    # Take care of longitude value equal to maximum longitude in the grid
-    if lon_index == len(lon_list) and lon_list[lon_index - 1] == lon:
-        lon_index = lon_index - 1
-    # Check if longitude value is inside the grid
-    if lon_index == 0 or lon_index == len(lon_list):
-        raise ValueError(
-            f"Longitude {lon} not inside region covered by file, which is "
-            f"from {lon_list[0]} to {lon_list[-1]}."
-        )
+    lon_len = len(lon_list)
+    lon_start = float(lon_list[0])
+    lon_end = float(lon_list[lon_len - 1])
+
+    lon = _normalize_longitude_value(longitude, lon_start, lon_end)
+    is_ascending = lon_start < lon_end
+
+    lon_index = _binary_search_coordinate_index(lon, lon_list, is_ascending)
+    lon_index = _adjust_boundary_coordinate_index(lon_index, lon_list, lon)
+
+    _validate_coordinate_index_in_range(
+        lon_index, lon_len, lon_start, lon_end, "Longitude"
+    )
 
     return lon, lon_index
 
@@ -237,28 +426,22 @@ def find_latitude_index(latitude, lat_list):
     ValueError
         If the latitude is not within the range covered by the list.
     """
-    # Check if reversed or sorted
-    if lat_list[0] < lat_list[-1]:
-        # Deal with sorted lat_list
-        lat_index = bisect.bisect(lat_list, latitude)
-    else:
-        # Deal with reversed lat_list
-        lat_list.reverse()
-        lat_index = len(lat_list) - bisect.bisect_left(lat_list, latitude)
-        lat_list.reverse()
-    # Take care of latitude value equal to maximum longitude in the grid
-    if lat_index == len(lat_list) and lat_list[lat_index - 1] == latitude:
-        lat_index = lat_index - 1
-    # Check if latitude value is inside the grid
-    if lat_index == 0 or lat_index == len(lat_list):
-        raise ValueError(
-            f"Latitude {latitude} not inside region covered by file, "
-            f"which is from {lat_list[0]} to {lat_list[-1]}."
-        )
+    lat_len = len(lat_list)
+    lat_start = float(lat_list[0])
+    lat_end = float(lat_list[lat_len - 1])
+    is_ascending = lat_start < lat_end
+
+    lat_index = _binary_search_coordinate_index(latitude, lat_list, is_ascending)
+    lat_index = _adjust_boundary_coordinate_index(lat_index, lat_list, latitude)
+
+    _validate_coordinate_index_in_range(
+        lat_index, lat_len, lat_start, lat_end, "Latitude"
+    )
+
     return latitude, lat_index
 
 
-def find_time_index(datetime_date, time_array):
+def find_time_index(datetime_date, time_array):  # pylint: disable=too-many-statements
     """Finds the index of the given datetime in a netCDF4 time array.
 
     Parameters
@@ -280,26 +463,58 @@ def find_time_index(datetime_date, time_array):
     ValueError
         If the exact datetime is not available and the nearest datetime is used instead.
     """
-    time_index = netCDF4.date2index(
-        datetime_date, time_array, calendar="gregorian", select="nearest"
-    )
-    # Convert times do dates and numbers
-    input_time_num = netCDF4.date2num(
-        datetime_date, time_array.units, calendar="gregorian"
-    )
-    file_time_num = time_array[time_index]
-    file_time_date = netCDF4.num2date(
-        time_array[time_index], time_array.units, calendar="gregorian"
-    )
+    time_len = len(time_array)
+    time_units = time_array.units
+    input_time_num = netCDF4.date2num(datetime_date, time_units, calendar="gregorian")
+
+    first_time_num = float(time_array[0])
+    last_time_num = float(time_array[time_len - 1])
+    is_ascending = first_time_num <= last_time_num
+
+    # Binary search nearest index using scalar probing only.
+    low = 0
+    high = time_len
+    while low < high:
+        mid = (low + high) // 2
+        mid_time_num = float(time_array[mid])
+        if (
+            (mid_time_num < input_time_num)
+            if is_ascending
+            else (mid_time_num > input_time_num)
+        ):
+            low = mid + 1
+        else:
+            high = mid
+
+    right_index = min(max(low, 0), time_len - 1)
+    left_index = min(max(right_index - 1, 0), time_len - 1)
+
+    right_time_num = float(time_array[right_index])
+    left_time_num = float(time_array[left_index])
+    if abs(input_time_num - left_time_num) <= abs(right_time_num - input_time_num):
+        time_index = left_index
+        file_time_num = left_time_num
+    else:
+        time_index = right_index
+        file_time_num = right_time_num
+
+    file_time_date = netCDF4.num2date(file_time_num, time_units, calendar="gregorian")
+
     # Check if time is inside range supplied by file
-    if time_index == 0 and input_time_num < file_time_num:
+    if time_index == 0 and (
+        (is_ascending and input_time_num < file_time_num)
+        or (not is_ascending and input_time_num > file_time_num)
+    ):
         raise ValueError(
             f"The chosen launch time '{datetime_date.strftime('%Y-%m-%d-%H:')} UTC' is"
             " not available in the provided file. Please choose a time within the range"
             " of the file, which starts at "
             f"'{file_time_date.strftime('%Y-%m-%d-%H')} UTC'."
         )
-    elif time_index == len(time_array) - 1 and input_time_num > file_time_num:
+    elif time_index == time_len - 1 and (
+        (is_ascending and input_time_num > file_time_num)
+        or (not is_ascending and input_time_num < file_time_num)
+    ):
         raise ValueError(
             "Chosen launch time is not available in the provided file, "
             f"which ends at {file_time_date}."
@@ -393,7 +608,7 @@ def get_initial_date_from_time_array(time_array, units=None):
         A datetime object representing the first time in the time array.
     """
     units = units or time_array.units
-    return netCDF4.num2date(time_array[0], units, calendar="gregorian")
+    return _to_datetime(netCDF4.num2date(time_array[0], units, calendar="gregorian"))
 
 
 def get_final_date_from_time_array(time_array, units=None):
@@ -412,7 +627,7 @@ def get_final_date_from_time_array(time_array, units=None):
         A datetime object representing the last time in the time array.
     """
     units = units if units is not None else time_array.units
-    return netCDF4.num2date(time_array[-1], units, calendar="gregorian")
+    return _to_datetime(netCDF4.num2date(time_array[-1], units, calendar="gregorian"))
 
 
 def get_interval_date_from_time_array(time_array, units=None):
@@ -656,6 +871,6 @@ def utm_to_geodesic(  # pylint: disable=too-many-locals,too-many-statements
 
     results = doctest.testmod()
     if results.failed < 1:
-        print(f"All the {results.attempted} tests passed!")
+        logger.info("All the %d tests passed!", results.attempted)
     else:
-        print(f"{results.failed} out of {results.attempted} tests failed.")
+        logger.error("%d out of %d tests failed.", results.failed, results.attempted)
diff --git a/rocketpy/environment/weather_model_mapping.py b/rocketpy/environment/weather_model_mapping.py
index 75089f577..c8617a523 100644
--- a/rocketpy/environment/weather_model_mapping.py
+++ b/rocketpy/environment/weather_model_mapping.py
@@ -1,9 +1,42 @@
 class WeatherModelMapping:
-    """Class to map the weather model variables to the variables used in the
-    Environment class.
+    """Map provider-specific variable names to RocketPy weather fields.
+
+    RocketPy reads forecast/reanalysis/ensemble datasets using canonical keys
+    such as ``time``, ``latitude``, ``longitude``, ``level``, ``temperature``,
+    ``geopotential_height``, ``geopotential``, ``u_wind`` and ``v_wind``.
+    Each dictionary in this class maps those canonical keys to the actual
+    variable names in a specific data provider format.
+
+    Mapping families
+    ----------------
+    - Base names (for example ``GFS``, ``NAM``, ``RAP``) represent the current
+      default mappings used by the latest-model shortcuts and THREDDS-style
+      datasets.
+    - ``*_LEGACY`` names represent older NOMADS-style variable naming
+      conventions (for example ``lev``, ``tmpprs``, ``ugrdprs`` and
+      ``vgrdprs``) and are intended for archived or previously downloaded files.
+
+    Notes
+    -----
+    - Mappings can also include optional keys such as ``projection`` for
+      projected grids and ``ensemble`` for member dimensions.
+    - The :meth:`get` method is case-insensitive, so ``"gfs_legacy"`` and
+      ``"GFS_LEGACY"`` are equivalent.
     """
 
     GFS = {
+        "time": "time",
+        "latitude": "lat",
+        "longitude": "lon",
+        "level": "isobaric",
+        "temperature": "Temperature_isobaric",
+        "surface_geopotential_height": "Geopotential_height_surface",
+        "geopotential_height": "Geopotential_height_isobaric",
+        "geopotential": None,
+        "u_wind": "u-component_of_wind_isobaric",
+        "v_wind": "v-component_of_wind_isobaric",
+    }
+    GFS_LEGACY = {
         "time": "time",
         "latitude": "lat",
         "longitude": "lon",
@@ -15,7 +48,20 @@ class WeatherModelMapping:
         "u_wind": "ugrdprs",
         "v_wind": "vgrdprs",
     }
-    NAM = {
+    AIGFS = {
+        "time": "time",
+        "latitude": "lat",
+        "longitude": "lon",
+        "projection": "LatLon_Projection",
+        "level": "isobaric",
+        "temperature": "Temperature_isobaric",
+        "surface_geopotential_height": None,
+        "geopotential_height": "Geopotential_height_isobaric",
+        "geopotential": None,
+        "u_wind": "u-component_of_wind_isobaric",
+        "v_wind": "v-component_of_wind_isobaric",
+    }
+    NAM_LEGACY = {
         "time": "time",
         "latitude": "lat",
         "longitude": "lon",
@@ -27,6 +73,19 @@ class WeatherModelMapping:
         "u_wind": "ugrdprs",
         "v_wind": "vgrdprs",
     }
+    NAM = {
+        "time": "time",
+        "latitude": "y",
+        "longitude": "x",
+        "projection": "LambertConformal_Projection",
+        "level": "isobaric",
+        "temperature": "Temperature_isobaric",
+        "surface_geopotential_height": None,
+        "geopotential_height": "Geopotential_height_isobaric",
+        "geopotential": None,
+        "u_wind": "u-component_of_wind_isobaric",
+        "v_wind": "v-component_of_wind_isobaric",
+    }
     ECMWF_v0 = {
         "time": "time",
         "latitude": "latitude",
@@ -54,6 +113,18 @@ class WeatherModelMapping:
         "v_wind": "v",
     }
     NOAA = {
+        "time": "time",
+        "latitude": "lat",
+        "longitude": "lon",
+        "level": "isobaric",
+        "temperature": "Temperature_isobaric",
+        "surface_geopotential_height": "Geopotential_height_surface",
+        "geopotential_height": "Geopotential_height_isobaric",
+        "geopotential": None,
+        "u_wind": "u-component_of_wind_isobaric",
+        "v_wind": "v-component_of_wind_isobaric",
+    }
+    NOAA_LEGACY = {
         "time": "time",
         "latitude": "lat",
         "longitude": "lon",
@@ -66,6 +137,19 @@ class WeatherModelMapping:
         "v_wind": "vgrdprs",
     }
     RAP = {
+        "time": "time",
+        "latitude": "y",
+        "longitude": "x",
+        "projection": "LambertConformal_Projection",
+        "level": "isobaric",
+        "temperature": "Temperature_isobaric",
+        "surface_geopotential_height": None,
+        "geopotential_height": "Geopotential_height_isobaric",
+        "geopotential": None,
+        "u_wind": "u-component_of_wind_isobaric",
+        "v_wind": "v-component_of_wind_isobaric",
+    }
+    RAP_LEGACY = {
         "time": "time",
         "latitude": "lat",
         "longitude": "lon",
@@ -77,6 +161,19 @@ class WeatherModelMapping:
         "u_wind": "ugrdprs",
         "v_wind": "vgrdprs",
     }
+    HRRR = {
+        "time": "time",
+        "latitude": "y",
+        "longitude": "x",
+        "projection": "LambertConformal_Projection",
+        "level": "isobaric",
+        "temperature": "Temperature_isobaric",
+        "surface_geopotential_height": None,
+        "geopotential_height": "Geopotential_height_isobaric",
+        "geopotential": None,
+        "u_wind": "u-component_of_wind_isobaric",
+        "v_wind": "v-component_of_wind_isobaric",
+    }
     CMC = {
         "time": "time",
         "latitude": "lat",
@@ -103,6 +200,19 @@ class WeatherModelMapping:
         "u_wind": "ugrdprs",
         "v_wind": "vgrdprs",
     }
+    GEFS_LEGACY = {
+        "time": "time",
+        "latitude": "lat",
+        "longitude": "lon",
+        "level": "lev",
+        "ensemble": "ens",
+        "temperature": "tmpprs",
+        "surface_geopotential_height": None,
+        "geopotential_height": "hgtprs",
+        "geopotential": None,
+        "u_wind": "ugrdprs",
+        "v_wind": "vgrdprs",
+    }
     HIRESW = {
         "time": "time",
         "latitude": "lat",
@@ -129,27 +239,62 @@ class WeatherModelMapping:
     }
 
     def __init__(self):
-        """Initialize the class, creates a dictionary with all the weather models
-        available and their respective dictionaries with the variables."""
+        """Build the lookup table with default and legacy mapping aliases."""
 
         self.all_dictionaries = {
             "GFS": self.GFS,
+            "GFS_LEGACY": self.GFS_LEGACY,
+            "AIGFS": self.AIGFS,
             "NAM": self.NAM,
+            "NAM_LEGACY": self.NAM_LEGACY,
             "ECMWF_v0": self.ECMWF_v0,
             "ECMWF": self.ECMWF,
             "NOAA": self.NOAA,
+            "NOAA_LEGACY": self.NOAA_LEGACY,
             "RAP": self.RAP,
+            "RAP_LEGACY": self.RAP_LEGACY,
+            "HRRR": self.HRRR,
             "CMC": self.CMC,
             "GEFS": self.GEFS,
+            "GEFS_LEGACY": self.GEFS_LEGACY,
             "HIRESW": self.HIRESW,
             "MERRA2": self.MERRA2,
         }
 
     def get(self, model):
+        """Return a mapping dictionary by model alias (case-insensitive).
+
+        Parameters
+        ----------
+        model : str
+            Mapping alias name, such as ``"GFS"`` or ``"GFS_LEGACY"``.
+
+        Returns
+        -------
+        dict
+            Dictionary mapping RocketPy canonical weather keys to dataset
+            variable names.
+
+        Raises
+        ------
+        KeyError
+            If ``model`` is unknown or not a string.
+        """
+        if not isinstance(model, str):
+            raise KeyError(
+                f"Model {model} not found in the WeatherModelMapping. "
+                f"The available models are: {self.all_dictionaries.keys()}"
+            )
+
         try:
             return self.all_dictionaries[model]
-        except KeyError as e:
+        except KeyError as exc:
+            model_casefold = model.casefold()
+            for key, value in self.all_dictionaries.items():
+                if key.casefold() == model_casefold:
+                    return value
+
             raise KeyError(
                 f"Model {model} not found in the WeatherModelMapping. "
                 f"The available models are: {self.all_dictionaries.keys()}"
-            ) from e
+            ) from exc
diff --git a/rocketpy/exceptions.py b/rocketpy/exceptions.py
new file mode 100644
index 000000000..e6bdc4355
--- /dev/null
+++ b/rocketpy/exceptions.py
@@ -0,0 +1,30 @@
+"""Custom exceptions and warnings for RocketPy."""
+
+# TODO: progressively adopt these custom exceptions across the codebase.
+# Many modules still ``raise ValueError``/``TypeError`` directly; migrate those
+# to the appropriate ``RocketPyError`` subclass (adding new exception types here
+# as needed) so users can reliably catch ``RocketPyError`` and its subclasses.
+
+
+class RocketPyError(Exception):
+    """Base class for all RocketPy exceptions."""
+
+
+class InvalidParameterError(RocketPyError, ValueError):
+    """Raised when a constructor parameter has an invalid value (e.g. negative
+    radius or mass)."""
+
+
+class InvalidInertiaError(RocketPyError, ValueError):
+    """Raised when the inertia tuple/list does not have the expected length."""
+
+
+class UnstableRocketWarning(UserWarning):
+    """Issued when the rocket's static margin is negative at motor ignition,
+    indicating an aerodynamically unstable configuration.
+
+    Not issued when the rocket has any ``GenericSurface`` aerodynamic
+    surfaces, since their lift coefficient derivative is not accounted for
+    in the center of pressure calculation, making the static margin
+    unreliable for this check in that case.
+    """
diff --git a/rocketpy/mathutils/_calc/__init__.py b/rocketpy/mathutils/_calc/__init__.py
new file mode 100644
index 000000000..76821d3d7
--- /dev/null
+++ b/rocketpy/mathutils/_calc/__init__.py
@@ -0,0 +1,12 @@
+"""Interpolation and extrapolation submodule for the Function class.
+
+This package provides factory functions that build strategy objects for
+interpolation and extrapolation.
+
+The registry helper :func:`build_interpolation_evaluator` is the main
+entry point consumed by :class:`rocketpy.mathutils.function.Function`.
+"""
+
+from .registry import build_interpolation_evaluator
+
+__all__ = ["build_interpolation_evaluator"]
diff --git a/rocketpy/mathutils/_calc/_fitting.py b/rocketpy/mathutils/_calc/_fitting.py
new file mode 100644
index 000000000..21eb1e75c
--- /dev/null
+++ b/rocketpy/mathutils/_calc/_fitting.py
@@ -0,0 +1,265 @@
+"""Coefficient fitting routines for 1-D interpolation methods.
+
+Every public function in this module takes sorted ``x`` and ``y`` arrays and
+returns a data structure (NumPy array / tuple) that the corresponding
+evaluation factory can close over.
+"""
+
+import warnings
+
+import numpy as np
+from scipy import linalg
+
+
+def fit_polynomial(x, y):
+    """Fit a single polynomial of degree ``len(x) - 1`` through all points.
+
+    Parameters
+    ----------
+    x : ndarray, shape (n,)
+    y : ndarray, shape (n,)
+
+    Returns
+    -------
+    coeffs : ndarray, shape (n,)
+        Coefficients in *ascending* power order: ``c[0] + c[1]*x + …``.
+    """
+    degree = len(x) - 1
+    if np.amax(np.abs(x)) ** degree > 1e308:
+        warnings.warn(
+            "Polynomial interpolation of too many points can't be done. "
+            "Once the degree is too high, numbers get too large. "
+            "Falling back to spline coefficients.",
+            stacklevel=3,
+        )
+        return None  # caller should fall back to spline
+    V = np.vander(x, increasing=True)
+    return np.linalg.solve(V, y)
+
+
+def fit_spline(x, y):
+    r"""Fit a natural cubic spline in local coordinates.
+
+    Returns coefficients ``[a, b, c, d]`` for each interval stored as a
+    ``(4, n-1)`` array, where the polynomial for segment *i* is
+
+    .. math::
+        p_i(t) = a_i + b_i t + c_i t^2 + d_i t^3, \quad t = x - x_i
+
+    Parameters
+    ----------
+    x : ndarray, shape (n,)
+    y : ndarray, shape (n,)
+
+    Returns
+    -------
+    coeffs : ndarray, shape (4, n-1)
+        Rows are ``[a, b, c, d]``.
+    """
+    n = len(x)
+    h = np.diff(x)
+
+    # Build tridiagonal system for the c coefficients (natural BC: c[0]=c[-1]=0)
+    banded = np.zeros((3, n))
+    banded[1, 0] = banded[1, -1] = 1.0
+    banded[2, :-2] = h[:-1]
+    banded[1, 1:-1] = 2.0 * (h[:-1] + h[1:])
+    banded[0, 2:] = h[1:]
+
+    rhs = np.zeros(n)
+    rhs[1:-1] = 3.0 * ((y[2:] - y[1:-1]) / h[1:] - (y[1:-1] - y[:-2]) / h[:-1])
+
+    c = linalg.solve_banded((1, 1), banded, rhs, overwrite_ab=True, overwrite_b=True)
+
+    b = (y[1:] - y[:-1]) / h - h * (2.0 * c[:-1] + c[1:]) / 3.0
+    d = (c[1:] - c[:-1]) / (3.0 * h)
+
+    return np.vstack([y[:-1], b, c[:-1], d])
+
+
+def fit_akima(x, y):
+    r"""Fit an Akima spline in local coordinates.
+
+    The slopes at each knot are computed using the original Akima weighting
+    of adjacent finite differences, extended at the boundaries by reflected
+    values.  This is fully vectorised — no Python loop.
+
+    Returns coefficients ``[a, b, c, d]`` for each interval in a ``(4, n-1)``
+    array identical in layout to :func:`fit_spline`.
+
+    Parameters
+    ----------
+    x : ndarray, shape (n,)
+    y : ndarray, shape (n,)
+
+    Returns
+    -------
+    coeffs : ndarray, shape (4, n-1)
+    """
+    n = len(x)
+    h = np.diff(x)
+    m = np.diff(y) / h  # slopes of segments, shape (n-1,)
+
+    # Extend m with 2 ghost values on each side (Akima boundary reflection)
+    # m_ext indices: 0..n+2  (n-1 interior + 2 left + 2 right)
+    m_ext = np.empty(n + 3)
+    m_ext[2:-2] = m
+    m_ext[1] = 2.0 * m[0] - m[1]
+    m_ext[0] = 2.0 * m_ext[1] - m[0]
+    m_ext[-2] = 2.0 * m[-1] - m[-2]
+    m_ext[-1] = 2.0 * m_ext[-2] - m[-1]
+
+    # Weights: |m_{i+1} - m_i|
+    dm = np.abs(np.diff(m_ext))  # shape (n+2,)
+
+    # Akima slope at each knot: weighted average of adjacent segment slopes.
+    # w1 = |m_{i+1} - m_i|, w2 = |m_{i-1} - m_{i-2}|
+    # t_i = (w1 * m_{i-1} + w2 * m_i) / (w1 + w2)
+    # When w1 + w2 == 0, fall back to simple average.
+    w1 = dm[2:]  # |m_{i+1} - m_i|  for i in 0..n-1
+    w2 = dm[:-2]  # |m_{i-1} - m_{i-2}| for i in 0..n-1
+    m_left = m_ext[1:-2]  # m_{i-1}
+    m_right = m_ext[2:-1]  # m_i
+
+    wsum = w1 + w2
+    t = 0.5 * (m_left + m_right)
+    np.divide(
+        w1 * m_left + w2 * m_right,
+        wsum,
+        out=t,
+        where=wsum > 0,
+    )
+
+    # Hermite-to-local coefficients:
+    #   a = y_i
+    #   b = t_i
+    #   c = (3*m_i - 2*t_i - t_{i+1}) / h_i
+    #   d = (t_i + t_{i+1} - 2*m_i) / h_i^2
+    a = y[:-1]
+    b = t[:-1]
+    c_coeff = (3.0 * m - 2.0 * t[:-1] - t[1:]) / h
+    d_coeff = (t[:-1] + t[1:] - 2.0 * m) / (h * h)
+
+    return np.vstack([a, b, c_coeff, d_coeff])
+
+
+def fit_pchip(x, y):
+    r"""Fit a PCHIP (Piecewise Cubic Hermite Interpolating Polynomial).
+
+    Uses the Fritsch–Carlson method to compute monotone-preserving slopes
+    and converts them to local-coordinate cubic coefficients identical in
+    layout to :func:`fit_spline`.
+
+    Parameters
+    ----------
+    x : ndarray, shape (n,)
+    y : ndarray, shape (n,)
+
+    Returns
+    -------
+    coeffs : ndarray, shape (4, n-1)
+    """
+    n = len(x)
+    h = np.diff(x)
+    m = np.diff(y) / h
+
+    # Compute slopes at each knot
+    t = np.zeros(n)
+
+    # Interior knots
+    if n > 2:
+        # Harmonic mean weighted by segment lengths
+        w1 = 2.0 * h[1:] + h[:-1]
+        w2 = h[1:] + 2.0 * h[:-1]
+
+        # Where signs differ or either is zero, slope is zero (monotonicity)
+        sign_change = (m[:-1] * m[1:]) <= 0
+        pos_mask = ~sign_change
+
+        # Safe harmonic mean
+        t[1:-1] = np.where(
+            pos_mask,
+            (w1 + w2) / (w1 / m[:-1] + w2 / m[1:]),
+            0.0,
+        )
+
+    # Boundary slopes: one-sided, clamped for monotonicity
+    t[0] = _pchip_edge_slope(
+        h[0], h[1] if n > 2 else h[0], m[0], m[1] if n > 2 else m[0]
+    )
+    t[-1] = _pchip_edge_slope(
+        h[-1], h[-2] if n > 2 else h[-1], m[-1], m[-2] if n > 2 else m[-1]
+    )
+
+    # Convert to local Hermite coefficients (same as akima)
+    a = y[:-1]
+    b = t[:-1]
+    c_coeff = (3.0 * m - 2.0 * t[:-1] - t[1:]) / h
+    d_coeff = (t[:-1] + t[1:] - 2.0 * m) / (h * h)
+
+    return np.vstack([a, b, c_coeff, d_coeff])
+
+
+def _pchip_edge_slope(h0, h1, m0, m1):
+    """Compute boundary slope for PCHIP (non-centred 3-point formula,
+    clamped to preserve monotonicity)."""
+    t = ((2.0 * h0 + h1) * m0 - h0 * m1) / (h0 + h1)
+    if np.sign(t) != np.sign(m0):
+        t = 0.0
+    elif np.sign(m0) != np.sign(m1) and np.abs(t) > 3.0 * np.abs(m0):
+        t = 3.0 * m0
+    return t
+
+
+def precompute_linear_deriv_integral(x, y):
+    """Pre-compute data for analytical linear derivative and integral.
+
+    Parameters
+    ----------
+    x : ndarray, shape (n,)
+    y : ndarray, shape (n,)
+
+    Returns
+    -------
+    slopes : ndarray, shape (n-1,)
+        Piecewise-constant derivative values.
+    cum_integrals : ndarray, shape (n,)
+        Cumulative trapezoid integral from x[0] to x[i].
+    """
+    h = np.diff(x)
+    slopes = np.diff(y) / h
+    # Cumulative trapezoid: area of each trapezoid is 0.5*(y[i]+y[i+1])*h[i]
+    trap_areas = 0.5 * (y[:-1] + y[1:]) * h
+    cum_integrals = np.empty(len(x))
+    cum_integrals[0] = 0.0
+    np.cumsum(trap_areas, out=cum_integrals[1:])
+    return slopes, cum_integrals
+
+
+def precompute_cubic_cumulative_integrals(x, coeffs):
+    """Pre-compute cumulative integrals over full intervals for cubic methods.
+
+    For piece *i* with local polynomial ``a + b*t + c*t² + d*t³``,
+    the integral over the full interval ``[0, h_i]`` is
+    ``a*h + b*h²/2 + c*h³/3 + d*h⁴/4``.
+
+    Parameters
+    ----------
+    x : ndarray, shape (n,)
+    coeffs : ndarray, shape (4, n-1)
+
+    Returns
+    -------
+    cum : ndarray, shape (n,)
+        ``cum[0] = 0``; ``cum[i] = ∫_{x[0]}^{x[i]} p(t) dt``.
+    """
+    h = np.diff(x)
+    a, b, c, d = coeffs
+    h2 = h * h
+    h3 = h2 * h
+    h4 = h3 * h
+    piece_integrals = a * h + b * h2 / 2.0 + c * h3 / 3.0 + d * h4 / 4.0
+    cum = np.empty(len(x))
+    cum[0] = 0.0
+    np.cumsum(piece_integrals, out=cum[1:])
+    return cum
diff --git a/rocketpy/mathutils/_calc/evaluator.py b/rocketpy/mathutils/_calc/evaluator.py
new file mode 100644
index 000000000..fdf46f753
--- /dev/null
+++ b/rocketpy/mathutils/_calc/evaluator.py
@@ -0,0 +1,473 @@
+"""Routers that choose interpolation or extrapolation strategies."""
+
+from __future__ import annotations
+
+import numpy as np
+
+from rocketpy.mathutils._calc.polation_base import PolationBase
+
+_COMPLEX_ND_ERROR = (
+    "Complex coordinates are not supported for N-D array-based Function interpolation."
+)
+
+
+class PolationEvaluator1D(PolationBase):
+    """Route 1-D evaluation to interpolation or extrapolation.
+
+    Complex queries are propagated only to support complex-step
+    differentiation of a real 1-D sampled Function. Routing uses the real
+    component so the imaginary perturbation cannot change the selected real
+    interval. This does not define general interpolation over the complex
+    plane.
+    """
+
+    __slots__ = (
+        "_interpolator",
+        "_extrapolator",
+        "_x_min",
+        "_x_max",
+        "_exposed_fn",
+        "_scalar_fn",
+        "_vector_fn",
+    )
+
+    def __init__(self, interpolator, extrapolator, x):
+        self._interpolator = interpolator
+        self._extrapolator = extrapolator
+        self._x_min = float(x[0])
+        self._x_max = float(x[-1])
+
+        # Expose the evaluator to shorten the call stack
+        self._scalar_fn = self.expose_scalar()
+        self._vector_fn = self.expose_vector()
+        self._exposed_fn = self.expose()
+
+    def evaluate(self, x, _is_iterable=None):
+        return self._exposed_fn(x, _is_iterable=_is_iterable)
+
+    def expose(self):
+        """Flattens the evaluator into a fast closure for the
+        simulation loop.
+        """
+        if hasattr(self, "_exposed_fn"):
+            return self._exposed_fn
+
+        scalar_eval = self._scalar_fn
+        vector_eval = self._vector_fn
+
+        def _eval(x, _is_iterable=None):
+            if _is_iterable is None:
+                _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+
+            if not _is_iterable:
+                return scalar_eval(x)
+            return vector_eval(x)
+
+        return _eval
+
+    def expose_scalar(self):
+        """Expose a scalar-only evaluator with no iterable checks."""
+        if hasattr(self, "_scalar_fn"):
+            return self._scalar_fn
+
+        x_min = self._x_min
+        x_max = self._x_max
+        interp_eval = self._interpolator.evaluate
+        extrap_eval = self._extrapolator.evaluate
+
+        if self._interpolator is self._extrapolator:
+
+            def _scalar_same(x):
+                return interp_eval(x, _is_iterable=False)
+
+            return _scalar_same
+
+        def _scalar(x):
+            if x_min <= x.real <= x_max:
+                return interp_eval(x, _is_iterable=False)
+            return extrap_eval(x, _is_iterable=False)
+
+        return _scalar
+
+    def expose_vector(self):
+        """Expose a vector-only evaluator with no iterable checks."""
+        if hasattr(self, "_vector_fn"):
+            return self._vector_fn
+
+        x_min = self._x_min
+        x_max = self._x_max
+        interp_eval = self._interpolator.evaluate
+        extrap_eval = self._extrapolator.evaluate
+
+        if self._interpolator is self._extrapolator:
+
+            def _vector_same(x):
+                return interp_eval(x, _is_iterable=True)
+
+            return _vector_same
+
+        def _vector(x):
+            out_dtype = complex if np.iscomplexobj(x) else float
+            result = np.empty_like(x, dtype=out_dtype)
+            x_real = x.real
+            inside = (x_real >= x_min) & (x_real <= x_max)
+            outside = ~inside
+            if inside.any():
+                result[inside] = interp_eval(x[inside], _is_iterable=True)
+            if outside.any():
+                result[outside] = extrap_eval(x[outside], _is_iterable=True)
+            return result
+
+        return _vector
+
+    def coefficients(self):
+        return self._interpolator.coefficients()
+
+    def derivative(self, x, _is_iterable=None):
+        """Route 1st derivative to interpolation or extrapolation."""
+        interp_deriv = self._interpolator.derivative
+        extrap_deriv = self._extrapolator.derivative
+
+        if self._interpolator is self._extrapolator:
+            return interp_deriv(x, _is_iterable=_is_iterable)
+
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+
+        if not _is_iterable:
+            if self._x_min <= x.real <= self._x_max:
+                return interp_deriv(x, _is_iterable=False)
+            return extrap_deriv(x, _is_iterable=False)
+        else:
+            out_dtype = complex if np.iscomplexobj(x) else float
+            result = np.empty_like(x, dtype=out_dtype)
+            x_real = x.real
+            inside = (x_real >= self._x_min) & (x_real <= self._x_max)
+            outside = ~inside
+            if inside.any():
+                result[inside] = interp_deriv(x[inside], _is_iterable=True)
+            if outside.any():
+                result[outside] = extrap_deriv(x[outside], _is_iterable=True)
+            return result
+
+    def second_derivative(self, x, _is_iterable=None):
+        """Route 2nd derivative to interpolation or extrapolation."""
+        interp_deriv2 = self._interpolator.second_derivative
+        extrap_deriv2 = self._extrapolator.second_derivative
+
+        if self._interpolator is self._extrapolator:
+            return interp_deriv2(x, _is_iterable=_is_iterable)
+
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+
+        if not _is_iterable:
+            if self._x_min <= x.real <= self._x_max:
+                return interp_deriv2(x, _is_iterable=False)
+            return extrap_deriv2(x, _is_iterable=False)
+        else:
+            out_dtype = complex if np.iscomplexobj(x) else float
+            result = np.empty_like(x, dtype=out_dtype)
+            x_real = x.real
+            inside = (x_real >= self._x_min) & (x_real <= self._x_max)
+            outside = ~inside
+            if inside.any():
+                result[inside] = interp_deriv2(x[inside], _is_iterable=True)
+            if outside.any():
+                result[outside] = extrap_deriv2(x[outside], _is_iterable=True)
+            return result
+
+    def integral(self, x, _is_iterable=None):
+        """Calculates the continuous antiderivative F(x) anchored at x_min.
+        Fully vectorized.
+        """
+        if self._interpolator is self._extrapolator:
+            return self._interpolator.integral(x, _is_iterable=_is_iterable)
+
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+
+        if not _is_iterable:
+            x_val = float(x)
+            if x_val < self._x_min:
+                return -self._extrapolator.definite_integral(x_val, self._x_min)
+            elif x_val <= self._x_max:
+                return self._interpolator.definite_integral(self._x_min, x_val)
+            else:
+                base_area = self._interpolator.definite_integral(
+                    self._x_min, self._x_max
+                )
+                return base_area + self._extrapolator.definite_integral(
+                    self._x_max, x_val
+                )
+
+        x_arr = np.asarray(x, dtype=float)
+        result = np.zeros_like(x_arr)
+
+        # Precalculate the total area of the core domain
+        base_area = self._interpolator.definite_integral(self._x_min, self._x_max)
+
+        # 1. Left of domain (Negative area)
+        lower = x_arr < self._x_min
+        if lower.any():
+            result[lower] = -self._extrapolator.definite_integral(
+                x_arr[lower], self._x_min
+            )
+
+        # 2. Inside domain
+        inside = (x_arr >= self._x_min) & (x_arr <= self._x_max)
+        if inside.any():
+            result[inside] = self._interpolator.definite_integral(
+                self._x_min, x_arr[inside]
+            )
+
+        # 3. Right of domain (Core area + new area)
+        upper = x_arr > self._x_max
+        if upper.any():
+            result[upper] = base_area + self._extrapolator.definite_integral(
+                self._x_max, x_arr[upper]
+            )
+
+        return result
+
+
+class PolationEvaluatorND(PolationBase):
+    """Route ND evaluation based on bounding box and NaN detection."""
+
+    __slots__ = (
+        "_interpolator",
+        "_extrapolator",
+        "_min_domain",
+        "_max_domain",
+        "_extrapolation_mask",
+        "_exposed_fn",
+        "_scalar_fn",
+        "_vector_fn",
+    )
+
+    def __init__(self, interpolator, extrapolator, domain):
+        self._interpolator = interpolator
+        self._extrapolator = extrapolator
+        domain = np.asarray(domain, dtype=float)
+        self._min_domain = np.min(domain, axis=0)
+        self._max_domain = np.max(domain, axis=0)
+        self._extrapolation_mask = getattr(interpolator, "extrapolation_mask", None)
+
+        self._scalar_fn = self.expose_scalar()
+        self._vector_fn = self.expose_vector()
+        self._exposed_fn = self.expose()
+
+    def evaluate(self, *args, _is_iterable=None):
+        return self._exposed_fn(*args, _is_iterable=_is_iterable)
+
+    def expose(self):
+        if hasattr(self, "_exposed_fn"):
+            return self._exposed_fn
+
+        scalar_eval = self._scalar_fn
+        vector_eval = self._vector_fn
+
+        def _eval(*args, _is_iterable=None):
+            if _is_iterable is None:
+                _is_iterable = any(
+                    hasattr(arg, "__iter__") and np.ndim(arg) > 0 for arg in args
+                )
+            if not _is_iterable:
+                return scalar_eval(*args)
+            return vector_eval(*args)
+
+        return _eval
+
+    def expose_scalar(self):
+        if hasattr(self, "_scalar_fn"):
+            return self._scalar_fn
+
+        min_domain = self._min_domain
+        max_domain = self._max_domain
+        extrapolation_mask = self._extrapolation_mask
+        interp_eval = self._interpolator.evaluate
+        extrap_eval = self._extrapolator.evaluate
+        np_local = np
+
+        def _scalar(*args):
+            points = np_local.array([args])
+            if np_local.iscomplexobj(points):
+                raise TypeError(_COMPLEX_ND_ERROR)
+            points = points.astype(float, copy=False)
+            point = points[0]
+            outside_bounds = ((point < min_domain) | (point > max_domain)).any()
+            outside_hull = (
+                extrapolation_mask is not None and extrapolation_mask(points)[0]
+            )
+            if outside_bounds or outside_hull:
+                res_val = extrap_eval(points)[0]
+            else:
+                res_val = interp_eval(points)[0]
+                if np_local.isnan(res_val):
+                    res_val = extrap_eval(points)[0]
+            return (
+                complex(res_val) if np_local.iscomplexobj(res_val) else float(res_val)
+            )
+
+        return _scalar
+
+    def expose_vector(self):  # pylint: disable=too-many-statements
+        if hasattr(self, "_vector_fn"):
+            return self._vector_fn
+
+        min_domain = self._min_domain
+        max_domain = self._max_domain
+        extrapolation_mask = self._extrapolation_mask
+        interp_eval = self._interpolator.evaluate
+        extrap_eval = self._extrapolator.evaluate
+        np_local = np
+
+        def _vector(*args):
+            points = np_local.column_stack(args)
+            if np_local.iscomplexobj(points):
+                raise TypeError(_COMPLEX_ND_ERROR)
+            result = np_local.empty(len(points), dtype=float)
+            lower = points < min_domain
+            upper = points > max_domain
+            extrap_mask = lower.any(axis=1) | upper.any(axis=1)
+            if extrapolation_mask is not None:
+                interp_candidates = ~extrap_mask
+                if interp_candidates.any():
+                    extrap_mask[interp_candidates] = extrapolation_mask(
+                        points[interp_candidates]
+                    )
+            interp_mask = ~extrap_mask
+
+            if interp_mask.any():
+                inside_points = points[interp_mask]
+                interp_values = interp_eval(inside_points)
+
+                if np_local.any(np_local.isnan(interp_values)):
+                    interp_values = np_local.asarray(interp_values, dtype=float)
+                    nan_mask = np_local.isnan(interp_values)
+                    if nan_mask.any():
+                        interp_values[nan_mask] = extrap_eval(inside_points[nan_mask])
+
+                result[interp_mask] = interp_values
+
+            if extrap_mask.any():
+                result[extrap_mask] = extrap_eval(points[extrap_mask])
+
+            return result
+
+        return _vector
+
+
+class RegularGridEvaluator(PolationBase):
+    """Route regular grid evaluation based on axes bounds."""
+
+    __slots__ = (
+        "_interpolator",
+        "_extrapolator",
+        "_min_domain",
+        "_max_domain",
+        "_exposed_fn",
+        "_scalar_fn",
+        "_vector_fn",
+    )
+
+    def __init__(self, interpolator, extrapolator, grid_axes):
+        self._interpolator = interpolator
+        self._extrapolator = extrapolator
+        self._min_domain = np.array([axis[0] for axis in grid_axes], dtype=float)
+        self._max_domain = np.array([axis[-1] for axis in grid_axes], dtype=float)
+        self._scalar_fn = self.expose_scalar()
+        self._vector_fn = self.expose_vector()
+        self._exposed_fn = self.expose()
+
+    def evaluate(self, *args, _is_iterable=None):
+        return self._exposed_fn(*args, _is_iterable=_is_iterable)
+
+    def expose(self):
+        if hasattr(self, "_exposed_fn"):
+            return self._exposed_fn
+
+        scalar_eval = self._scalar_fn
+        vector_eval = self._vector_fn
+
+        def _eval(*args, _is_iterable=None):
+            if _is_iterable is None:
+                _is_iterable = any(
+                    hasattr(arg, "__iter__") and np.ndim(arg) > 0 for arg in args
+                )
+            if not _is_iterable:
+                return scalar_eval(*args)
+            return vector_eval(*args)
+
+        return _eval
+
+    def expose_scalar(self):
+        if hasattr(self, "_scalar_fn"):
+            return self._scalar_fn
+
+        min_domain = self._min_domain
+        max_domain = self._max_domain
+        interp_eval = self._interpolator.evaluate
+        extrap_eval = self._extrapolator.evaluate
+        np_local = np
+
+        if self._interpolator is self._extrapolator:
+
+            def _scalar_same(*args):
+                points = np_local.array([args])
+                if np_local.iscomplexobj(points):
+                    raise TypeError(_COMPLEX_ND_ERROR)
+                points = points.astype(float, copy=False)
+                res = interp_eval(points)
+                return float(res[0])
+
+            return _scalar_same
+
+        def _scalar(*args):
+            points = np_local.array([args])
+            if np_local.iscomplexobj(points):
+                raise TypeError(_COMPLEX_ND_ERROR)
+            points = points.astype(float, copy=False)
+            point = points[0]
+            if ((point < min_domain) | (point > max_domain)).any():
+                return float(extrap_eval(points)[0])
+            return float(interp_eval(points)[0])
+
+        return _scalar
+
+    def expose_vector(self):
+        if hasattr(self, "_vector_fn"):
+            return self._vector_fn
+
+        min_domain = self._min_domain
+        max_domain = self._max_domain
+        interp_eval = self._interpolator.evaluate
+        extrap_eval = self._extrapolator.evaluate
+        np_local = np
+
+        if self._interpolator is self._extrapolator:
+
+            def _vector_same(*args):
+                points = np_local.column_stack(np_local.broadcast_arrays(*args))
+                if np_local.iscomplexobj(points):
+                    raise TypeError(_COMPLEX_ND_ERROR)
+                return interp_eval(points)
+
+            return _vector_same
+
+        def _vector(*args):
+            points = np_local.column_stack(np_local.broadcast_arrays(*args))
+            if np_local.iscomplexobj(points):
+                raise TypeError(_COMPLEX_ND_ERROR)
+            result = np_local.empty(len(points), dtype=float)
+            lower = points < min_domain
+            upper = points > max_domain
+            extrap_mask = lower.any(axis=1) | upper.any(axis=1)
+            interp_mask = ~extrap_mask
+            if interp_mask.any():
+                result[interp_mask] = interp_eval(points[interp_mask])
+            if extrap_mask.any():
+                result[extrap_mask] = extrap_eval(points[extrap_mask])
+            return result
+
+        return _vector
diff --git a/rocketpy/mathutils/_calc/polation_1d.py b/rocketpy/mathutils/_calc/polation_1d.py
new file mode 100644
index 000000000..d704831d7
--- /dev/null
+++ b/rocketpy/mathutils/_calc/polation_1d.py
@@ -0,0 +1,817 @@
+"""1-D interpolation and extrapolation strategies.
+
+The sampled domain and image are real. Complex query values are propagated
+only for complex-step differentiation: their real component selects the
+piecewise interval and the full value is passed to the selected polynomial.
+These strategies do not provide general complex-plane interpolation.
+"""
+
+from __future__ import annotations
+
+from bisect import bisect_left
+
+import numpy as np
+from numpy.typing import NDArray
+
+from rocketpy.mathutils._calc._fitting import (
+    fit_akima,
+    fit_pchip,
+    fit_polynomial,
+    fit_spline,
+    precompute_cubic_cumulative_integrals,
+    precompute_linear_deriv_integral,
+)
+from rocketpy.mathutils._calc.polation_base import PolationBase
+
+
+def _find_index(
+    x_arr: NDArray[np.float64],
+    xq: float | NDArray[np.float64] | complex,
+    n: int,
+    _is_iterable: bool | None = None,
+) -> int | NDArray[np.int_]:
+    """Find the index in a sorted 1D array corresponding to a query value.
+
+    Parameters
+    ----------
+    x_arr : np.ndarray
+        Sorted 1D array of x-coordinates.
+    xq : float, complex or np.ndarray
+        The query coordinate(s). A complex component is meaningful only as an
+        imaginary perturbation for complex-step differentiation. Interval
+        ordering is determined exclusively from the real component.
+    n : int
+        The size of x_arr.
+    _is_iterable : bool, optional
+        Whether the query value represents a batch of points. Default is None.
+
+    Returns
+    -------
+    int or np.ndarray
+        The index or indices representing the interval.
+
+    Notes
+    -----
+    Complex numbers have no ordering compatible with the real sampled domain.
+    Using the real component keeps ``x + i*h`` in the same interval as ``x``,
+    which is required for complex-step differentiation.
+    """
+    if _is_iterable is None:
+        _is_iterable = hasattr(xq, "__iter__") and np.ndim(xq) > 0
+
+    if not _is_iterable:
+        # Both real and complex scalars have the .real property
+        idx = bisect_left(x_arr, xq.real)
+        return 1 if idx < 1 else (idx if idx < n else n - 1)
+    else:
+        idx = np.searchsorted(x_arr, np.real(xq), side="left")
+        return np.clip(idx, 1, n - 1)
+
+
+def _cubic_eval_vec(
+    t: float | NDArray[np.float64],
+    a: float | NDArray[np.float64],
+    b: float | NDArray[np.float64],
+    c: float | NDArray[np.float64],
+    d: float | NDArray[np.float64],
+) -> float | NDArray[np.float64]:
+    """Evaluate a cubic polynomial: a + b*t + c*t**2 + d*t**3.
+
+    Parameters
+    ----------
+    t : float or np.ndarray
+        The relative parameter values.
+    a : float or np.ndarray
+        Constant coefficients.
+    b : float or np.ndarray
+        Linear coefficients.
+    c : float or np.ndarray
+        Quadratic coefficients.
+    d : float or np.ndarray
+        Cubic coefficients.
+
+    Returns
+    -------
+    float or np.ndarray
+        The evaluated cubic polynomial value(s).
+    """
+    return a + b * t + c * (t**2) + d * (t**3)
+
+
+class Linear1DPolation(PolationBase):
+    """Linear 1D interpolation and extrapolation."""
+
+    __slots__ = ("_x", "_y", "_n", "_slopes", "_cum_int")
+
+    def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None:
+        """Initialize the Linear1DPolation.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The x-coordinates of the data points.
+        y : np.ndarray
+            The y-coordinates of the data points.
+        """
+        self._x = np.asarray(x, dtype=float)
+        self._y = np.asarray(y, dtype=float)
+        self._n = self._x.size
+        self._slopes = np.diff(self._y) / np.diff(self._x)
+        self._cum_int = None
+
+    def _slopes_at(self, i):
+        """Return cached or on-demand linear slopes for interval
+        index/indices.
+        """
+        return self._slopes[i]
+
+    def _ensure_integral_cache(self):
+        """Populate linear slopes and cumulative integrals on
+        first integral use.
+        """
+        if self._cum_int is None:
+            _, self._cum_int = precompute_linear_deriv_integral(self._x, self._y)
+
+    def evaluate(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the linear interpolation.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the function is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The interpolated values.
+        """
+        x_arr = self._x
+        i = _find_index(x_arr, x, self._n, _is_iterable) - 1
+        return self._y[i] + self._slopes_at(i) * (x - x_arr[i])
+
+    def derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the first derivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The first derivative values.
+        """
+        i = _find_index(self._x, x, self._n, _is_iterable) - 1
+        return self._slopes_at(i)
+
+    def second_derivative(
+        self, _x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the second derivative.
+
+        Parameters
+        ----------
+        _x : float or np.ndarray
+            The coordinates where the second derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The second derivative values, which are zero.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(_x, "__iter__") and np.ndim(_x) > 0
+        return np.zeros_like(_x, dtype=float) if _is_iterable else 0.0
+
+    def integral(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the antiderivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the antiderivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The antiderivative values.
+        """
+        self._ensure_integral_cache()
+        i = _find_index(self._x, x, self._n, _is_iterable) - 1
+        return (
+            self._cum_int[i]
+            + self._y[i] * (x - self._x[i])
+            + self._slopes[i] * (x - self._x[i]) ** 2 / 2
+        )
+
+
+class Polynomial1DPolation(PolationBase):
+    """Polynomial 1D interpolation."""
+
+    def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None:
+        """Initialize the Polynomial1DPolation.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The x-coordinates of the data points.
+        y : np.ndarray
+            The y-coordinates of the data points.
+        """
+        coeffs = fit_polynomial(x, y)
+        self._coeffs = np.asarray(coeffs, dtype=float)
+
+        # Pre-slice value coefficients for high-speed Horner evaluation.
+        # Derivative and integral coefficients are built lazily.
+        self._c_desc = self._coeffs[::-1].copy()
+        c_list = self._c_desc.tolist()
+        self._c_first, self._c_rest = c_list[0], c_list[1:]
+
+        self._d_desc = None
+        self._d_first = None
+        self._d_rest = None
+        self._d2_desc = None
+        self._d2_first = None
+        self._d2_rest = None
+        self._i_desc = None
+        self._i_first = None
+        self._i_rest = None
+
+    def _ensure_derivative_coefficients(self):
+        """Build first-derivative Horner coefficients on first derivative use."""
+        if self._d_desc is not None:
+            return
+        d_coeffs = (
+            self._coeffs[1:] * np.arange(1, len(self._coeffs))
+            if len(self._coeffs) > 1
+            else np.array([0.0])
+        )
+        self._d_desc = d_coeffs[::-1].copy()
+        d_list = self._d_desc.tolist()
+        self._d_first, self._d_rest = d_list[0], d_list[1:]
+
+    def _ensure_second_derivative_coefficients(self):
+        """Build second-derivative Horner coefficients on first use."""
+        if self._d2_desc is not None:
+            return
+        self._ensure_derivative_coefficients()
+        d_asc = self._d_desc[::-1]
+        d2_coeffs = (
+            d_asc[1:] * np.arange(1, len(d_asc)) if len(d_asc) > 1 else np.array([0.0])
+        )
+        self._d2_desc = d2_coeffs[::-1].copy()
+        d2_list = self._d2_desc.tolist()
+        self._d2_first, self._d2_rest = d2_list[0], d2_list[1:]
+
+    def _ensure_integral_coefficients(self):
+        """Build antiderivative Horner coefficients on first integral use."""
+        if self._i_desc is not None:
+            return
+        i_coeffs = np.empty(len(self._coeffs) + 1)
+        i_coeffs[0] = 0.0
+        i_coeffs[1:] = self._coeffs / np.arange(1, len(self._coeffs) + 1)
+        self._i_desc = i_coeffs[::-1].copy()
+        i_list = self._i_desc.tolist()
+        self._i_first, self._i_rest = i_list[0], i_list[1:]
+
+    def _horner(
+        self,
+        xq: float | NDArray[np.float64],
+        first: float,
+        rest: list[float],
+        desc: NDArray[np.float64],
+        _is_iterable: bool | None = None,
+    ) -> float | NDArray[np.float64]:
+        """Unified Horner evaluation logic.
+
+        Parameters
+        ----------
+        xq : float or np.ndarray
+            The coordinates to evaluate.
+        first : float
+            The first coefficient of the polynomial.
+        rest : list of float
+            The remaining coefficients of the polynomial.
+        desc : np.ndarray
+            The descending coefficients.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The evaluated polynomial value.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(xq, "__iter__") and np.ndim(xq) > 0
+
+        if not _is_iterable:
+            r = first
+            for c in rest:
+                r = r * xq + c
+            return r
+        else:
+            return np.polyval(desc, xq)
+
+    def evaluate(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the polynomial.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the polynomial is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The polynomial values.
+        """
+        return self._horner(x, self._c_first, self._c_rest, self._c_desc, _is_iterable)
+
+    def derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the first derivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The first derivative values.
+        """
+        self._ensure_derivative_coefficients()
+        return self._horner(x, self._d_first, self._d_rest, self._d_desc, _is_iterable)
+
+    def second_derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the second derivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the second derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The second derivative values.
+        """
+        self._ensure_second_derivative_coefficients()
+        return self._horner(
+            x, self._d2_first, self._d2_rest, self._d2_desc, _is_iterable
+        )
+
+    def integral(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the antiderivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the antiderivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The antiderivative values.
+        """
+        self._ensure_integral_coefficients()
+        return self._horner(x, self._i_first, self._i_rest, self._i_desc, _is_iterable)
+
+    def coefficients(self) -> NDArray[np.float64]:
+        """Get the polynomial coefficients.
+
+        Returns
+        -------
+        np.ndarray
+            The polynomial coefficients.
+        """
+        return self._coeffs
+
+
+class Cubic1DPolation(PolationBase):
+    """Cubic piecewise 1D interpolation."""
+
+    __slots__ = ("_x", "_n", "_a", "_b", "_c", "_d", "_cum_int")
+
+    def __init__(
+        self,
+        x: NDArray[np.float64],
+        coeffs: tuple[
+            NDArray[np.float64],
+            NDArray[np.float64],
+            NDArray[np.float64],
+            NDArray[np.float64],
+        ],
+    ) -> None:
+        """Initialize the Cubic1DPolation.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The x-coordinates of the data points.
+        coeffs : tuple of np.ndarray
+            A tuple (a, b, c, d) of numpy arrays representing the
+            piecewise cubic coefficients.
+        """
+        self._x = np.asarray(x, dtype=float)
+        self._n = self._x.size
+        self._a, self._b, self._c, self._d = coeffs
+        self._cum_int = None
+
+    def evaluate(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the cubic piecewise interpolation.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the function is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The interpolated values.
+        """
+        i = _find_index(self._x, x, self._n, _is_iterable) - 1
+        t = x - self._x[i]
+        return _cubic_eval_vec(t, self._a[i], self._b[i], self._c[i], self._d[i])
+
+    def derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the first derivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The first derivative values.
+        """
+        i = _find_index(self._x, x, self._n, _is_iterable) - 1
+        t = x - self._x[i]
+        return self._b[i] + 2 * self._c[i] * t + 3 * self._d[i] * t**2
+
+    def second_derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the second derivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the second derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The second derivative values.
+        """
+        i = _find_index(self._x, x, self._n, _is_iterable) - 1
+        t = x - self._x[i]
+        return 2 * self._c[i] + 6 * self._d[i] * t
+
+    def integral(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the antiderivative.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the antiderivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The antiderivative values.
+        """
+        if self._cum_int is None:
+            self._cum_int = precompute_cubic_cumulative_integrals(
+                self._x, (self._a, self._b, self._c, self._d)
+            )
+
+        i = _find_index(self._x, x, self._n, _is_iterable) - 1
+        t = x - self._x[i]
+        return (
+            self._cum_int[i]
+            + self._a[i] * t
+            + self._b[i] * t**2 / 2
+            + self._c[i] * t**3 / 3
+            + self._d[i] * t**4 / 4
+        )
+
+    def coefficients(self) -> list[NDArray[np.float64]]:
+        """Get the cubic coefficients.
+
+        Returns
+        -------
+        list of np.ndarray
+            A list [a, b, c, d] of the cubic coefficients.
+        """
+        return [self._a, self._b, self._c, self._d]
+
+
+class Spline1DPolation(Cubic1DPolation):
+    """Spline 1D interpolation."""
+
+    __slots__ = ()
+
+    def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None:
+        """Initialize the Spline1DPolation.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The x-coordinates of the data points.
+        y : np.ndarray
+            The y-coordinates of the data points.
+        """
+        super().__init__(x, fit_spline(x, y))
+
+
+class Akima1DPolation(Cubic1DPolation):
+    """Akima 1D interpolation."""
+
+    __slots__ = ()
+
+    def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None:
+        """Initialize the Akima1DPolation.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The x-coordinates of the data points.
+        y : np.ndarray
+            The y-coordinates of the data points.
+        """
+        super().__init__(x, fit_akima(x, y))
+
+
+class Pchip1DPolation(Cubic1DPolation):
+    """PCHIP (Piecewise Cubic Hermite Interpolating Polynomial) 1D interpolation."""
+
+    __slots__ = ()
+
+    def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None:
+        """Initialize the Pchip1DPolation.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The x-coordinates of the data points.
+        y : np.ndarray
+            The y-coordinates of the data points.
+        """
+        super().__init__(x, fit_pchip(x, y))
+
+
+class Constant1DExtrapolation(PolationBase):
+    """Constant 1D extrapolation."""
+
+    __slots__ = ("_x_min", "_x_max", "_y_min", "_y_max")
+
+    def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None:
+        """Initialize the Constant1DExtrapolation.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The x-coordinates of the data points.
+        y : np.ndarray
+            The y-coordinates of the data points.
+        """
+        self._x_min = float(x[0])
+        self._x_max = float(x[-1])
+        self._y_min = float(y[0])
+        self._y_max = float(y[-1])
+
+    def evaluate(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the constant extrapolation.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates to extrapolate.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            The extrapolated constant values at boundary exceedances, or NaN.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+        if _is_iterable:
+            x = np.asarray(x, dtype=float)
+            x_real = x.real
+            result = np.empty_like(x_real, dtype=float)
+            lower = x_real < self._x_min
+            upper = x_real > self._x_max
+            inside = ~(lower | upper)
+
+            result[lower] = self._y_min
+            result[upper] = self._y_max
+            result[inside] = np.nan
+            return result
+
+        x_real = x.real
+        if x_real < self._x_min:
+            return self._y_min
+        if x_real > self._x_max:
+            return self._y_max
+        return np.nan
+
+    def definite_integral(self, a: float, b: float) -> float:
+        """Evaluate the definite integral over a constant extrapolation range.
+
+        Parameters
+        ----------
+        a : float
+            Lower bound of integration.
+        b : float
+            Upper bound of integration.
+
+        Returns
+        -------
+        float
+            The definite integral value.
+        """
+        midpoint = (a + b) / 2.0
+        is_iterable = hasattr(midpoint, "__iter__") and np.ndim(midpoint) > 0
+        return self.evaluate(midpoint, _is_iterable=is_iterable) * (b - a)
+
+    def derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the derivative, which is zero.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            An array of zeros or a scalar zero.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+        return np.zeros_like(x, dtype=float) if _is_iterable else 0.0
+
+    def second_derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the second derivative, which is zero.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the second derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            An array of zeros or a scalar zero.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+        return np.zeros_like(x, dtype=float) if _is_iterable else 0.0
+
+
+class Zero1DExtrapolation(PolationBase):
+    """Zero 1D extrapolation."""
+
+    __slots__ = ()
+
+    def evaluate(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the zero extrapolation.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates to extrapolate.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            An array of zeros or a scalar zero.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+        return np.zeros_like(x, dtype=float) if _is_iterable else 0.0
+
+    def definite_integral(self, a: float, b: float) -> float:
+        """Evaluate the definite integral, which is zero.
+
+        Parameters
+        ----------
+        a : float
+            Lower bound of integration.
+        b : float
+            Upper bound of integration.
+
+        Returns
+        -------
+        float
+            Scalar zero.
+        """
+        return 0.0
+
+    def derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the derivative, which is zero.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            An array of zeros or a scalar zero.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+        return np.zeros_like(x, dtype=float) if _is_iterable else 0.0
+
+    def second_derivative(
+        self, x: float | NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> float | NDArray[np.float64]:
+        """Evaluate the second derivative, which is zero.
+
+        Parameters
+        ----------
+        x : float or np.ndarray
+            The coordinates where the second derivative is to be evaluated.
+        _is_iterable : bool, optional
+            Whether the input is iterable. Default is None.
+
+        Returns
+        -------
+        float or np.ndarray
+            An array of zeros or a scalar zero.
+        """
+        if _is_iterable is None:
+            _is_iterable = hasattr(x, "__iter__") and np.ndim(x) > 0
+        return np.zeros_like(x, dtype=float) if _is_iterable else 0.0
diff --git a/rocketpy/mathutils/_calc/polation_base.py b/rocketpy/mathutils/_calc/polation_base.py
new file mode 100644
index 000000000..5b3790d2a
--- /dev/null
+++ b/rocketpy/mathutils/_calc/polation_base.py
@@ -0,0 +1,31 @@
+"""Strategy interfaces for interpolation/extrapolation evaluation."""
+
+from abc import ABC, abstractmethod
+
+
+class PolationBase(ABC):
+    """Base strategy for evaluation and optional derivatives/integrals."""
+
+    @abstractmethod
+    def evaluate(self, x, _is_iterable=None):
+        """Evaluate the strategy at the given input(s)."""
+
+    def derivative(self, x, _is_iterable=None):
+        raise NotImplementedError("Analytical derivative not available.")
+
+    def second_derivative(self, x, _is_iterable=None):
+        raise NotImplementedError("Analytical 2nd derivative not available.")
+
+    def integral(self, x, _is_iterable=None):
+        raise NotImplementedError("Analytical antiderivative not available.")
+
+    def definite_integral(self, a, b):
+        """Evaluate the definite integral from a to b."""
+        return self.integral(b) - self.integral(a)
+
+    def coefficients(self):
+        return None
+
+    def expose(self):
+        """Returns a fast callable. By default, returns self.evaluate."""
+        return self.evaluate
diff --git a/rocketpy/mathutils/_calc/polation_nd.py b/rocketpy/mathutils/_calc/polation_nd.py
new file mode 100644
index 000000000..4cd16c77c
--- /dev/null
+++ b/rocketpy/mathutils/_calc/polation_nd.py
@@ -0,0 +1,451 @@
+"""N-D interpolation and extrapolation strategies.
+
+Sampled N-D coordinates are real-valued. Complex query coordinates are not
+supported and are rejected by the evaluator before reaching these strategies.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from numpy.typing import NDArray
+from scipy.interpolate import (
+    LinearNDInterpolator,
+    NearestNDInterpolator,
+    RBFInterpolator,
+    RegularGridInterpolator,
+)
+from scipy.spatial import Delaunay  # pylint: disable=no-name-in-module
+from scipy.spatial.distance import cdist
+
+from rocketpy.mathutils._calc.polation_base import PolationBase
+
+
+class LinearNDPolation(PolationBase):
+    """Linear interpolation for N-dimensional scattered data."""
+
+    __slots__ = ("_interpolator", "_triangulation")
+
+    def __init__(self, domain: NDArray[np.float64], image: NDArray[np.float64]) -> None:
+        """Initialize the LinearNDPolation.
+
+        Parameters
+        ----------
+        domain : np.ndarray
+            The domain coordinates of shape (n_samples, n_dimensions).
+        image : np.ndarray
+            The function values at the domain coordinates of shape
+            (n_samples,).
+        """
+        self._triangulation = Delaunay(domain)
+        self._interpolator = LinearNDInterpolator(self._triangulation, image)
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the linear interpolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The interpolated values at the given coordinates.
+        """
+        return self._interpolator(x)
+
+    def extrapolation_mask(self, x: NDArray[np.float64]) -> NDArray[np.bool_]:
+        """Return True for points outside the Delaunay triangulation.
+
+        ``LinearNDInterpolator`` returns NaN outside the convex hull even when
+        a point is inside the axis-aligned bounds. Pre-checking the simplex
+        lets the evaluator route those points directly to extrapolation.
+        """
+        points = np.asarray(x, dtype=float)
+        return self._triangulation.find_simplex(points) < 0
+
+
+class RbfNDPolation(PolationBase):
+    """Radial Basis Function (RBF) interpolation for N-dimensional scattered data."""
+
+    __slots__ = ("_interpolator",)
+
+    def __init__(
+        self,
+        domain: NDArray[np.float64],
+        image: NDArray[np.float64],
+        neighbors: int = 100,
+    ) -> None:
+        """Initialize the RbfNDPolation.
+
+        Parameters
+        ----------
+        domain : np.ndarray
+            The domain coordinates of shape (n_samples, n_dimensions).
+        image : np.ndarray
+            The function values at the domain coordinates of shape (n_samples,).
+        neighbors : int, optional
+            Number of nearest neighbors to use for interpolation. Default is 100.
+        """
+        self._interpolator = RBFInterpolator(domain, image, neighbors=neighbors)
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the RBF interpolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The interpolated values at the given coordinates.
+        """
+        return self._interpolator(x)
+
+
+class ShepardNDPolation(PolationBase):
+    """Shepard (IDW) interpolation for scattered ND data."""
+
+    __slots__ = ("_domain", "_image")
+
+    def __init__(self, domain: NDArray[np.float64], image: NDArray[np.float64]) -> None:
+        """Initialize the ShepardNDPolation.
+
+        Parameters
+        ----------
+        domain : np.ndarray
+            The domain coordinates of shape (n_samples, n_dimensions).
+        image : np.ndarray
+            The function values at the domain coordinates of shape
+            (n_samples,).
+        """
+        self._domain = np.asarray(domain, dtype=float)
+        self._image = np.asarray(image, dtype=float)
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the Shepard interpolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The interpolated values at the given coordinates.
+        """
+        points = np.asarray(x, dtype=float)
+        arg_qty = points.shape[0]
+
+        # Vectorized path
+        distances_sq = cdist(points, self._domain, metric="sqeuclidean")
+        zero_mask = distances_sq == 0
+        exact_match_rows = np.any(zero_mask, axis=1)
+
+        with np.errstate(divide="ignore"):
+            weights = distances_sq ** (-1.5)
+        weights[exact_match_rows] = 0.0
+
+        numerator = np.sum(self._image * weights, axis=1)
+        denominator = np.sum(weights, axis=1)
+
+        result = np.empty(arg_qty, dtype=float)
+        valid = ~exact_match_rows
+        result[valid] = numerator[valid] / denominator[valid]
+
+        if exact_match_rows.any():
+            match_indices = np.argmax(zero_mask[exact_match_rows], axis=1)
+            result[exact_match_rows] = self._image[match_indices]
+
+        return result
+
+
+class ConstantNDExtrapolation(PolationBase):
+    """Constant extrapolation for N-dimensional data using nearest neighbors."""
+
+    __slots__ = ("_interpolator",)
+
+    def __init__(self, domain: NDArray[np.float64], image: NDArray[np.float64]) -> None:
+        """Initialize the ConstantNDExtrapolation.
+
+        Parameters
+        ----------
+        domain : np.ndarray
+            The domain coordinates of shape (n_samples, n_dimensions).
+        image : np.ndarray
+            The function values at the domain coordinates of shape
+            (n_samples,).
+        """
+        self._interpolator = NearestNDInterpolator(domain, image)
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the constant extrapolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The extrapolated values at the given coordinates.
+        """
+        return self._interpolator(x)
+
+
+class ZeroNDExtrapolation(PolationBase):
+    """Zero extrapolation for N-dimensional data, returning zero
+    for all points.
+    """
+
+    __slots__ = ()
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the zero extrapolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            An array of zeros matching the number of input points.
+        """
+        points = np.asarray(x)
+        return np.zeros(points.shape[0], dtype=float)
+
+
+class RbfNaturalNDExtrapolation(PolationBase):
+    """Natural RBF extrapolation for N-dimensional data."""
+
+    __slots__ = ("_interpolator",)
+
+    def __init__(self, domain: NDArray[np.float64], image: NDArray[np.float64]) -> None:
+        """Initialize the RbfNaturalNDExtrapolation.
+
+        Parameters
+        ----------
+        domain : np.ndarray
+            The domain coordinates of shape (n_samples, n_dimensions).
+        image : np.ndarray
+            The function values at the domain coordinates of shape
+            (n_samples,).
+        """
+        self._interpolator = RBFInterpolator(domain, image)
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the natural RBF extrapolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The extrapolated values at the given coordinates.
+        """
+        return self._interpolator(x)
+
+
+class RegularGridInterpolation(PolationBase):
+    """Interpolation for N-dimensional data defined on a regular grid."""
+
+    __slots__ = ("_grid_axes", "_interpolator")
+
+    def __init__(
+        self, grid_axes: list[NDArray[np.float64]], grid_data: NDArray[np.float64]
+    ) -> None:
+        """Initialize the RegularGridInterpolation.
+
+        Parameters
+        ----------
+        grid_axes : list of np.ndarray
+            A list containing 1D arrays defining the grid coordinates
+            for each dimension.
+        grid_data : np.ndarray
+            The N-dimensional array containing the function values at
+            the grid points.
+        """
+        self._interpolator = RegularGridInterpolator(
+            grid_axes, grid_data, bounds_error=True
+        )
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the regular grid interpolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The interpolated values at the given coordinates.
+        """
+        return self._interpolator(x)
+
+
+class RegularGridNaturalExtrapolation(PolationBase):
+    """Natural extrapolation for N-dimensional data defined on a regular grid."""
+
+    __slots__ = ("_grid_axes", "_interpolator")
+
+    def __init__(
+        self, grid_axes: list[NDArray[np.float64]], grid_data: NDArray[np.float64]
+    ) -> None:
+        """Initialize the RegularGridNaturalExtrapolation.
+
+        Parameters
+        ----------
+        grid_axes : list of np.ndarray
+            A list containing 1D arrays defining the grid coordinates
+            for each dimension.
+        grid_data : np.ndarray
+            The N-dimensional array containing the function values at
+            the grid points.
+        """
+        self._interpolator = RegularGridInterpolator(
+            grid_axes, grid_data, bounds_error=False, fill_value=None
+        )
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the natural regular grid extrapolation at
+        the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated, of shape
+            (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The extrapolated values at the given coordinates.
+        """
+        return self._interpolator(x)
+
+
+class RegularGridConstantExtrapolation(PolationBase):
+    """Constant extrapolation for N-dimensional data defined on a
+    regular grid by clamping coordinates.
+    """
+
+    __slots__ = ("_grid_axes", "_interpolator")
+
+    def __init__(
+        self, grid_axes: list[NDArray[np.float64]], grid_data: NDArray[np.float64]
+    ) -> None:
+        """Initialize the RegularGridConstantExtrapolation.
+
+        Parameters
+        ----------
+        grid_axes : list of np.ndarray
+            A list containing 1D arrays defining the grid coordinates
+            for each dimension.
+        grid_data : np.ndarray
+            The N-dimensional array containing the function values
+            at the grid points.
+        """
+        self._grid_axes = grid_axes
+        self._interpolator = RegularGridInterpolator(
+            grid_axes, grid_data, bounds_error=True
+        )
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the constant regular grid extrapolation
+        at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated,
+            of shape (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            The extrapolated values at the clamped coordinates.
+        """
+        x_clamped = np.array(x, copy=True)
+        for i, axis in enumerate(self._grid_axes):
+            x_clamped[:, i] = np.clip(x_clamped[:, i], axis[0], axis[-1])
+        return self._interpolator(x_clamped)
+
+
+class RegularGridZeroExtrapolation(PolationBase):
+    """Regular grid extrapolation using zeros."""
+
+    __slots__ = ()
+
+    def evaluate(
+        self, x: NDArray[np.float64], _is_iterable: bool | None = None
+    ) -> NDArray[np.float64]:
+        """Evaluate the zero extrapolation at the given coordinates.
+
+        Parameters
+        ----------
+        x : np.ndarray
+            The points where the function is to be evaluated,
+            of shape (n_points, n_dimensions).
+        _is_iterable : bool, optional
+            Whether the input represents a batch of points. Default is None.
+
+        Returns
+        -------
+        np.ndarray
+            An array of zeros matching the number of input points.
+        """
+        return np.zeros(len(x))
diff --git a/rocketpy/mathutils/_calc/registry.py b/rocketpy/mathutils/_calc/registry.py
new file mode 100644
index 000000000..2dccfcbde
--- /dev/null
+++ b/rocketpy/mathutils/_calc/registry.py
@@ -0,0 +1,134 @@
+"""Registry and factory dispatch for interpolation/extrapolation.
+
+The main entry-point is :func:`build_interpolation_evaluator`, which
+returns a router strategy that selects interpolation or extrapolation
+based on the input bounds.
+"""
+
+from rocketpy.mathutils._calc.evaluator import (
+    PolationEvaluator1D,
+    PolationEvaluatorND,
+    RegularGridEvaluator,
+)
+from rocketpy.mathutils._calc.polation_1d import (
+    Akima1DPolation,
+    Constant1DExtrapolation,
+    Linear1DPolation,
+    Pchip1DPolation,
+    Polynomial1DPolation,
+    Spline1DPolation,
+    Zero1DExtrapolation,
+)
+from rocketpy.mathutils._calc.polation_nd import (
+    ConstantNDExtrapolation,
+    LinearNDPolation,
+    RbfNaturalNDExtrapolation,
+    RbfNDPolation,
+    RegularGridConstantExtrapolation,
+    RegularGridInterpolation,
+    RegularGridNaturalExtrapolation,
+    RegularGridZeroExtrapolation,
+    ShepardNDPolation,
+    ZeroNDExtrapolation,
+)
+
+_INTERP_1D = {
+    "linear": Linear1DPolation,
+    "polynomial": Polynomial1DPolation,
+    "akima": Akima1DPolation,
+    "pchip": Pchip1DPolation,
+    "spline": Spline1DPolation,
+}
+
+_INTERP_ND = {
+    "linear": LinearNDPolation,
+    "rbf": RbfNDPolation,
+    "shepard": ShepardNDPolation,
+}
+
+_EXTRAP_1D = {
+    "zero": lambda interp, x, y: Zero1DExtrapolation(),
+    "constant": lambda interp, x, y: Constant1DExtrapolation(x, y),
+    # Natural 1D uses the interpolator, or falls back to Constant
+    "natural": lambda interp, x, y: interp or Constant1DExtrapolation(x, y),
+}
+
+
+def _build_natural_nd(interp, method, domain, image):
+    """Build scattered-ND natural extrapolation.
+
+    Natural extrapolation normally reuses the interpolation strategy. The
+    exception is scattered linear interpolation: SciPy's LinearNDInterpolator
+    does not extrapolate outside the convex hull, so RocketPy falls back to an
+    RBF extrapolator for out-of-domain points.
+    """
+    if interp and method in ("rbf", "shepard"):
+        return interp
+    if method == "linear":
+        return RbfNaturalNDExtrapolation(domain, image)
+    return interp or ShepardNDPolation(domain, image)
+
+
+_EXTRAP_ND = {
+    "zero": lambda interp, method, domain, image: ZeroNDExtrapolation(),
+    "constant": lambda interp, method, domain, image: ConstantNDExtrapolation(
+        domain, image
+    ),
+    "natural": _build_natural_nd,
+}
+
+_EXTRAP_GRID = {
+    "zero": lambda interp, axes, data: RegularGridZeroExtrapolation(),
+    "constant": lambda interp, axes, data: RegularGridConstantExtrapolation(axes, data),
+    # RegularGridInterpolator extrapolates with fill_value=None when
+    # bounds_error=False. This is the regular-grid natural policy.
+    "natural": lambda interp, axes, data: RegularGridNaturalExtrapolation(axes, data),
+}
+
+
+def build_interpolation_evaluator(
+    method,
+    extrap_method,
+    dom_dim,
+    x=None,
+    y=None,
+    domain=None,
+    image=None,
+    grid_axes=None,
+    grid_data=None,
+):
+    """Build the evaluator router for interpolation and extrapolation.
+
+    Routes to the correct 1D, ND, or Regular Grid construction path early
+    to avoid passing unused arguments to the underlying classes.
+    """
+    if method == "regular_grid":
+        if grid_axes is None or grid_data is None:
+            raise ValueError("Regular grid requires both grid_axes and grid_data.")
+
+        interp = RegularGridInterpolation(grid_axes, grid_data)
+        extrap_cls = _EXTRAP_GRID.get(extrap_method, _EXTRAP_GRID["constant"])
+        extrap = extrap_cls(interp, grid_axes, grid_data)
+
+        return RegularGridEvaluator(interp, extrap, grid_axes)
+
+    if dom_dim == 1:
+        interp_cls = _INTERP_1D.get(method, Spline1DPolation)
+        try:
+            interp = interp_cls(x, y)
+        except TypeError:
+            # fit_polynomial may overflow and return None; fall back to spline.
+            interp = Spline1DPolation(x, y)
+
+        extrap_cls = _EXTRAP_1D.get(extrap_method, _EXTRAP_1D["constant"])
+        extrap = extrap_cls(interp, x, y)
+
+        return PolationEvaluator1D(interp, extrap, x)
+
+    interp_cls = _INTERP_ND.get(method, ShepardNDPolation)
+    interp = interp_cls(domain, image)
+
+    extrap_cls = _EXTRAP_ND.get(extrap_method, _EXTRAP_ND["constant"])
+    extrap = extrap_cls(interp, method, domain, image)
+
+    return PolationEvaluatorND(interp, extrap, domain)
diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py
index 33a82ec01..207cdef49 100644
--- a/rocketpy/mathutils/function.py
+++ b/rocketpy/mathutils/function.py
@@ -5,50 +5,62 @@
 carefully as it may impact all the rest of the project.
 """
 
+import logging
 import operator
 import warnings
-from bisect import bisect_left
 from collections.abc import Iterable
 from copy import deepcopy
 from enum import Enum
-from functools import cached_property
 from inspect import signature
 from pathlib import Path
 
 import matplotlib.pyplot as plt
 import numpy as np
-from numpy import trapezoid
-from scipy import integrate, linalg, optimize
-from scipy.interpolate import (
-    LinearNDInterpolator,
-    NearestNDInterpolator,
-    RBFInterpolator,
-    RegularGridInterpolator,
-)
+from scipy import integrate, optimize
 
+from rocketpy.mathutils._calc import build_interpolation_evaluator
 from rocketpy.plots.plot_helpers import show_or_save_plot
 from rocketpy.tools import deprecated, from_hex_decode, to_hex_encode
 
-NUMERICAL_TYPES = (float, int, complex, np.integer, np.floating)
-INTERPOLATION_TYPES = {
-    "linear": 0,
-    "polynomial": 1,
-    "akima": 2,
-    "spline": 3,
-    "shepard": 4,
-    "rbf": 5,
-    "regular_grid": 6,
+logger = logging.getLogger(__name__)
+
+NUMERICAL_TYPES = (
+    float,
+    int,
+    complex,
+    np.integer,
+    np.floating,
+    np.complexfloating,
+)
+_LIST_VECTORIZE_THRESHOLD = 10
+_FAST_MATH = False
+
+
+def _safe_truediv(a, b):
+    with np.errstate(divide="ignore", invalid="ignore"):
+        result = a / b
+        return np.nan_to_num(result)
+
+
+_OPERATOR_SYMBOLS = {
+    operator.add: "+",
+    operator.sub: "-",
+    operator.mul: "*",
+    operator.truediv: "/",
+    _safe_truediv: "/",
+    operator.pow: "**",
+    operator.mod: "%",
 }
-EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2}
 
 
 class SourceType(Enum):
     """Enumeration of the source types for the Function class.
-    The source can be either a callable or an array.
+    The source can be a callable, an array, or a scalar constant.
     """
 
     CALLABLE = 0
     ARRAY = 1
+    SCALAR = 2
 
 
 class Function:  # pylint: disable=too-many-public-methods
@@ -68,6 +80,7 @@ def __init__(
         interpolation=None,
         extrapolation=None,
         title=None,
+        **kwargs,
     ):
         """Convert source into a Function, to be used more naturally.
         Set inputs, outputs, domain dimension, interpolation and extrapolation
@@ -80,11 +93,18 @@ def __init__(
 
             - ``Callable``: Called for evaluation with input values. Must have \
                 the desired inputs as arguments and return a single output \
-                value. Input order is important. Example: Python functions.
-            - ``int`` or ``float``: Treated as a constant value function.
+                value. Input order is important. Complex-valued coordinates \
+                are supported only when the supplied callable accepts and \
+                correctly handles complex values. Example: Python functions.
+            - ``int``, ``float`` or ``complex``: Treated as a constant value \
+                function.
             - ``np.ndarray``: Used for interpolation. Format as [(x0, y0, z0), \
             (x1, y1, z1), ..., (xn, yn, zn)], where 'x' and 'y' are inputs, \
-            and 'z' is the output.
+            and 'z' is the output. Sampled coordinates and values are real. \
+            One-dimensional sampled Functions propagate an imaginary \
+            perturbation only to support complex-step differentiation; this \
+            is not general complex-plane interpolation. Sampled N-D Functions \
+            do not support complex coordinates.
             - ``str``: Path to a CSV file. The file is read and converted into an \
             ndarray. The file can optionally contain a single header line.
             - ``Function``: Copies the source of the provided Function object, \
@@ -93,32 +113,44 @@ def __init__(
         inputs : string, sequence of strings, optional
             The name of the inputs of the function. Will be used for
             representation and graphing (axis names). 'Scalar' is default.
-            If source is function, int or float and has multiple inputs,
+            If source is a function and has multiple inputs,
             this parameter must be given for correct operation.
         outputs : string, sequence of strings, optional
             The name of the outputs of the function. Will be used for
             representation and graphing (axis names). Scalar is default.
         interpolation : string, optional
             Interpolation method to be used if source type is ndarray.
-            For 1-D functions, linear, polynomial, akima and spline are
+            For 1-D functions, linear, polynomial, akima, pchip and spline are
             supported. For N-D functions, linear, shepard, rbf and
             regular_grid are supported.
             Default for 1-D functions is spline and for N-D functions is
             shepard.
         extrapolation : string, optional
             Extrapolation method to be used if source type is ndarray.
-            Options are 'natural', which keeps interpolation, 'constant',
-            which returns the value of the function at the nearest edge of
-            the domain, and 'zero', which returns zero for all points outside
-            of source range.
-            Multidimensional 'natural' extrapolation for 'linear' interpolation
-            use a 'rbf' algorithm for smoother results.
+            Options are 'natural', 'constant' and 'zero'. For 1-D functions,
+            'natural' extends the selected interpolation method outside the
+            data range. For scattered N-D functions, 'natural' usually keeps
+            the interpolation method; the documented exception is scattered
+            N-D 'linear', which falls back to an RBF extrapolator. For 
+            regular-grid functions, 'natural' uses SciPy's regular grid 
+            extrapolation behavior. 'constant' returns the value of the 
+            function at the nearest edge of the domain, and 'zero' returns 
+            zero for all points outside the source range.
             Default for 1-D functions is constant and for N-D functions
             is natural.
         title : string, optional
             Title to be displayed in the plots' figures. If none, the title will
             be constructed using the inputs and outputs arguments in the form
             of  "{inputs} x {outputs}".
+        kwargs : dict, optional
+            - vectorized_callable : bool, optional
+                Whether a callable source accepts NumPy array inputs and returns
+                vectorized outputs. Defaults to False.
+            - validate : bool, optional
+                Whether to validate and normalize the source before construction.
+                Defaults to True. If False, the caller is responsible for passing
+                an already-normalized source: array sources must be numeric 2D
+                arrays and 1-D array sources must already be sorted by domain.
 
         Returns
         -------
@@ -139,18 +171,19 @@ def __init__(
         (II) Fields in CSV files may be enclosed in double quotes. If fields
         are not quoted, double quotes should not appear inside them.
         """
-        # initialize parameters
         self.source = source
         self.__inputs__ = inputs
         self.__outputs__ = outputs
         self.__interpolation__ = interpolation
         self.__extrapolation__ = extrapolation
         self.title = title
+        self.__vectorized_callable__ = kwargs.get("vectorized_callable", False)
+        validate = kwargs.get("validate", True)
         self.__img_dim__ = 1  # always 1, here for backwards compatibility
-        self.__cropped_domain__ = (None, None)  # the x interval if cropped
+        self.__cropped_domain__ = None
 
         # args must be passed from self.
-        self.set_source(self.source)
+        self.set_source(self.source, validate=validate)
         self.set_inputs(self.__inputs__)
         self.set_outputs(self.__outputs__)
         self.set_title(self.title)
@@ -254,7 +287,7 @@ def set_outputs(self, outputs):
         self.__outputs__ = self.__validate_outputs(outputs)
         return self
 
-    def set_source(self, source):  # pylint: disable=too-many-statements
+    def set_source(self, source, validate=True):  # pylint: disable=too-many-statements
         """Sets the data source for the function, defining how the function
         produces output from a given input.
 
@@ -274,6 +307,10 @@ def set_source(self, source):  # pylint: disable=too-many-statements
             ndarray. The file can optionally contain a single header line.
             - ``Function``: Copies the source of the provided Function object, \
             creating a new Function with adjusted inputs and outputs.
+        validate : bool, optional
+            If True, validate, coerce and sort the source. If False, skip source
+            validation for faster internal construction. In this mode, array
+            sources must already be numeric 2D arrays and sorted for 1-D domains.
 
         Notes
         -----
@@ -296,37 +333,63 @@ def set_source(self, source):  # pylint: disable=too-many-statements
         self : Function
             Returns the Function instance with the new source set.
         """
-        source = self.__validate_source(source)
+        if validate:
+            validated = self.__validate_source(
+                source,
+                inputs=self.__inputs__,
+                outputs=self.__outputs__,
+                interpolation=self.__interpolation__,
+            )
+            source, self.__inputs__, self.__outputs__, grid_axes, grid_data = validated
+            if grid_axes is not None:
+                self._grid_axes = grid_axes
+                self._grid_data = grid_data
 
-        # Handle callable source or number source
-        if callable(source):
+        # Handle scalar (constant) source
+        if isinstance(source, NUMERICAL_TYPES):
+            self._source_type = SourceType.SCALAR
+            self._scalar_value = source
+            self.__dom_dim__ = 1
+            self.__interpolation__ = None
+            self.__extrapolation__ = None
+
+        # Handle callable source
+        elif callable(source):
             self._source_type = SourceType.CALLABLE
-            self.get_value_opt = source
+            self.__vectorized_callable__ = bool(
+                self.__vectorized_callable__
+                or getattr(source, "__vectorized_callable__", False)
+            )
             self.__interpolation__ = None
             self.__extrapolation__ = None
 
             # Set arguments name and domain dimensions
-            parameters = signature(source).parameters
-            self.__dom_dim__ = len(parameters)
-            if self.__inputs__ is None:
-                self.__inputs__ = list(parameters)
+            if hasattr(source, "__dom_dim__"):
+                self.__dom_dim__ = source.__dom_dim__
+            else:
+                parameters = signature(source).parameters
+                self.__dom_dim__ = len(parameters)
+                if self.__inputs__ is None:
+                    self.__inputs__ = list(parameters)
 
         # Handle ndarray source
         else:
             self._source_type = SourceType.ARRAY
             # Evaluate dimension
             self.__dom_dim__ = source.shape[1] - 1
+
+            # set x and y. If Function is 2D, also set z
+            if validate and self.__dom_dim__ == 1:
+                source = source[source[:, 0].argsort()]
+
             self._domain = source[:, :-1]
             self._image = source[:, -1]
 
-            # set x and y. If Function is 2D, also set z
             if self.__dom_dim__ == 1:
-                source = source[source[:, 0].argsort()]
                 self.x_array = source[:, 0]
                 self.x_initial, self.x_final = self.x_array[0], self.x_array[-1]
                 self.y_array = source[:, 1]
                 self.y_initial, self.y_final = self.y_array[0], self.y_array[-1]
-                self.get_value_opt = self.__get_value_opt_1d
             elif self.__dom_dim__ > 1:
                 self.x_array = source[:, 0]
                 self.x_initial, self.x_final = self.x_array[0], self.x_array[-1]
@@ -334,14 +397,76 @@ def set_source(self, source):  # pylint: disable=too-many-statements
                 self.y_initial, self.y_final = self.y_array[0], self.y_array[-1]
                 self.z_array = source[:, 2]
                 self.z_initial, self.z_final = self.z_array[0], self.z_array[-1]
-                self.get_value_opt = self.__get_value_opt_nd
 
         self.source = source
-        self.set_interpolation(self.__interpolation__)
-        self.set_extrapolation(self.__extrapolation__)
+        if self._source_type is SourceType.ARRAY:
+            self.__interpolation__ = self.__validate_interpolation(
+                self.__interpolation__
+            )
+            self.__extrapolation__ = self.__validate_extrapolation(
+                self.__extrapolation__
+            )
+            self._build_interp_extrap()
+        else:
+            self.set_get_value_opt()
         return self
 
-    @cached_property
+    @classmethod
+    def _from_sorted_arrays(
+        cls,
+        domain,
+        image,
+        inputs=None,
+        outputs=None,
+        interpolation=None,
+        extrapolation=None,
+        title=None,
+    ):  # pylint: disable=too-many-statements
+        """Build an array-based Function from already-normalized arrays.
+
+        This private constructor is for internal results whose domain is already
+        sorted and whose source data has already been validated by construction.
+        """
+        func = cls.__new__(cls)
+        domain = np.asarray(domain, dtype=float)
+        if domain.ndim == 1:
+            domain = domain.reshape(-1, 1)
+        image = np.asarray(image, dtype=float)
+
+        func.source = np.column_stack((domain, image))
+        func._domain = domain
+        func._image = image
+        func._source_type = SourceType.ARRAY
+        func.__dom_dim__ = domain.shape[1]
+        func.__img_dim__ = 1
+        func.__inputs__ = inputs
+        func.__outputs__ = outputs
+        func.__interpolation__ = interpolation
+        func.__extrapolation__ = extrapolation
+        func.__vectorized_callable__ = False
+        func.__cropped_domain__ = None
+        func.title = title
+
+        func.x_array = domain[:, 0]
+        func.x_initial, func.x_final = func.x_array[0], func.x_array[-1]
+        if func.__dom_dim__ == 1:
+            func.y_array = image
+            func.y_initial, func.y_final = image[0], image[-1]
+        else:
+            func.y_array = domain[:, 1]
+            func.y_initial, func.y_final = func.y_array[0], func.y_array[-1]
+            func.z_array = image
+            func.z_initial, func.z_final = image[0], image[-1]
+
+        func.set_inputs(inputs)
+        func.set_outputs(outputs)
+        func.set_title(title)
+        func.__interpolation__ = func.__validate_interpolation(interpolation)
+        func.__extrapolation__ = func.__validate_extrapolation(extrapolation)
+        func._build_interp_extrap()
+        return func
+
+    @property
     def min(self):
         """Get the minimum value of the Function y_array.
         Raises an error if the Function is lambda based.
@@ -352,7 +477,7 @@ def min(self):
         """
         return self.y_array.min()
 
-    @cached_property
+    @property
     def max(self):
         """Get the maximum value of the Function y_array.
         Raises an error if the Function is lambda based.
@@ -382,27 +507,9 @@ def set_interpolation(self, method="spline"):
         """
         if self._source_type is SourceType.ARRAY:
             self.__interpolation__ = self.__validate_interpolation(method)
-            self.__update_interpolation_coefficients(self.__interpolation__)
-            self.__set_interpolation_func()
+            self._build_interp_extrap()
         return self
 
-    def __update_interpolation_coefficients(self, method):
-        """Update interpolation coefficients for the given method."""
-        # Spline, akima and polynomial need data processing
-        # Shepard, and linear do not
-        match method:
-            case "polynomial":
-                self.__interpolate_polynomial__()
-                self._coeffs = self.__polynomial_coefficients__
-            case "akima":
-                self.__interpolate_akima__()
-                self._coeffs = self.__akima_coefficients__
-            case "spline" | None:
-                self.__interpolate_spline__()
-                self._coeffs = self.__spline_coefficients__
-            case _:
-                self._coeffs = []
-
     def set_extrapolation(self, method="constant"):
         """Set extrapolation behavior of data set.
 
@@ -410,12 +517,15 @@ def set_extrapolation(self, method="constant"):
         ----------
         extrapolation : string, optional
             Extrapolation method to be used if source type is ndarray.
-            Options are 'natural', which keeps interpolation, 'constant',
-            which returns the value of the function at the nearest edge of
-            the domain, and 'zero', which returns zero for all points outside
-            of source range.
-            Multidimensional 'natural' extrapolation for 'linear' interpolation
-            use a 'rbf' algorithm for smoother results.
+            Options are 'natural', 'constant' and 'zero'. For 1-D functions,
+            'natural' extends the selected interpolation method outside the
+            data range. For scattered N-D functions, 'natural' usually keeps
+            the interpolation method; the documented exception is scattered
+            N-D 'linear', which falls back to an RBF extrapolator. For regular
+            -grid functions, 'natural' uses SciPy's regular grid extrapolation
+            behavior. 'constant' returns the value of the function at the
+            nearest edge of the domain, and 'zero' returns zero for all points
+            outside the source range.
             Default for 1-D functions is constant and for N-D functions
             is natural.
 
@@ -426,18 +536,14 @@ def set_extrapolation(self, method="constant"):
         """
         if self._source_type is SourceType.ARRAY:
             self.__extrapolation__ = self.__validate_extrapolation(method)
-            self.__set_extrapolation_func()
+            self._build_interp_extrap()
         return self
 
-    def __process_grid_source(self, source):
+    @staticmethod
+    def __process_grid_source(source):
         """Validate and process a ``(axes, grid_data)`` tuple into a flat
         scatter :class:`numpy.ndarray` ready for :meth:`set_source`.
 
-        As a side-effect, stores ``self._grid_axes`` and ``self._grid_data``
-        so that :meth:`__set_interpolation_func` (case 6) and
-        :meth:`__set_extrapolation_func` can build the
-        :class:`~scipy.interpolate.RegularGridInterpolator`.
-
         Parameters
         ----------
         source : tuple
@@ -448,9 +554,10 @@ def __process_grid_source(self, source):
 
         Returns
         -------
-        flat_source : numpy.ndarray
-            Array of shape ``(n_points, n_dims + 1)`` with all grid points
-            unrolled in row-major (C) order.
+        tuple
+            ``(flat_source, axes, grid_data)``. ``flat_source`` has shape
+            ``(n_points, n_dims + 1)`` with all grid points unrolled in
+            row-major (C) order.
 
         Raises
         ------
@@ -494,466 +601,311 @@ def __process_grid_source(self, source):
                     UserWarning,
                 )
 
-        self._grid_axes = axes
-        self._grid_data = grid_data
-
         mesh = np.meshgrid(*axes, indexing="ij")
         domain_points = np.column_stack([m.ravel() for m in mesh])
-        return np.column_stack([domain_points, grid_data.ravel()])
-
-    def __set_interpolation_func(self):  # pylint: disable=too-many-statements
-        """Defines interpolation function used by the Function. Each
-        interpolation method has its own function`.
-        The function is stored in the attribute _interpolation_func."""
-        interpolation = INTERPOLATION_TYPES[self.__interpolation__]
-        match interpolation:
-            case 0:  # linear
-                if self.__dom_dim__ == 1:
-
-                    def linear_interpolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                        x_interval = bisect_left(x_data, x)
-                        x_left = x_data[x_interval - 1]
-                        y_left = y_data[x_interval - 1]
-                        dx = float(x_data[x_interval] - x_left)
-                        dy = float(y_data[x_interval] - y_left)
-                        return (x - x_left) * (dy / dx) + y_left
-
-                else:
-                    interpolator = LinearNDInterpolator(self._domain, self._image)
-
-                    def linear_interpolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                        return interpolator(x)
-
-                self._interpolation_func = linear_interpolation
-
-            case 1:  # polynomial
-
-                def polynomial_interpolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                    return np.sum(coeffs * x ** np.arange(len(coeffs)))
+        return np.column_stack([domain_points, grid_data.ravel()]), axes, grid_data
 
-                self._interpolation_func = polynomial_interpolation
+    def _build_interp_extrap(self):
+        """Build interpolation and extrapolation callables from the
+        ``interpolation`` submodule and store them directly as attributes.
 
-            case 2:  # akima
-
-                def akima_interpolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                    x_interval = bisect_left(x_data, x)
-                    x_interval = x_interval if x_interval != 0 else 1
-                    a = coeffs[4 * x_interval - 4 : 4 * x_interval]
-                    return a[3] * x**3 + a[2] * x**2 + a[1] * x + a[0]
-
-                self._interpolation_func = akima_interpolation
+        This replaces the old ``__set_interpolation_func``,
+        ``__update_interpolation_coefficients``, and
+        ``__set_extrapolation_func`` methods with a single setup call
+        that delegates all algorithmic work to factory functions.
+        """
+        if self._source_type is not SourceType.ARRAY:
+            return
 
-            case 3:  # spline
+        method = self.__interpolation__
+        extrap = self.__extrapolation__
 
-                def spline_interpolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                    x_interval = bisect_left(x_data, x)
-                    x_interval = max(x_interval, 1)
-                    a = coeffs[:, x_interval - 1]
-                    x = x - x_data[x_interval - 1]
-                    return a[3] * x**3 + a[2] * x**2 + a[1] * x + a[0]
+        if method == "regular_grid":
+            self._build_regular_grid_polation(extrap)
+            self.set_get_value_opt()
+            return
 
-                self._interpolation_func = spline_interpolation
+        if self.__dom_dim__ == 1:
+            self._evaluator = build_interpolation_evaluator(
+                method,
+                extrap,
+                dom_dim=1,
+                x=self.x_array,
+                y=self.y_array,
+            )
+            coeffs = self._evaluator.coefficients()
+            self._coeffs = [] if coeffs is None else coeffs
 
-            case 4:  # shepard
-                # pylint: disable=unused-argument
-                def shepard_interpolation(x, x_min, x_max, x_data, y_data, _):
-                    arg_qty, arg_dim = x.shape
-                    result = np.empty(arg_qty)
-                    x = x.reshape((arg_qty, 1, arg_dim))
-                    sub_matrix = x_data - x
-                    distances_squared = np.sum(sub_matrix**2, axis=2)
+            # Legacy coefficient attributes for backward compatibility
+            if method in ("spline", None) and isinstance(self._coeffs, np.ndarray):
+                self.__spline_coefficients__ = self._coeffs
+            elif method == "akima" and isinstance(self._coeffs, np.ndarray):
+                self.__akima_coefficients__ = self._coeffs
+            elif method == "polynomial" and isinstance(self._coeffs, np.ndarray):
+                self.__polynomial_coefficients__ = self._coeffs
 
-                    # Remove zero distances from further calculations
-                    zero_distances = np.where(distances_squared == 0)
-                    valid_indexes = np.ones(arg_qty, dtype=bool)
-                    valid_indexes[zero_distances[0]] = False
+        else:
+            self._evaluator = build_interpolation_evaluator(
+                method,
+                extrap,
+                dom_dim=self.__dom_dim__,
+                domain=self._domain,
+                image=self._image,
+            )
+            self._coeffs = []
 
-                    weights = distances_squared[valid_indexes] ** (-1.5)
-                    numerator_sum = np.sum(y_data * weights, axis=1)
-                    denominator_sum = np.sum(weights, axis=1)
-                    result[valid_indexes] = numerator_sum / denominator_sum
-                    result[~valid_indexes] = y_data[zero_distances[1]]
+        self.set_get_value_opt()
 
-                    return result
+    def _build_regular_grid_polation(self, extrap):
+        """Set up interpolation/extrapolation for regular_grid data.
 
-                self._interpolation_func = shepard_interpolation
+        Expects ``_grid_axes`` and ``_grid_data`` to already be set on
+        the instance (done by ``set_source`` when it receives a tuple).
+        Falls back to shepard interpolation if grid data is not available.
+        """
+        if not hasattr(self, "_grid_axes") or not hasattr(self, "_grid_data"):
+            warnings.warn(
+                "The 'regular_grid' interpolation requires '_grid_axes' and "
+                "'_grid_data' attributes. Since they are not set, "
+                "falling back to shepard interpolation.",
+                stacklevel=3,
+            )
+            self.__interpolation__ = "shepard"
+            self._build_interp_extrap()
+            return
+
+        self._evaluator = build_interpolation_evaluator(
+            "regular_grid",
+            extrap,
+            dom_dim=self.__dom_dim__,
+            grid_axes=self._grid_axes,
+            grid_data=self._grid_data,
+        )
+        self._coeffs = []
 
-            case 5:  # RBF
-                interpolator = RBFInterpolator(self._domain, self._image, neighbors=100)
+    def set_get_value_opt(self):
+        """Defines a method that evaluates interpolations.
 
-                def rbf_interpolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                    return interpolator(x)
+        Returns
+        -------
+        self : Function
+        """
+        if self._source_type is SourceType.ARRAY:
+            self._array_evaluate = self._evaluator.expose()
+            self._array_evaluate_scalar = (
+                self._evaluator.expose_scalar()
+                if hasattr(self._evaluator, "expose_scalar")
+                else self._array_evaluate
+            )
+            self._array_evaluate_vector = (
+                self._evaluator.expose_vector()
+                if hasattr(self._evaluator, "expose_vector")
+                else self._array_evaluate
+            )
+        self._get_value_scalar = self.__make_get_value_scalar()
+        self._get_value_vector = self.__make_get_value_vector()
+        self.get_value_opt = self.__make_get_value_opt()
+        return self
 
-                self._interpolation_func = rbf_interpolation
+    def __make_get_value_scalar(self):
+        """Build a scalar evaluator for this source type."""
+        if self._source_type is SourceType.ARRAY:
+            return self._array_evaluate_scalar
 
-            case 6:  # regular_grid (RegularGridInterpolator)
-                if not hasattr(self, "_grid_axes") or not hasattr(self, "_grid_data"):
-                    raise AttributeError(
-                        "The 'regular_grid' interpolation requires '_grid_axes' and "
-                        "'_grid_data' to be set on the Function instance before calling "
-                        "set_interpolation('regular_grid')."
-                    )
-                grid_interpolator = RegularGridInterpolator(
-                    self._grid_axes,
-                    self._grid_data,
-                    method="linear",
-                    bounds_error=True,
-                )
-                # Store so extrapolation funcs can reuse it
-                self._grid_interpolator = grid_interpolator
+        if self._source_type is SourceType.SCALAR:
+            scalar_value = self._scalar_value
 
-                def grid_interpolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                    return grid_interpolator(x)
+            def constant_scalar(*_):
+                return scalar_value
 
-                self._interpolation_func = grid_interpolation
+            return constant_scalar
 
-            case _:
-                raise ValueError(
-                    f"Interpolation {interpolation} method not recognized."
-                )
+        return self.source
 
-    def __set_extrapolation_func(self):  # pylint: disable=too-many-statements
-        """Defines extrapolation function used by the Function. Each
-        extrapolation method has its own function. The function is stored in
-        the attribute _extrapolation_func."""
-        interpolation = INTERPOLATION_TYPES[self.__interpolation__]
-        extrapolation = EXTRAPOLATION_TYPES[self.__extrapolation__]
-
-        match extrapolation:
-            case 0:  # zero
-
-                def zero_extrapolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                    return 0
-
-                self._extrapolation_func = zero_extrapolation
-            case 1:  # natural
-                match interpolation:
-                    case 0:  # linear
-                        if self.__dom_dim__ == 1:
-
-                            def natural_extrapolation(
-                                x, x_min, x_max, x_data, y_data, coeffs
-                            ):  # pylint: disable=unused-argument
-                                x_interval = 1 if x < x_min else -1
-                                x_left = x_data[x_interval - 1]
-                                y_left = y_data[x_interval - 1]
-                                dx = float(x_data[x_interval] - x_left)
-                                dy = float(y_data[x_interval] - y_left)
-                                return (x - x_left) * (dy / dx) + y_left
-
-                        else:
-                            interpolator = RBFInterpolator(
-                                self._domain, self._image, neighbors=100
-                            )
-
-                            def natural_extrapolation(
-                                x, x_min, x_max, x_data, y_data, coeffs
-                            ):  # pylint: disable=unused-argument
-                                return interpolator(x)
-
-                    case 1:  # polynomial
-
-                        def natural_extrapolation(  # pylint: disable=function-redefined
-                            x, x_min, x_max, x_data, y_data, coeffs
-                        ):  # pylint: disable=unused-argument
-                            return np.sum(coeffs * x ** np.arange(len(coeffs)))
-
-                    case 2:  # akima
-
-                        def natural_extrapolation(  # pylint: disable=function-redefined
-                            x, x_min, x_max, x_data, y_data, coeffs
-                        ):  # pylint: disable=unused-argument
-                            a = coeffs[:4] if x < x_min else coeffs[-4:]
-                            return a[3] * x**3 + a[2] * x**2 + a[1] * x + a[0]
-
-                    case 3:  # spline
-
-                        def natural_extrapolation(  # pylint: disable=function-redefined
-                            x, x_min, x_max, x_data, y_data, coeffs
-                        ):  # pylint: disable=unused-argument
-                            if x < x_min:
-                                a = coeffs[:, 0]
-                                x_offset = x - x_data[0]
-                            else:
-                                a = coeffs[:, -1]
-                                x_offset = x - x_data[-2]
-                            return (
-                                a[3] * x_offset**3
-                                + a[2] * x_offset**2
-                                + a[1] * x_offset
-                                + a[0]
-                            )
-
-                    case 4:  # shepard
-                        # pylint: disable=unused-argument,function-redefined
-                        def natural_extrapolation(x, x_min, x_max, x_data, y_data, _):
-                            arg_qty, arg_dim = x.shape
-                            result = np.empty(arg_qty)
-                            x = x.reshape((arg_qty, 1, arg_dim))
-                            sub_matrix = x_data - x
-                            distances_squared = np.sum(sub_matrix**2, axis=2)
-
-                            # Remove zero distances from further calculations
-                            zero_distances = np.where(distances_squared == 0)
-                            valid_indexes = np.ones(arg_qty, dtype=bool)
-                            valid_indexes[zero_distances[0]] = False
-
-                            weights = distances_squared[valid_indexes] ** (-1.5)
-                            numerator_sum = np.sum(y_data * weights, axis=1)
-                            denominator_sum = np.sum(weights, axis=1)
-                            result[valid_indexes] = numerator_sum / denominator_sum
-                            result[~valid_indexes] = y_data[zero_distances[1]]
-
-                            return result
-
-                    case 5:  # RBF
-                        interpolator = RBFInterpolator(
-                            self._domain, self._image, neighbors=100
+    def __make_get_value_vector(self):  # pylint: disable=too-many-statements
+        """Build a vector evaluator for this source type."""
+        if self._source_type is SourceType.ARRAY:
+            if self.__dom_dim__ == 1:
+                scalar_eval = self._array_evaluate_scalar
+                vector_eval = self._array_evaluate_vector
+                threshold = _LIST_VECTORIZE_THRESHOLD
+
+                def array_1d_vector(x):
+                    out_dtype = complex if np.iscomplexobj(x) else float
+                    x_array = np.asarray(x, dtype=out_dtype)
+                    output_shape = x_array.shape
+                    flat_x = x_array.ravel()
+                    if flat_x.size < threshold:
+                        values = np.array(
+                            [scalar_eval(xi) for xi in flat_x], dtype=out_dtype
                         )
+                    else:
+                        values = vector_eval(flat_x)
+                    return np.asarray(values).reshape(output_shape)
 
-                        def natural_extrapolation(  # pylint: disable=function-redefined
-                            x, x_min, x_max, x_data, y_data, coeffs
-                        ):  # pylint: disable=unused-argument
-                            return interpolator(x)
-
-                    case 6:  # regular_grid
-                        grid_extrapolator = RegularGridInterpolator(
-                            self._grid_axes,
-                            self._grid_data,
-                            method="linear",
-                            bounds_error=False,
-                            fill_value=None,  # linear extrapolation beyond edges
-                        )
+                return array_1d_vector
 
-                        def natural_extrapolation(  # pylint: disable=function-redefined
-                            x, x_min, x_max, x_data, y_data, coeffs
-                        ):  # pylint: disable=unused-argument
-                            return grid_extrapolator(x)
+            array_evaluate = self._array_evaluate
 
-                    case _:
-                        raise ValueError(
-                            f"Natural extrapolation not defined for {interpolation}."
-                        )
+            def array_nd_vector(*args):
+                broadcast_args = np.broadcast_arrays(*args)
+                output_shape = broadcast_args[0].shape
+                flat_args = (arg.ravel() for arg in broadcast_args)
+                values = array_evaluate(*flat_args, _is_iterable=True)
+                return np.asarray(values).reshape(output_shape)
 
-                self._extrapolation_func = natural_extrapolation
-            case 2:  # constant
-                if self.__dom_dim__ == 1:
+            return array_nd_vector
 
-                    def constant_extrapolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                        return y_data[0] if x < x_min else y_data[-1]
+        if self._source_type is SourceType.SCALAR:
+            scalar_value = self._scalar_value
 
-                elif self.__interpolation__ == "regular_grid":
-                    grid_axes = self._grid_axes
-                    grid_interpolator_const = self._grid_interpolator
+            def constant_vector(x, *_):
+                out_dtype = complex if np.iscomplexobj(scalar_value) else float
+                return np.full_like(x, scalar_value, dtype=out_dtype)
 
-                    def constant_extrapolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                        # Clamp each coordinate to its axis bounds, then interpolate
-                        x_clamped = np.copy(x)
-                        for i, axis in enumerate(grid_axes):
-                            x_clamped[:, i] = np.clip(
-                                x_clamped[:, i], axis[0], axis[-1]
-                            )
-                        return grid_interpolator_const(x_clamped)
+            return constant_vector
 
-                else:
-                    extrapolator = NearestNDInterpolator(self._domain, self._image)
+        source = self.source
+        if self.__dom_dim__ == 1:
+            if self.__vectorized_callable__:
 
-                    def constant_extrapolation(x, x_min, x_max, x_data, y_data, coeffs):  # pylint: disable=unused-argument
-                        return extrapolator(x)
+                def vectorized_callable_1d_vector(x):
+                    in_dtype = complex if np.iscomplexobj(x) else float
+                    return source(np.asarray(x, dtype=in_dtype))
 
-                self._extrapolation_func = constant_extrapolation
-            case _:
-                raise ValueError(
-                    f"Extrapolation {extrapolation} method not recognized."
-                )
+                return vectorized_callable_1d_vector
 
-    def set_get_value_opt(self):
-        """Defines a method that evaluates interpolations.
+            def callable_1d_vector(x):
+                x_array = np.asarray(x)
+                values = np.asarray([source(xi) for xi in x_array.ravel()])
+                out_dtype = complex if np.iscomplexobj(values) else float
+                return values.astype(out_dtype, copy=False).reshape(x_array.shape)
 
-        Returns
-        -------
-        self : Function
-        """
-        if self._source_type is SourceType.CALLABLE:
-            self.get_value_opt = self.source
-        elif self.__dom_dim__ == 1:
-            self.get_value_opt = self.__get_value_opt_1d
-        elif self.__dom_dim__ > 1:
-            self.get_value_opt = self.__get_value_opt_nd
-        return self
+            return callable_1d_vector
 
-    def __get_value_opt_1d(self, x):
-        """Evaluate the Function at a single point x. This method is used
-        when the Function is 1-D.
+        if self.__vectorized_callable__:
 
-        Parameters
-        ----------
-        x : scalar
-            Value where the Function is to be evaluated.
+            def vectorized_callable_nd_vector(*args):
+                args = np.broadcast_arrays(*args)
+                return source(*args)
 
-        Returns
-        -------
-        y : scalar
-            Value of the Function at the specified point.
-        """
-        # Retrieve general info
-        x_data = self.x_array
-        y_data = self.y_array
-        x_min, x_max = self.x_initial, self.x_final
-        coeffs = self._coeffs
-        if x_min <= x <= x_max:
-            y = self._interpolation_func(x, x_min, x_max, x_data, y_data, coeffs)
-        else:
-            y = self._extrapolation_func(x, x_min, x_max, x_data, y_data, coeffs)
-        return y
+            return vectorized_callable_nd_vector
 
-    def __get_value_opt_nd(self, *args):
-        """Evaluate the Function in a vectorized fashion for N-D domains.
+        def callable_nd_vector(*args):
+            broadcast_args = np.broadcast_arrays(*args)
+            output_shape = broadcast_args[0].shape
+            values = np.asarray(
+                [source(*a) for a in zip(*(arg.ravel() for arg in broadcast_args))]
+            )
+            out_dtype = complex if np.iscomplexobj(values) else float
+            return values.astype(out_dtype, copy=False).reshape(output_shape)
 
-        Parameters
-        ----------
-        args : tuple
-            Values where the Function is to be evaluated.
+        return callable_nd_vector
 
-        Returns
-        -------
-        result : scalar, ndarray
-            Value of the Function at the specified points.
-        """
-        args = np.column_stack(args)
-        arg_qty = len(args)
-        result = np.empty(arg_qty)
+    def __make_get_value_opt(self):
+        """Build a mixed scalar/vector evaluator that skips public validation."""
+        is_vector_argument = self.__is_vector_argument
+        scalar_eval = self._get_value_scalar
+        vector_eval = self._get_value_vector
 
-        min_domain = self._domain.T.min(axis=1)
-        max_domain = self._domain.T.max(axis=1)
+        if self.__dom_dim__ == 1:
 
-        lower, upper = args < min_domain, args > max_domain
-        extrap = np.logical_or(lower.any(axis=1), upper.any(axis=1))
+            def get_value_opt_1d(x):
+                if is_vector_argument(x):
+                    return vector_eval(x)
+                return scalar_eval(x)
 
-        if extrap.any():
-            result[extrap] = self._extrapolation_func(
-                args[extrap], min_domain, max_domain, self._domain, self._image, None
-            )
-        if (~extrap).any():
-            result[~extrap] = self._interpolation_func(
-                args[~extrap], min_domain, max_domain, self._domain, self._image, None
-            )
+            return get_value_opt_1d
 
-        if arg_qty == 1:
-            return float(result[0])
+        def get_value_opt_nd(*args):
+            if any(is_vector_argument(arg) for arg in args):
+                return vector_eval(*args)
+            return scalar_eval(*args)
 
-        return result
+        return get_value_opt_nd
+
+    def __resolve_bounds(self, lower, upper, samples):
+        """Normalize lower, upper and samples to lists of length ``dom_dim``.
 
-    def __determine_1d_domain_bounds(self, lower, upper):
-        """Determine domain bounds for 1-D function discretization.
+        For callable sources the default domain is ``[0, 10]`` per dimension.
+        Cropped-domain constraints are applied first; explicitly supplied
+        values always take precedence.
 
         Parameters
         ----------
-        lower : scalar, optional
-            Lower bound. If None, will use cropped domain or default.
-        upper : scalar, optional
-            Upper bound. If None, will use cropped domain or default.
+        lower : scalar, list, or None
+            Lower bound(s). Scalars are broadcast to all dimensions.
+        upper : scalar, list, or None
+            Upper bound(s). Scalars are broadcast to all dimensions.
+        samples : int, list, or None
+            Sample count(s). Scalars are broadcast to all dimensions.
 
         Returns
         -------
         tuple
-            (lower_bound, upper_bound) for the domain.
+            ``(lowers, uppers, samples_list)``, each a plain list of length
+            ``dom_dim``.
         """
-        domain = [0, 10]  # default boundaries
-        cropped = self.__cropped_domain__
+        n = self.__dom_dim__
+        default_lo = [0.0] * n
+        default_hi = [10.0] * n
 
-        if cropped[0] is not None and cropped[0] > domain[0]:
-            domain[0] = cropped[0]
-
-        if cropped[1] is not None and cropped[1] < domain[1]:
-            domain[1] = cropped[1]
+        # Tighten defaults with any recorded cropped-domain constraints
+        if self.__cropped_domain__ is not None:
+            for i, lim in enumerate(self.__cropped_domain__):
+                if i < n and lim is not None:
+                    lo, hi = lim
+                    if lo is not None:
+                        default_lo[i] = max(default_lo[i], lo)
+                    if hi is not None:
+                        default_hi[i] = min(default_hi[i], hi)
+
+        def _to_list(param, default):
+            if param is None:
+                return list(default)
+            if isinstance(param, NUMERICAL_TYPES):
+                return [float(param)] * n
+            return [float(p) for p in param]
+
+        if isinstance(samples, NUMERICAL_TYPES):
+            samples_list = [int(samples)] * n
+        elif samples is None:
+            samples_list = [50] * n
+        else:
+            samples_list = [int(s) for s in samples]
 
-        # Input bounds have preference
-        domain[0] = lower if lower is not None else domain[0]
-        domain[1] = upper if upper is not None else domain[1]
+        return _to_list(lower, default_lo), _to_list(upper, default_hi), samples_list
 
-        return domain
+    def __build_nd_grid(self, lowers, uppers, samples_list):
+        """Build an N-D evaluation grid and return flattened input columns.
 
-    def __determine_2d_domain_bounds(self, lower, upper, samples):
-        """Determine domain bounds for 2-D function discretization.
+        For 1-D returns a single-element list ``[xs]``. For higher dimensions
+        an open meshgrid is created and each axis array is ravelled.
 
         Parameters
         ----------
-        lower : scalar or list, optional
-            Lower bounds. If None, will use cropped domain or default.
-        upper : scalar or list, optional
-            Upper bounds. If None, will use cropped domain or default.
-        samples : int or list
-            Number of samples for each dimension.
+        lowers : list of float
+            Lower bound per dimension.
+        uppers : list of float
+            Upper bound per dimension.
+        samples_list : list of int
+            Number of sample points per dimension.
 
         Returns
         -------
-        tuple
-            (lower_bounds, upper_bounds, sample_counts) for the 2D domain.
+        list of ndarray
+            One flat array per input dimension; all arrays share the same
+            length (``samples_list[0]`` for 1-D, or the product of all
+            sample counts for N-D).
         """
-        default_bounds = [[0, 10], [0, 10]]
-
-        # Apply cropped domain constraints if they exist
-        final_bounds = deepcopy(default_bounds)
-        if self.__cropped_domain__ is not None:
-            for dim in range(2):
-                cropped_limits = self.__cropped_domain__[dim]
-                if cropped_limits is not None:
-                    # Use the more restrictive bounds (cropped domain takes precedence)
-                    final_bounds[dim][0] = max(
-                        default_bounds[dim][0], cropped_limits[0]
-                    )
-                    final_bounds[dim][1] = min(
-                        default_bounds[dim][1], cropped_limits[1]
-                    )
-
-        # Convert parameters to consistent list format
-        lower_bounds = self.__normalize_2d_parameter(
-            lower, [final_bounds[0][0], final_bounds[1][0]]
-        )
-        upper_bounds = self.__normalize_2d_parameter(
-            upper, [final_bounds[0][1], final_bounds[1][1]]
-        )
-        sample_counts = self.__normalize_2d_parameter(samples, samples)
-
-        return lower_bounds, upper_bounds, sample_counts
-
-    def __normalize_2d_parameter(self, param, default_values):
-        if param is None:
-            return (
-                default_values
-                if isinstance(default_values, list)
-                else [default_values, default_values]
-            )
-
-        if isinstance(param, NUMERICAL_TYPES):
-            return [param, param]
-
-        return param
-
-    def __discretize_1d_function(
-        self, func, lower, upper, samples, interpolation, extrapolation, one_by_one
-    ):
-        lower, upper = self.__determine_1d_domain_bounds(lower, upper)
-        xs = np.linspace(lower, upper, samples)
-        ys = func.get_value(xs.tolist()) if one_by_one else func.get_value(xs)
-        func.__interpolation__ = interpolation
-        func.__extrapolation__ = extrapolation
-        func.set_source(np.column_stack((xs, ys)))
-
-    def __discretize_2d_function(self, func, lower, upper, samples):
-        lower, upper, sam = self.__determine_2d_domain_bounds(lower, upper, samples)
-
-        # Create nodes to evaluate function
-        xs = np.linspace(lower[0], upper[0], sam[0])
-        ys = np.linspace(lower[1], upper[1], sam[1])
-        xs, ys = np.array(np.meshgrid(xs, ys)).reshape(2, xs.size * ys.size)
-
-        # Evaluate function at all mesh nodes and convert it to matrix
-        zs = np.array(func.get_value(xs, ys))
-        func.set_source(np.concatenate(([xs], [ys], [zs])).transpose())
-        func.__interpolation__ = "shepard"
-        func.__extrapolation__ = "natural"
+        axes = [
+            np.linspace(lowers[i], uppers[i], samples_list[i])
+            for i in range(self.__dom_dim__)
+        ]
+        if self.__dom_dim__ == 1:
+            return axes
+        mesh = np.meshgrid(*axes)
+        return [m.ravel() for m in mesh]
 
     def set_discrete(
         self,
@@ -962,7 +914,7 @@ def set_discrete(
         samples=200,
         interpolation="spline",
         extrapolation="constant",
-        one_by_one=True,
+        one_by_one=True,  # pylint: disable=unused-argument
         mutate_self=True,
     ):
         """This method discretizes a 1-D or 2-D Function by evaluating it at
@@ -1014,27 +966,30 @@ def set_discrete(
         1. This method performs by default in place replacement of the original
         Function object source. This can be changed by the attribute `mutate_self`.
 
-        2. Currently, this method only supports 1-D and 2-D Functions.
+        2. For N-D functions (dim > 1) the interpolation is forced to
+        ``shepard`` and extrapolation to ``natural``, regardless of the
+        arguments passed.
         """
         func = deepcopy(self) if not mutate_self else self
 
-        if func.__dom_dim__ == 1:
-            self.__discretize_1d_function(
-                func, lower, upper, samples, interpolation, extrapolation, one_by_one
-            )
-        elif func.__dom_dim__ == 2:
-            self.__discretize_2d_function(func, lower, upper, samples)
+        lowers, uppers, samples = self.__resolve_bounds(lower, upper, samples)
+        columns = self.__build_nd_grid(lowers, uppers, samples)
+
+        if self.__dom_dim__ == 1:
+            func.__interpolation__ = interpolation
+            func.__extrapolation__ = extrapolation
         else:
-            raise ValueError(
-                "Discretization is only supported for 1-D and 2-D Functions."
-            )
-        return func
+            func.__interpolation__ = "shepard"
+            func.__extrapolation__ = "natural"
+
+        zs = np.array(func.get_value(*columns))
+        return func.set_source(np.column_stack(columns + [zs]), validate=False)
 
     def set_discrete_based_on_model(
         self, model_function, one_by_one=True, keep_self=True, mutate_self=True
-    ):
-        """This method transforms the domain of a 1-D or 2-D Function instance
-        into a list of discrete points based on the domain of a model Function
+    ):  # pylint: disable=unused-argument
+        """This method transforms the domain of an N-D Function instance into a
+        list of discrete points based on the domain of a model Function
         instance. It does so by retrieving the domain, domain name,
         interpolation method and extrapolation method of the model Function
         instance. It then evaluates the original Function instance in all
@@ -1116,42 +1071,24 @@ def set_discrete_based_on_model(
         2. This method is similar to set_discrete, but it uses the domain of a
         model Function to define the domain of the new Function instance.
 
-        3. Currently, this method only supports 1-D and 2-D Functions.
+        3. This method supports functions of any domain dimension.
         """
-        if model_function._source_type is SourceType.CALLABLE:
+        if model_function._source_type is not SourceType.ARRAY:
             raise TypeError("model_function must be a list based Function.")
         if model_function.__dom_dim__ != self.__dom_dim__:
             raise ValueError("model_function must have the same domain dimension.")
 
         func = deepcopy(self) if not mutate_self else self
 
-        if func.__dom_dim__ == 1:
-            xs = model_function.source[:, 0]
-            ys = func.get_value(xs.tolist()) if one_by_one else func.get_value(xs)
-            func.set_source(np.concatenate(([xs], [ys])).transpose())
-        elif func.__dom_dim__ == 2:
-            # Create nodes to evaluate function
-            xs = model_function.source[:, 0]
-            ys = model_function.source[:, 1]
-            # Evaluate function at all mesh nodes and convert it to matrix
-            zs = np.array(func.get_value(xs, ys))
-            func.set_source(np.concatenate(([xs], [ys], [zs])).transpose())
-        else:
-            raise ValueError(
-                "Discretization is only supported for 1-D and 2-D Functions."
-            )
-
-        interp = (
-            func.__interpolation__ if keep_self else model_function.__interpolation__
-        )
-        extrap = (
-            func.__extrapolation__ if keep_self else model_function.__extrapolation__
-        )
+        if not keep_self:
+            func.__interpolation__ = model_function.__interpolation__
+            func.__extrapolation__ = model_function.__extrapolation__
 
-        func.set_interpolation(interp)
-        func.set_extrapolation(extrap)
+        n = func.__dom_dim__
+        columns = [model_function.source[:, i] for i in range(n)]
 
-        return func
+        zs = np.array(func.get_value(*columns))
+        return func.set_source(np.column_stack(columns + [zs]), validate=False)
 
     def reset(
         self,
@@ -1214,64 +1151,40 @@ def reset(
 
         return self
 
-    def __crop_array_source(self, cropped_func, x_lim):
-        """Crop the array source of a Function based on domain limits.
-
-        Parameters
-        ----------
-        cropped_func : Function
-            The Function instance to be cropped.
-        x_lim : list[tuple]
-            Range of values with lower and upper limits for cropping.
-        """
-        if cropped_func.__dom_dim__ == 1:
-            cropped_func.source = cropped_func.source[
-                (cropped_func.source[:, 0] >= x_lim[0][0])
-                & (cropped_func.source[:, 0] <= x_lim[0][1])
-            ]
-        elif cropped_func.__dom_dim__ == 2:
-            cropped_func.source = cropped_func.source[
-                (cropped_func.source[:, 0] >= x_lim[0][0])
-                & (cropped_func.source[:, 0] <= x_lim[0][1])
-                & (cropped_func.source[:, 1] >= x_lim[1][0])
-                & (cropped_func.source[:, 1] <= x_lim[1][1])
-            ]
-
-    def __set_cropped_domain_1d(self, cropped_func, x_lim):
-        """Set the cropped domain for 1-D functions.
-
-        Parameters
-        ----------
-        cropped_func : Function
-            The Function instance to set the cropped domain for.
-        x_lim : list[tuple]
-            Range of values with lower and upper limits.
-        """
-        if x_lim[0][0] < x_lim[0][1]:
-            cropped_func.__cropped_domain__ = x_lim[0]
+    def __crop_input(self, func, x_lim):
+        """Restrict input domain of func to the intervals in x_lim.
 
-    def __set_cropped_domain_2d(self, cropped_func, x_lim):
-        """Set the cropped domain for 2-D functions.
+        Records the bounds in ``func.__cropped_domain__`` for any source type
+        so that plotting and discretisation helpers can honour the restriction.
+        For array sources the rows that fall outside the specified ranges are
+        also removed in-place via a vectorised boolean mask.
 
         Parameters
         ----------
-        cropped_func : Function
-            The Function instance to set the cropped domain for.
-        x_lim : list[tuple]
-            Range of values with lower and upper limits.
+        func : Function
+            The Function instance to be modified in-place.
+        x_lim : list[tuple | None]
+            Per-dimension ``(lower, upper)`` pairs.  ``None`` entries skip
+            that dimension.
         """
-        if len(x_lim) < 2:
-            raise IndexError("x_lim must have a length of 2 for 2-D function")
-
-        if x_lim[0] is not None and x_lim[0][0] < x_lim[0][1]:
-            cropped_func.__cropped_domain__ = [x_lim[0]]
-        else:
-            cropped_func.__cropped_domain__ = [None]
-
-        if len(x_lim) >= 2 and x_lim[1] is not None and x_lim[1][0] < x_lim[1][1]:
-            cropped_func.__cropped_domain__.append(x_lim[1])
-        else:
-            cropped_func.__cropped_domain__.append(None)
+        n = func.__dom_dim__
+
+        # Build unified cropped_domain list: [(lo, hi) | None, ...]
+        cropped = [None] * n
+        for i, lim in enumerate(x_lim):
+            if lim is not None and lim[0] < lim[1]:
+                cropped[i] = lim
+        func.__cropped_domain__ = cropped
+
+        # Mask array data with a single vectorised pass
+        if isinstance(func.source, np.ndarray):
+            mask = np.ones(len(func.source), dtype=bool)
+            for i, lim in enumerate(x_lim):
+                if lim is not None:
+                    mask &= (func.source[:, i] >= lim[0]) & (
+                        func.source[:, i] <= lim[1]
+                    )
+            func.source = func.source[mask]
 
     def crop(self, x_lim):
         """Restrict the **input** domain of the Function to specified ranges.
@@ -1334,65 +1247,55 @@ def crop(self, x_lim):
             )
 
         cropped_func = deepcopy(self)
-
-        if isinstance(cropped_func.source, np.ndarray):
-            self.__crop_array_source(cropped_func, x_lim)
-
-        if cropped_func.__dom_dim__ == 1:
-            self.__set_cropped_domain_1d(cropped_func, x_lim)
-        elif cropped_func.__dom_dim__ == 2:
-            self.__set_cropped_domain_2d(cropped_func, x_lim)
-
-        cropped_func.set_source(cropped_func.source)
+        self.__crop_input(cropped_func, x_lim)
+        cropped_func.set_source(cropped_func.source, validate=False)
         return cropped_func
 
-    def __validate_clip_parameters(self, y_lim):
-        if not isinstance(y_lim, list):
-            raise TypeError("y_lim must be a list of tuples.")
-
-        if len(y_lim) != len(self.__outputs__):
-            raise ValueError(
-                "y_lim must have the same length as the output dimensions."
-            )
+    def __clip_output(self, func, y_lim: list[tuple]):
+        """Restrict the output of func to the ranges specified in y_lim.
 
-    def __clip_array_source(self, clipped_func, y_lim: list[tuple]):
-        clipped_func.source = clipped_func.source[
-            (clipped_func.source[:, clipped_func.__dom_dim__] >= y_lim[0][0])
-            & (clipped_func.source[:, clipped_func.__dom_dim__] <= y_lim[0][1])
-        ]
+        Dispatches on the source type of func:
 
-    def __clip_numerical_source(self, clipped_func, y_lim: list[tuple]):
-        try:
-            if clipped_func.source < y_lim[0][0]:
-                raise ArithmeticError("Constant function outside range")
-            if clipped_func.source > y_lim[0][1]:
-                raise ArithmeticError("Constant function outside range")
-        except TypeError as e:
-            raise TypeError("y_lim must be the same type as the function source") from e
-
-    def __clip_callable_source(self, clipped_func, y_lim: list[tuple]):
-        original_function = clipped_func.source
-
-        def clipped_function(*args):
-            results = original_function(*args)
-            clipped_results = []
-
-            if isinstance(results, (tuple, list)):
-                # Multi-dimensional output
-                for i, (lower, upper) in enumerate(y_lim):
-                    clipped_results.append(max(lower, min(upper, results[i])))
-            else:
-                # Single value output
-                for lower, upper in y_lim:
-                    clipped_results.append(max(lower, min(upper, results)))
-
-            return (
-                tuple(clipped_results)
-                if len(clipped_results) > 1
-                else clipped_results[0]
-            )
+        - ``ndarray``: removes rows whose output column falls outside the
+          range via a vectorised boolean mask.
+        - Scalar: raises ``ArithmeticError`` when the constant value is
+          outside every output range.
+        - Callable: wraps the callable so that returned values are clamped
+          to the specified ranges without removing any inputs.
 
-        clipped_func.source = clipped_function
+        Parameters
+        ----------
+        func : Function
+            The Function instance to be modified in-place.
+        y_lim : list[tuple]
+            Per-output ``(lower, upper)`` pairs.
+        """
+        if isinstance(func.source, np.ndarray):
+            mask = np.ones(len(func.source), dtype=bool)
+            for i, (lo, hi) in enumerate(y_lim):
+                col = func.__dom_dim__ + i
+                mask &= (func.source[:, col] >= lo) & (func.source[:, col] <= hi)
+            func.source = func.source[mask]
+        elif func._source_type is SourceType.SCALAR:
+            # Clamp the scalar value to the output range
+            lo, hi = y_lim[0]
+            clamped = max(lo, min(hi, func._scalar_value))
+            func.set_source(clamped)
+        elif callable(func.source):
+            original = func.source
+
+            def _clipped(*args):
+                result = original(*args)
+                if isinstance(result, (tuple, list)):
+                    clipped = [
+                        max(lo, min(hi, result[i])) for i, (lo, hi) in enumerate(y_lim)
+                    ]
+                    return tuple(clipped) if len(clipped) > 1 else clipped[0]
+                # Scalar result
+                lo, hi = y_lim[0]
+                return max(lo, min(hi, result))
+
+            func.source = _clipped
 
     def clip(self, y_lim):
         """Restrict the **output** values of the Function to specified ranges.
@@ -1421,36 +1324,26 @@ def clip(self, y_lim):
         Examples
         --------
         >>> from rocketpy import Function
-        >>>
         >>> f = Function(lambda x: x**2, inputs='x', outputs='y')
-        >>> print(f)
-        Function from R1 to R1 : (x) → (y)
-        >>> f_clipped = f.clip([(-5, 5)])
-        >>> print(f_clipped)
-        Function from R1 to R1 : (x) → (y)
+        >>> f_clip = f.clip([(-5.0, 5.0)])
+        >>> f.get_value(-3.0), f.get_value(0.0), f.get_value(3.0)
+        (9.0, 0.0, 9.0)
+        >>> f_clip.get_value(-3.0), f_clip.get_value(0.0), f_clip.get_value(3.0)
+        (5.0, 0.0, 5.0)
         """
-        self.__validate_clip_parameters(y_lim)
+        if not isinstance(y_lim, list):
+            raise TypeError("y_lim must be a list of tuples.")
 
-        clipped_func = deepcopy(self)
+        if len(y_lim) != len(self.__outputs__):
+            raise ValueError(
+                "y_lim must have the same length as the output dimensions."
+            )
 
-        if isinstance(clipped_func.source, np.ndarray):
-            self.__clip_array_source(clipped_func, y_lim)
-        elif isinstance(clipped_func.source, NUMERICAL_TYPES):
-            self.__clip_numerical_source(clipped_func, y_lim)
-        elif callable(clipped_func.source):
-            self.__clip_callable_source(clipped_func, y_lim)
+        clipped_func = deepcopy(self)
 
-        try:
-            clipped_func.set_source(clipped_func.source)
-        except ValueError as e:
-            raise ValueError(
-                "Cannot clip function as function reduces to "
-                f"{len(clipped_func.source) if isinstance(clipped_func.source, (list, np.ndarray)) else 'unknown'} points (too few data points to define"
-                " a domain). Ensure that the source is array-like and has "
-                "sufficient data points after applying the clipping function."
-            ) from e
+        self.__clip_output(clipped_func, y_lim)
 
-        return clipped_func
+        return clipped_func.set_source(clipped_func.source)
 
     # Define all get methods
     def get_inputs(self):
@@ -1465,6 +1358,40 @@ def get_source(self):
         "Return source list or function of the Function."
         return self.source
 
+    def get_source_type(self):
+        """Return the Function source type.
+
+        Returns
+        -------
+        SourceType
+            Enum describing whether the source is callable, array, or scalar.
+        """
+        return self._source_type
+
+    def is_scalar_source(self):
+        """Return True if the Function is a constant scalar source."""
+        return self._source_type is SourceType.SCALAR
+
+    def is_array_source(self):
+        """Return True if the Function source is array-based."""
+        return self._source_type is SourceType.ARRAY
+
+    def is_callable_source(self):
+        """Return True if the Function source is callable-based."""
+        return self._source_type is SourceType.CALLABLE
+
+    def get_scalar_value(self):
+        """Return the scalar value for a constant Function.
+
+        Raises
+        ------
+        ValueError
+            If the Function is not scalar-based.
+        """
+        if self._source_type is not SourceType.SCALAR:
+            raise ValueError("Function is not scalar-based")
+        return self._scalar_value
+
     def get_image_dim(self):
         "Return int describing dimension of the image space of the function."
         return self.__img_dim__
@@ -1488,18 +1415,26 @@ def get_value(self, *args):
 
         Parameters
         ----------
-        args : scalar, list
-            Value where the Function is to be evaluated. If the Function is
-            1-D, only one argument is expected, which may be an int, a float
-            or a list of ints or floats, in which case the Function will be
-            evaluated at all points in the list and a list of floats will be
-            returned. If the function is N-D, N arguments must be given, each
-            one being an scalar or list.
+        args : scalar or array-like
+            Coordinates where the Function is evaluated. A 1-D Function takes
+            one argument. An N-D Function takes N arguments; array-like
+            arguments are broadcast together and evaluated pointwise.
+
+            Callable sources support complex coordinates only when the supplied
+            callable accepts and correctly handles complex values. For sampled
+            sources, complex coordinates are not a general interpolation
+            domain: the 1-D implementation propagates an infinitesimal
+            imaginary perturbation only for complex-step differentiation, with
+            interval selection based on the real component. Sampled N-D
+            sources do not support complex coordinates.
 
         Returns
         -------
-        ans : scalar, list
-            Value of the Function at the specified point(s).
+        scalar or np.ndarray
+            Value of the Function at the specified point, or an array of
+            values for array-like coordinates. The result may be complex when
+            a compatible callable source produces complex values or while the
+            1-D implementation is being used for complex-step differentiation.
 
         Examples
         --------
@@ -1513,9 +1448,11 @@ def get_value(self, *args):
         >>> f.get_value(2.5)
         6.25
         >>> f.get_value([1, 2, 3])
-        [1, 4, 9]
+        array([1., 4., 9.])
         >>> f.get_value([1, 2.5, 4.0])
-        [1, 6.25, 16.0]
+        array([ 1.  ,  6.25, 16.  ])
+        >>> f.get_value(1 + 2j)
+        (-3+4j)
 
         Testing with callable source (2 dimensions):
 
@@ -1523,9 +1460,9 @@ def get_value(self, *args):
         >>> f2.get_value(1, 2)
         5
         >>> f2.get_value([1, 2, 3], [1, 2, 3])
-        [2, 8, 18]
+        array([ 2.,  8., 18.])
         >>> f2.get_value([5], [5])
-        [50]
+        array([50.])
 
         Testing with ndarray source (1 dimension):
 
@@ -1537,9 +1474,9 @@ def get_value(self, *args):
         >>> f3.get_value(2.5)
         np.float64(6.25)
         >>> f3.get_value([1, 2, 3])
-        [np.float64(1.0), np.float64(4.0), np.float64(9.0)]
+        array([1., 4., 9.])
         >>> f3.get_value([1, 2.5, 4.0])
-        [np.float64(1.0), np.float64(6.25), np.float64(16.0)]
+        array([ 1.  ,  6.25, 16.  ])
 
         Testing with ndarray source (2 dimensions):
 
@@ -1560,40 +1497,16 @@ def get_value(self, *args):
                 f"This Function takes {self.__dom_dim__} arguments, {len(args)} given."
             )
 
-        # Return value for Function of function type
-        if self._source_type is SourceType.CALLABLE:
-            # if the function is 1-D:
-            if self.__dom_dim__ == 1:
-                # if the args is a simple number (int or float)
-                if isinstance(args[0], NUMERICAL_TYPES):
-                    return self.source(args[0])
-                # if the arguments are iterable, we map and return a list
-                if isinstance(args[0], Iterable):
-                    return list(map(self.source, args[0]))
-
-            # if the function is n-D:
-            else:
-                # if each arg is a simple number (int or float)
-                if all(isinstance(arg, NUMERICAL_TYPES) for arg in args):
-                    return self.source(*args)
-                # if each arg is iterable, we map and return a list
-                if all(isinstance(arg, Iterable) for arg in args):
-                    return [self.source(*arg) for arg in zip(*args)]
-
-        elif self.__dom_dim__ > 1:  # deals with nd functions
-            return self.get_value_opt(*args)
-
-        # Returns value for other interpolation type
-        else:  # interpolation is "polynomial", "spline", "akima" or "linear"
-            if isinstance(args[0], NUMERICAL_TYPES):
-                args = [list(args)]
-
-        x = list(args[0])
-        x = list(map(self.get_value_opt, x))
-        if isinstance(args[0], np.ndarray):
-            return np.array(x)
-        else:
-            return x if len(x) > 1 else x[0]
+        if self.__dom_dim__ == 1:
+            arg = args[0]
+            if self.__is_vector_argument(arg):
+                return self._get_value_vector(arg)
+            return self._get_value_scalar(arg)
+
+        if any(self.__is_vector_argument(arg) for arg in args):
+            return self._get_value_vector(*args)
+
+        return self._get_value_scalar(*args)
 
     def __getitem__(self, args):
         """Returns item of the Function source. If the source is not an array,
@@ -1867,6 +1780,7 @@ def low_pass_filter(self, alpha, file_path=None):
             interpolation=self.__interpolation__,
             extrapolation=self.__extrapolation__,
             title=self.title,
+            validate=False,
         )
 
     def remove_outliers_iqr(self, threshold=1.5):
@@ -1888,9 +1802,9 @@ def remove_outliers_iqr(self, threshold=1.5):
         [1] https://en.wikipedia.org/wiki/Outlier#Tukey's_fences
         """
 
-        if self._source_type is SourceType.CALLABLE:
+        if self._source_type is not SourceType.ARRAY:
             raise TypeError(
-                "Cannot remove outliers if the source is a callable object."
+                "Cannot remove outliers if the source is not array-based."
                 + " The Function.source should be array-like."
             )
 
@@ -1912,6 +1826,7 @@ def remove_outliers_iqr(self, threshold=1.5):
             interpolation=self.__interpolation__,
             extrapolation=self.__extrapolation__,
             title=self.title,
+            validate=False,
         )
 
     # Define all presentation methods
@@ -1939,10 +1854,10 @@ def __call__(self, *args, filename=None):
         -------
         ans : None, scalar, list
         """
-        if len(args) == 0:
+        if not args:
             return self.plot(filename=filename)
-        else:
-            return self.get_value(*args)
+
+        return self.get_value(*args)
 
     def __str__(self):
         "Return a string representation of the Function"
@@ -2012,7 +1927,7 @@ def plot(self, *args, **kwargs):
             elif self.__dom_dim__ == 2:
                 self.plot_2d(*args, **kwargs)
             else:
-                print("Error: Only functions with 1D or 2D domains can be plotted.")
+                logger.error("Only functions with 1D or 2D domains can be plotted.")
 
     @deprecated(
         reason="The `Function.plot1D` method is set to be deprecated and fully "
@@ -2077,13 +1992,20 @@ def plot_1d(  # pylint: disable=too-many-statements
         # Define a mesh and y values at mesh nodes for plotting
         fig = plt.figure()
         ax = fig.axes
-        if self._source_type is SourceType.CALLABLE:
+        if self._source_type is not SourceType.ARRAY:
             # Determine boundaries
             domain = [0, 10]
-            if self.__cropped_domain__[0] and self.__cropped_domain__[0] > domain[0]:
-                domain[0] = self.__cropped_domain__[0]
-            if self.__cropped_domain__[1] and self.__cropped_domain__[1] < domain[1]:
-                domain[1] = self.__cropped_domain__[1]
+            # pylint: disable=unsubscriptable-object
+            if (
+                self.__cropped_domain__ is not None
+                and self.__cropped_domain__[0] is not None
+            ):
+                lo, hi = self.__cropped_domain__[0]
+                # pylint: enable=unsubscriptable-object
+                if lo is not None and lo > domain[0]:
+                    domain[0] = lo
+                if hi is not None and hi < domain[1]:
+                    domain[1] = hi
             lower = domain[0] if lower is None else lower
             upper = domain[1] if upper is None else upper
         else:
@@ -2096,13 +2018,13 @@ def plot_1d(  # pylint: disable=too-many-statements
             too_low = x_min >= lower
             too_high = x_max <= upper
             lo_ind = 0 if too_low else np.where(x_data >= lower)[0][0]
-            up_ind = len(x_data) - 1 if too_high else np.where(x_data <= upper)[0][0]
-            points = self.source[lo_ind : (up_ind + 1), :].T.tolist()
+            up_ind = len(x_data) - 1 if too_high else np.where(x_data <= upper)[0][-1]
+            points = self.source[lo_ind : (up_ind + 1), :].T
             if force_data:
                 plt.scatter(points[0], points[1], marker="o")
         # Calculate function at mesh nodes
         x = np.linspace(lower, upper, samples)
-        y = self.get_value(x.tolist())
+        y = self.get_value(x)
         # Plots function
         if force_points:
             plt.scatter(x, y, marker="o")
@@ -2193,7 +2115,7 @@ def plot_2d(  # pylint: disable=too-many-statements
         figure = plt.figure()
         axes = figure.add_subplot(111, projection="3d")
         # Define a mesh and f values at mesh nodes for plotting
-        if self._source_type is SourceType.CALLABLE:
+        if self._source_type is not SourceType.ARRAY:
             # Determine boundaries
             domain = [[0, 10], [0, 10]]
             if self.__cropped_domain__ is not None:
@@ -2350,14 +2272,14 @@ def compare_plots(  # pylint: disable=too-many-statements
         if lower is None:
             lower = 0
             for plot in plots:
-                if not callable(plot[0].source):
+                if plot[0]._source_type is SourceType.ARRAY:
                     # Determine boundaries
                     x_min = plot[0].source[0, 0]
                     lower = x_min if x_min < lower else lower
         if upper is None:
             upper = 10
             for plot in plots:
-                if not callable(plot[0].source):
+                if plot[0]._source_type is SourceType.ARRAY:
                     # Determine boundaries
                     x_max = plot[0].source[-1, 0]
                     upper = x_max if x_max > upper else upper
@@ -2366,7 +2288,7 @@ def compare_plots(  # pylint: disable=too-many-statements
         # Iterate to plot all plots
         for plot in plots:
             # Deal with discrete data sets when no range is given
-            if no_range_specified and not callable(plot[0].source):
+            if no_range_specified and plot[0]._source_type is SourceType.ARRAY:
                 ax.plot(plot[0][:, 0], plot[0][:, 1], label=plot[1])
                 if force_points:
                     ax.scatter(plot[0][:, 0], plot[0][:, 1], marker="o")
@@ -2381,7 +2303,7 @@ def compare_plots(  # pylint: disable=too-many-statements
         # Plot data points if specified
         if force_data:
             for plot in plots:
-                if not callable(plot[0].source):
+                if plot[0]._source_type is SourceType.ARRAY:
                     x_data = plot[0].source[:, 0]
                     x_min, x_max = x_data[0], x_data[-1]
                     too_low = x_min >= lower
@@ -2409,85 +2331,6 @@ def compare_plots(  # pylint: disable=too-many-statements
         if return_object:
             return fig, ax
 
-    # Define all interpolation methods
-    def __interpolate_polynomial__(self):
-        """Calculate polynomial coefficients that fit the data exactly."""
-        # Find the degree of the polynomial interpolation
-        degree = self.source.shape[0] - 1
-        # Get x and y values for all supplied points.
-        x = self.x_array
-        y = self.y_array
-        # Check if interpolation requires large numbers
-        if np.amax(x) ** degree > 1e308:
-            warnings.warn(
-                "Polynomial interpolation of too many points can't be done."
-                " Once the degree is too high, numbers get too large."
-                " The process becomes inefficient. Using spline instead."
-            )
-            return self.set_interpolation("spline")
-        # Create coefficient matrix1
-        sys_coeffs = np.zeros((degree + 1, degree + 1))
-        for i in range(degree + 1):
-            sys_coeffs[:, i] = x**i
-        # Solve the system and store the resultant coefficients
-        self.__polynomial_coefficients__ = np.linalg.solve(sys_coeffs, y)
-
-    def __interpolate_spline__(self):
-        """Calculate natural spline coefficients that fit the data exactly."""
-        # Get x and y values for all supplied points
-        x, y = self.x_array, self.y_array
-        m_dim = len(x)
-        h = np.diff(x)
-        # Initialize the matrix
-        banded_matrix = np.zeros((3, m_dim))
-        banded_matrix[1, 0] = banded_matrix[1, m_dim - 1] = 1
-        # Construct the Ab banded matrix and B vector
-        vector_b = [0]
-        banded_matrix[2, :-2] = h[:-1]
-        banded_matrix[1, 1:-1] = 2 * (h[:-1] + h[1:])
-        banded_matrix[0, 2:] = h[1:]
-        vector_b.extend(3 * ((y[2:] - y[1:-1]) / h[1:] - (y[1:-1] - y[:-2]) / h[:-1]))
-        vector_b.append(0)
-        # Solve the system for c coefficients
-        c = linalg.solve_banded(
-            (1, 1), banded_matrix, vector_b, overwrite_ab=True, overwrite_b=True
-        )
-        # Calculate other coefficients
-        b = (y[1:] - y[:-1]) / h - h * (2 * c[:-1] + c[1:]) / 3
-        d = (c[1:] - c[:-1]) / (3 * h)
-        # Store coefficients
-        self.__spline_coefficients__ = np.vstack([y[:-1], b, c[:-1], d])
-
-    def __interpolate_akima__(self):
-        """Calculate akima spline coefficients that fit the data exactly"""
-        # Get x and y values for all supplied points
-        x, y = self.x_array, self.y_array
-        # Estimate derivatives at each point
-        d = [0] * len(x)
-        d[0] = (y[1] - y[0]) / (x[1] - x[0])
-        d[-1] = (y[-1] - y[-2]) / (x[-1] - x[-2])
-        for i in range(1, len(x) - 1):
-            w1, w2 = (x[i] - x[i - 1]), (x[i + 1] - x[i])
-            d1, d2 = ((y[i] - y[i - 1]) / w1), ((y[i + 1] - y[i]) / w2)
-            d[i] = (w1 * d2 + w2 * d1) / (w1 + w2)
-        # Calculate coefficients for each interval with system already solved
-        coeffs = [0] * 4 * (len(x) - 1)
-        for i in range(len(x) - 1):
-            xl, xr = x[i], x[i + 1]
-            yl, yr = y[i], y[i + 1]
-            dl, dr = d[i], d[i + 1]
-            matrix = np.array(
-                [
-                    [1, xl, xl**2, xl**3],
-                    [1, xr, xr**2, xr**3],
-                    [0, 1, 2 * xl, 3 * xl**2],
-                    [0, 1, 2 * xr, 3 * xr**2],
-                ]
-            )
-            result = np.array([yl, yr, dl, dr]).T
-            coeffs[4 * i : 4 * i + 4] = np.linalg.solve(matrix, result)
-        self.__akima_coefficients__ = coeffs
-
     def __neg__(self):
         """Negates the Function object. The result has the same effect as
         multiplying the Function by -1.
@@ -2497,15 +2340,21 @@ def __neg__(self):
         Function
             The negated Function object.
         """
-        if self._source_type is SourceType.ARRAY:
-            neg_source = self.source.copy()
-            neg_source[:, -1] = -neg_source[:, -1]
+        if self._source_type is SourceType.SCALAR:
             return Function(
-                neg_source,
+                -self._scalar_value,
                 self.__inputs__,
                 self.__outputs__,
-                self.__interpolation__,
-                self.__extrapolation__,
+                validate=False,
+            )
+        elif self._source_type is SourceType.ARRAY:
+            return Function._from_sorted_arrays(
+                self._domain,
+                -self._image,
+                self.__inputs__,
+                self.__outputs__,
+                self.__interpolation__,
+                self.__extrapolation__,
             )
         else:
             if self.__dom_dim__ == 1:
@@ -2513,16 +2362,21 @@ def __neg__(self):
                     lambda x: -self.source(x),
                     self.__inputs__,
                     self.__outputs__,
+                    vectorized_callable=self.__vectorized_callable__,
+                    validate=False,
                 )
             else:
-                param_names = [f"x{i}" for i in range(self.__dom_dim__)]
-                param_str = ", ".join(param_names)
-                func_str = f"lambda {param_str}: -func({param_str})"
+
+                def source_function(*args):
+                    return -self.source(*args)
+
+                source_function.__dom_dim__ = self.__dom_dim__
                 return Function(
-                    # pylint: disable=eval-used
-                    eval(func_str, {"func": self.source}),
+                    source_function,
                     self.__inputs__,
                     self.__outputs__,
+                    vectorized_callable=self.__vectorized_callable__,
+                    validate=False,
                 )
 
     def __ge__(self, other):
@@ -2677,78 +2531,310 @@ def __lt__(self, other):
         return ~self.__ge__(other)
 
     # Define all possible algebraic operations
-    def __add__(self, other):  # pylint: disable=too-many-statements
-        """Sums a Function object and 'other', returns a new Function
-        object which gives the result of the sum.
+    def __arithmetic_operation(self, other, op):  # pylint: disable=too-many-statements
+        """Generic handler for arithmetic operations between a Function and
+        another operand.
 
         Parameters
         ----------
         other : Function, int, float, callable
-            What self will be added to. If other and self are Function
-            objects which are based on a list of points, have the exact same
-            domain (are defined in the same grid points) and have the same
-            dimension, then a special implementation is used.
-            This implementation is faster, however behavior between grid
-            points is only interpolated, not calculated as it would be;
-            the resultant Function has the same interpolation as self.
+            The operand to combine with self.
+        op : callable
+            The binary operator to apply (e.g. operator.add).
 
         Returns
         -------
-        result : Function
-            A Function object which gives the result of self(x)+other(x).
+        Function
         """
         other_is_func = isinstance(other, Function)
         other_is_array = (
             other._source_type is SourceType.ARRAY if other_is_func else False
         )
+
+        # If other is a scalar Function, extract its constant value so the
+        # rest of the logic treats it like a plain number.
+        other_is_scalar = (
+            other._source_type is SourceType.SCALAR if other_is_func else False
+        )
+        if other_is_scalar:
+            other = other._scalar_value
+            other_is_func = False
+            other_is_array = False
+
         inputs = self.__inputs__[:]
         interp = self.__interpolation__
         extrap = self.__extrapolation__
         dom_dim = self.__dom_dim__
+        op_symbol = _OPERATOR_SYMBOLS.get(op, op.__name__)
+
+        # For division, use _safe_truediv (with nan_to_num) only on
+        # pre-computed arrays; callable lambdas use plain operator.truediv
+        # to avoid overhead from nan_to_num on every evaluation.
+        lambda_op = operator.truediv if op is _safe_truediv else op
+
+        # Scalar self fast path: self is a constant value
+        if self._source_type is SourceType.SCALAR:
+            sv = self._scalar_value
+            # SCALAR op number → SCALAR
+            if self.__is_scalar_operand(other):
+                other = self.__scalar_operand_value(other)
+                return Function(
+                    lambda_op(sv, other),
+                    inputs,
+                    validate=False,
+                )
+            # SCALAR op ARRAY Function → ARRAY
+            if other_is_array:
+                return Function._from_sorted_arrays(
+                    other._domain,
+                    op(sv, other._image),
+                    other.__inputs__[:],
+                    f"({self.__outputs__[0]}{op_symbol}{other.__outputs__[0]})",
+                    other.__interpolation__,
+                    other.__extrapolation__,
+                )
+            # SCALAR op callable → CALLABLE
+            if callable(other):
+                if other_is_func:
+                    other_dim = other.__dom_dim__
+                    other_callable = (
+                        other.get_value_opt if other_is_array else other.source
+                    )
+                    if other_dim > dom_dim:
+                        inputs = other.__inputs__[:]
+                else:
+                    other_dim = len(signature(other).parameters)
+                    other_callable = other
+                # Reverse: op(sv, other_callable(...)) — the scalar is the
+                # "other" and the callable is the "func" from make_arith_lambda's
+                # perspective, with reverse=True.
+                return Function(
+                    self.__make_arith_lambda(
+                        lambda_op, other_callable, sv, other_dim, reverse=True
+                    ),
+                    inputs,
+                    vectorized_callable=self.__is_vectorized_operand(other),
+                    validate=False,
+                )
 
-        if (
-            self._source_type is SourceType.ARRAY
-            and other_is_array
-            and np.array_equal(self._domain, other._domain)
-        ):
-            source = np.column_stack((self._domain, self._image + other._image))
-            outputs = f"({self.__outputs__[0]}+{other.__outputs__[0]})"
-            return Function(source, inputs, outputs, interp, extrap)
-        elif isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(
-            other
-        ):
+        if self._source_type is SourceType.ARRAY and other_is_array:
+            if np.array_equal(self._domain, other._domain):
+                new_domain = self._domain
+                new_image = op(self._image, other._image)
+                return Function._from_sorted_arrays(
+                    new_domain,
+                    new_image,
+                    inputs,
+                    f"({self.__outputs__[0]}{op_symbol}{other.__outputs__[0]})",
+                    interp,
+                    extrap,
+                )
+
+            elif _FAST_MATH and dom_dim == 1 and other.__dom_dim__ == 1:
+                new_domain = np.union1d(self._domain, other._domain)
+                new_image = op(
+                    self.get_value_opt(new_domain), other.get_value_opt(new_domain)
+                )
+                return Function._from_sorted_arrays(
+                    new_domain,
+                    new_image,
+                    inputs,
+                    f"({self.__outputs__[0]}{op_symbol}{other.__outputs__[0]})",
+                    interp,
+                    extrap,
+                )
+
+        # Scalar path
+        if self.__is_scalar_operand(other):
+            other = self.__scalar_operand_value(other)
             if self._source_type is SourceType.ARRAY:
-                source = np.column_stack((self._domain, np.add(self._image, other)))
-                outputs = f"({self.__outputs__[0]}+{other})"
-                return Function(source, inputs, outputs, interp, extrap)
+                return Function._from_sorted_arrays(
+                    self._domain,
+                    op(self._image, other),
+                    inputs,
+                    f"({self.__outputs__[0]}{op_symbol}{other})",
+                    interp,
+                    extrap,
+                )
             else:
+                self_callable = (
+                    self.source
+                    if self._source_type is SourceType.CALLABLE
+                    else self.get_value_opt
+                )
                 return Function(
-                    self.__make_arith_lambda(
-                        operator.add, self.get_value_opt, other, dom_dim
-                    ),
+                    self.__make_arith_lambda(lambda_op, self_callable, other, dom_dim),
                     inputs,
+                    vectorized_callable=self.__is_vectorized_operand(self),
+                    validate=False,
                 )
-        elif callable(other):
+
+        # Callable path
+        if callable(other):
             if other_is_func:
                 other_dim = other.__dom_dim__
-                other = other.get_value_opt if other_is_array else other.source
+                other_callable = other.get_value_opt if other_is_array else other.source
+                if other_dim > dom_dim:
+                    inputs = other.__inputs__[:]
             else:
                 other_dim = len(signature(other).parameters)
+                other_callable = other
 
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
+            if dom_dim != 1 and other_dim != 1 and dom_dim != other_dim:
+                raise TypeError(
+                    f"The number of parameters in the function to be operated on "
+                    f"({other_dim}) does not match the number of parameters of the "
+                    f"Function ({dom_dim})."
+                )
+
+            self_callable = (
+                self.source
+                if self._source_type is SourceType.CALLABLE
+                else self.get_value_opt
+            )
+            return Function(
+                self.__make_arith_lambda(
+                    lambda_op, self_callable, other_callable, dom_dim, other_dim
+                ),
+                inputs,
+                vectorized_callable=(
+                    self.__is_vectorized_operand(self)
+                    and self.__is_vectorized_operand(other)
+                ),
+                validate=False,
+            )
+
+        raise TypeError(
+            f"Unsupported type for arithmetic operation '{op_symbol}': {type(other)}"
+        )
+
+    def __reverse_arithmetic_operation(self, other, op):
+        """Handles reverse arithmetic operations where other is guaranteed
+        to not be a Function instance.
+
+        Parameters
+        ----------
+        other : int, float, callable
+            The left-hand operand.
+        op : callable
+            The binary operator to apply as op(other, self).
+        """
+        inputs = self.__inputs__[:]
+        interp = self.__interpolation__
+        extrap = self.__extrapolation__
+        dom_dim = self.__dom_dim__
+        op_symbol = _OPERATOR_SYMBOLS.get(op, op.__name__)
+        lambda_op = operator.truediv if op is _safe_truediv else op
+
+        # Scalar self fast path: self is a constant value
+        if self._source_type is SourceType.SCALAR:
+            sv = self._scalar_value
+            # number op SCALAR → SCALAR
+            if self.__is_scalar_operand(other):
+                other = self.__scalar_operand_value(other)
+                return Function(lambda_op(other, sv), inputs, validate=False)
+            # callable op SCALAR → CALLABLE
+            if callable(other):
+                other_dim = len(signature(other).parameters)
                 return Function(
                     self.__make_arith_lambda(
-                        operator.add, self.get_value_opt, other, dom_dim, other_dim
-                    )
+                        lambda_op,
+                        other,
+                        sv,
+                        other_dim,
+                        0,
+                        reverse=False,
+                    ),
+                    inputs,
+                    vectorized_callable=self.__is_vectorized_operand(other),
+                    validate=False,
+                )
+
+        # Scalar path
+        if self.__is_scalar_operand(other):
+            other = self.__scalar_operand_value(other)
+            if self._source_type is SourceType.ARRAY:
+                return Function._from_sorted_arrays(
+                    self._domain,
+                    op(other, self._image),
+                    inputs,
+                    f"({other}{op_symbol}{self.__outputs__[0]})",
+                    interp,
+                    extrap,
                 )
             else:
-                # pragma: no cover
+                self_callable = (
+                    self.source
+                    if self._source_type is SourceType.CALLABLE
+                    else self.get_value_opt
+                )
+                return Function(
+                    self.__make_arith_lambda(
+                        lambda_op, self_callable, other, dom_dim, reverse=True
+                    ),
+                    inputs,
+                    vectorized_callable=self.__is_vectorized_operand(self),
+                    validate=False,
+                )
+
+        # Callable path — other is a plain callable, never a Function
+        if callable(other):
+            other_dim = len(signature(other).parameters)
+
+            if dom_dim != 1 and other_dim != 1 and dom_dim != other_dim:
                 raise TypeError(
-                    f"The number of parameters in the function to be added ({other_dim}) "
-                    f"does not match the number of parameters of the Function ({dom_dim})."
+                    f"The number of parameters in the function to be operated on "
+                    f"({other_dim}) does not match the number of parameters of the "
+                    f"Function ({dom_dim})."
                 )
-        # pragma: no cover
-        raise TypeError("Unsupported type for addition")
+            self_callable = (
+                self.source
+                if self._source_type is SourceType.CALLABLE
+                else self.get_value_opt
+            )
+            return Function(
+                self.__make_arith_lambda(
+                    lambda_op,
+                    self_callable,
+                    other,
+                    dom_dim,
+                    other_dim,
+                    reverse=True,
+                ),
+                inputs,
+                vectorized_callable=(
+                    self.__is_vectorized_operand(self)
+                    and self.__is_vectorized_operand(other)
+                ),
+                validate=False,
+            )
+
+        raise TypeError(
+            "Unsupported type for reverse arithmetic "
+            f"operation '{op_symbol}': {type(other)}"
+        )
+
+    def __add__(self, other):
+        """Sums a Function object and 'other', returns a new Function
+        object which gives the result of the sum.
+
+        Parameters
+        ----------
+        other : Function, int, float, callable
+            What self will be added to. If other and self are Function
+            objects which are based on a list of points, have the exact same
+            domain (are defined in the same grid points) and have the same
+            dimension, then a special implementation is used.
+            This implementation is faster, however behavior between grid
+            points is only interpolated, not calculated as it would be;
+            the resultant Function has the same interpolation as self.
+
+        Returns
+        -------
+        result : Function
+            A Function object which gives the result of self(x)+other(x).
+        """
+        return self.__arithmetic_operation(other, operator.add)
 
     def __radd__(self, other):
         """Sums 'other' and a Function object and returns a new Function
@@ -2764,9 +2850,9 @@ def __radd__(self, other):
         result : Function
             A Function object which gives the result of other(x)+self(x).
         """
-        return self + other
+        return self.__reverse_arithmetic_operation(other, operator.add)
 
-    def __sub__(self, other):  # pylint: disable=too-many-statements
+    def __sub__(self, other):
         """Subtracts from a Function object and returns a new Function object
         which gives the result of the subtraction.
 
@@ -2786,60 +2872,7 @@ def __sub__(self, other):  # pylint: disable=too-many-statements
         result : Function
             A Function object which gives the result of self(x)-other(x).
         """
-        other_is_func = isinstance(other, Function)
-        other_is_array = (
-            other._source_type is SourceType.ARRAY if other_is_func else False
-        )
-        inputs = self.__inputs__[:]
-        interp = self.__interpolation__
-        extrap = self.__extrapolation__
-        dom_dim = self.__dom_dim__
-
-        if (
-            self._source_type is SourceType.ARRAY
-            and other_is_array
-            and np.array_equal(self._domain, other._domain)
-        ):
-            source = np.column_stack((self._domain, self._image - other._image))
-            outputs = f"({self.__outputs__[0]}-{other.__outputs__[0]})"
-            return Function(source, inputs, outputs, interp, extrap)
-        elif isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(
-            other
-        ):
-            if self._source_type is SourceType.ARRAY:
-                source = np.column_stack(
-                    (self._domain, np.subtract(self._image, other))
-                )
-                outputs = f"({self.__outputs__[0]}-{other})"
-                return Function(source, inputs, outputs, interp, extrap)
-            else:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.sub, self.get_value_opt, other, dom_dim
-                    ),
-                    inputs,
-                )
-        elif callable(other):
-            if other_is_func:
-                other_dim = other.__dom_dim__
-                other = other.get_value_opt if other_is_array else other.source
-            else:
-                other_dim = len(signature(other).parameters)
-
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.sub, self.get_value_opt, other, dom_dim, other_dim
-                    )
-                )
-            else:
-                # pragma: no cover
-                raise TypeError(
-                    f"The number of parameters in the function to be subtracted ({other_dim}) "
-                    f"does not match the number of parameters of the Function ({dom_dim})."
-                )
-        # pragma: no cover
-        raise TypeError("Unsupported type for subtraction")
+        return self.__arithmetic_operation(other, operator.sub)
 
     def __rsub__(self, other):
         """Subtracts a Function object from 'other' and returns a new Function
@@ -2856,9 +2889,9 @@ def __rsub__(self, other):
         result : Function
             A Function object which gives the result of other(x)-self(x).
         """
-        return other + (-self)
+        return self.__reverse_arithmetic_operation(other, operator.sub)
 
-    def __mul__(self, other):  # pylint: disable=too-many-statements
+    def __mul__(self, other):
         """Multiplies a Function object and returns a new Function object
         which gives the result of the multiplication.
 
@@ -2878,60 +2911,7 @@ def __mul__(self, other):  # pylint: disable=too-many-statements
         result : Function
             A Function object which gives the result of self(x)*other(x).
         """
-        other_is_func = isinstance(other, Function)
-        other_is_array = (
-            other._source_type is SourceType.ARRAY if other_is_func else False
-        )
-        inputs = self.__inputs__[:]
-        interp = self.__interpolation__
-        extrap = self.__extrapolation__
-        dom_dim = self.__dom_dim__
-
-        if (
-            self._source_type is SourceType.ARRAY
-            and other_is_array
-            and np.array_equal(self._domain, other._domain)
-        ):
-            source = np.column_stack((self._domain, self._image * other._image))
-            outputs = f"({self.__outputs__[0]}*{other.__outputs__[0]})"
-            return Function(source, inputs, outputs, interp, extrap)
-        elif isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(
-            other
-        ):
-            if self._source_type is SourceType.ARRAY:
-                source = np.column_stack(
-                    (self._domain, np.multiply(self._image, other))
-                )
-                outputs = f"({self.__outputs__[0]}*{other})"
-                return Function(source, inputs, outputs, interp, extrap)
-            else:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.mul, self.get_value_opt, other, dom_dim
-                    ),
-                    inputs,
-                )
-        elif callable(other):
-            if other_is_func:
-                other_dim = other.__dom_dim__
-                other = other.get_value_opt if other_is_array else other.source
-            else:
-                other_dim = len(signature(other).parameters)
-
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.mul, self.get_value_opt, other, dom_dim, other_dim
-                    )
-                )
-            else:
-                # pragma: no cover
-                raise TypeError(
-                    f"The number of parameters in the function to be multiplied ({other_dim}) "
-                    f"does not match the number of parameters of the Function ({dom_dim})."
-                )
-        # pragma: no cover
-        raise TypeError("Unsupported type for multiplication")
+        return self.__arithmetic_operation(other, operator.mul)
 
     def __rmul__(self, other):
         """Multiplies 'other' by a Function object and returns a new Function
@@ -2947,9 +2927,9 @@ def __rmul__(self, other):
         result : Function
             A Function object which gives the result of other(x)*self(x).
         """
-        return self * other
+        return self.__reverse_arithmetic_operation(other, operator.mul)
 
-    def __truediv__(self, other):  # pylint: disable=too-many-statements
+    def __truediv__(self, other):
         """Divides a Function object and returns a new Function object
         which gives the result of the division.
 
@@ -2969,64 +2949,7 @@ def __truediv__(self, other):  # pylint: disable=too-many-statements
         result : Function
             A Function object which gives the result of self(x)/other(x).
         """
-        other_is_func = isinstance(other, Function)
-        other_is_array = (
-            other._source_type is SourceType.ARRAY if other_is_func else False
-        )
-        inputs = self.__inputs__[:]
-        interp = self.__interpolation__
-        extrap = self.__extrapolation__
-        dom_dim = self.__dom_dim__
-
-        if (
-            self._source_type is SourceType.ARRAY
-            and other_is_array
-            and np.array_equal(self._domain, other._domain)
-        ):
-            with np.errstate(divide="ignore", invalid="ignore"):
-                ys = self._image / other._image
-                ys = np.nan_to_num(ys)
-            source = np.column_stack((self._domain, ys))
-            outputs = f"({self.__outputs__[0]}/{other.__outputs__[0]})"
-            return Function(source, inputs, outputs, interp, extrap)
-        elif isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(
-            other
-        ):
-            if self._source_type is SourceType.ARRAY:
-                with np.errstate(divide="ignore", invalid="ignore"):
-                    ys = np.divide(self._image, other)
-                    ys = np.nan_to_num(ys)
-                source = np.column_stack((self._domain, ys))
-                outputs = f"({self.__outputs__[0]}/{other})"
-                return Function(source, inputs, outputs, interp, extrap)
-            else:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.truediv, self.get_value_opt, other, dom_dim
-                    ),
-                    inputs,
-                )
-        elif callable(other):
-            if other_is_func:
-                other_dim = other.__dom_dim__
-                other = other.get_value_opt if other_is_array else other.source
-            else:
-                other_dim = len(signature(other).parameters)
-
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.truediv, self.get_value_opt, other, dom_dim, other_dim
-                    )
-                )
-            else:
-                # pragma: no cover
-                raise TypeError(
-                    f"The number of parameters in the function to be divided ({other_dim}) "
-                    f"does not match the number of parameters of the Function ({dom_dim})."
-                )
-        # pragma: no cover
-        raise TypeError("Unsupported type for division")
+        return self.__arithmetic_operation(other, _safe_truediv)
 
     def __rtruediv__(self, other):
         """Divides 'other' by a Function object and returns a new Function
@@ -3042,54 +2965,9 @@ def __rtruediv__(self, other):
         result : Function
             A Function object which gives the result of other(x)/self(x).
         """
-        inputs = self.__inputs__[:]
-        interp = self.__interpolation__
-        extrap = self.__extrapolation__
-        dom_dim = self.__dom_dim__
-
-        if isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(other):
-            if self._source_type is SourceType.ARRAY:
-                with np.errstate(divide="ignore", invalid="ignore"):
-                    ys = np.divide(other, self._image)
-                    ys = np.nan_to_num(ys)
-                source = np.column_stack((self._domain, ys))
-                outputs = f"({other}/{self.__outputs__[0]})"
-                return Function(source, inputs, outputs, interp, extrap)
-            else:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.truediv,
-                        self.get_value_opt,
-                        other,
-                        dom_dim,
-                        reverse=True,
-                    ),
-                    inputs,
-                )
-        elif callable(other):
-            other_dim = len(signature(other).parameters)
-
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.truediv,
-                        self.get_value_opt,
-                        other,
-                        dom_dim,
-                        other_dim,
-                        True,
-                    )
-                )
-            else:
-                # pragma: no cover
-                raise TypeError(
-                    f"The number of parameters in the function dividing by this Function ({other_dim}) "
-                    f"does not match the number of parameters of this Function ({dom_dim})."
-                )
-        # pragma: no cover
-        raise TypeError("Unsupported type for division")
+        return self.__reverse_arithmetic_operation(other, _safe_truediv)
 
-    def __pow__(self, other):  # pylint: disable=too-many-statements
+    def __pow__(self, other):
         """Raises a Function object to the power of 'other' and
         returns a new Function object which gives the result.
 
@@ -3109,60 +2987,7 @@ def __pow__(self, other):  # pylint: disable=too-many-statements
         result : Function
             A Function object which gives the result of self(x)**other(x).
         """
-        other_is_func = isinstance(other, Function)
-        other_is_array = (
-            other._source_type is SourceType.ARRAY if other_is_func else False
-        )
-        inputs = self.__inputs__[:]
-        interp = self.__interpolation__
-        extrap = self.__extrapolation__
-        dom_dim = self.__dom_dim__
-
-        if (
-            self._source_type is SourceType.ARRAY
-            and other_is_array
-            and np.array_equal(self._domain, other._domain)
-        ):
-            source = np.column_stack(
-                (self._domain, np.power(self._image, other._image))
-            )
-            outputs = f"({self.__outputs__[0]}**{other.__outputs__[0]})"
-            return Function(source, inputs, outputs, interp, extrap)
-        elif isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(
-            other
-        ):
-            if self._source_type is SourceType.ARRAY:
-                source = np.column_stack((self._domain, np.power(self._image, other)))
-                outputs = f"({self.__outputs__[0]}**{other})"
-                return Function(source, inputs, outputs, interp, extrap)
-            else:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.pow, self.get_value_opt, other, dom_dim
-                    ),
-                    inputs,
-                )
-        elif callable(other):
-            if other_is_func:
-                other_dim = other.__dom_dim__
-                other = other.get_value_opt if other_is_array else other.source
-            else:
-                other_dim = len(signature(other).parameters)
-
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.pow, self.get_value_opt, other, dom_dim, other_dim
-                    )
-                )
-            else:
-                # pragma: no cover
-                raise TypeError(
-                    f"The number of parameters in the function to be exponentiated by ({other_dim}) "
-                    f"does not match the number of parameters of the Function ({dom_dim})."
-                )
-        # pragma: no cover
-        raise TypeError("Unsupported type for exponentiation")
+        return self.__arithmetic_operation(other, operator.pow)
 
     def __rpow__(self, other):
         """Raises 'other' to the power of a Function object and returns
@@ -3178,46 +3003,11 @@ def __rpow__(self, other):
         result : Function
             A Function object which gives the result of other(x)**self(x).
         """
-        inputs = self.__inputs__[:]
-        interp = self.__interpolation__
-        extrap = self.__extrapolation__
-        dom_dim = self.__dom_dim__
+        return self.__reverse_arithmetic_operation(other, operator.pow)
 
-        if isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(other):
-            if self._source_type is SourceType.ARRAY:
-                source = np.column_stack((self._domain, np.power(other, self._image)))
-                outputs = f"({other}**{self.__outputs__[0]})"
-                return Function(source, inputs, outputs, interp, extrap)
-            else:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.pow, self.get_value_opt, other, dom_dim, reverse=True
-                    ),
-                    inputs,
-                )
-        elif callable(other):
-            other_dim = len(signature(other).parameters)
-
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.pow,
-                        self.get_value_opt,
-                        other,
-                        dom_dim,
-                        other_dim,
-                        True,
-                    ),
-                    inputs,
-                )
-            else:
-                # pragma: no cover
-                raise TypeError(
-                    f"The number of parameters in the base function ({other_dim}) "
-                    f"does not match the number of parameters of the Function exponent ({dom_dim})."
-                )
-        # pragma: no cover
-        raise TypeError("Unsupported type for exponentiation")
+    def __mod__(self, other):
+        """Operator % as an alias for modulo operation."""
+        return self.__arithmetic_operation(other, operator.mod)
 
     def __matmul__(self, other):
         """Operator @ as an alias for composition. Therefore, this
@@ -3239,62 +3029,7 @@ def __matmul__(self, other):
         """
         return self.compose(other)
 
-    def __mod__(self, other):  # pylint: disable=too-many-statements
-        """Operator % as an alias for modulo operation."""
-        other_is_func = isinstance(other, Function)
-        other_is_array = (
-            other._source_type is SourceType.ARRAY if other_is_func else False
-        )
-        inputs = self.__inputs__[:]
-        interp = self.__interpolation__
-        extrap = self.__extrapolation__
-        dom_dim = self.__dom_dim__
-
-        if (
-            self._source_type is SourceType.ARRAY
-            and other_is_array
-            and np.array_equal(self._domain, other._domain)
-        ):
-            source = np.column_stack((self._domain, np.mod(self._image, other._image)))
-            outputs = f"({self.__outputs__[0]}%{other.__outputs__[0]})"
-            return Function(source, inputs, outputs, interp, extrap)
-        elif isinstance(other, NUMERICAL_TYPES) or self.__is_single_element_array(
-            other
-        ):
-            if self._source_type is SourceType.ARRAY:
-                source = np.column_stack((self._domain, np.mod(self._image, other)))
-                outputs = f"({self.__outputs__[0]}%{other})"
-                return Function(source, inputs, outputs, interp, extrap)
-            else:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.mod, self.get_value_opt, other, dom_dim
-                    ),
-                    inputs,
-                )
-        elif callable(other):
-            if other_is_func:
-                other_dim = other.__dom_dim__
-                other = other.get_value_opt if other_is_array else other.source
-            else:
-                other_dim = len(signature(other).parameters)
-
-            if dom_dim == 1 or other_dim == 1 or dom_dim == other_dim:
-                return Function(
-                    self.__make_arith_lambda(
-                        operator.mod, self.get_value_opt, other, dom_dim, other_dim
-                    )
-                )
-            else:
-                # pragma: no cover
-                raise TypeError(
-                    f"The number of parameters in the function used as divisor ({other_dim}) "
-                    f"does not match the number of parameters of the Function ({dom_dim})."
-                )
-        # pragma: no cover
-        raise TypeError("Unsupported type for modulo operation")
-
-    def integral(self, a, b, numerical=False):  # pylint: disable=too-many-statements
+    def integral(self, a, b, numerical=False):
         """Evaluate a definite integral of a 1-D Function in the interval
         from a to b.
 
@@ -3307,9 +3042,9 @@ def integral(self, a, b, numerical=False):  # pylint: disable=too-many-statement
         numerical : bool
             If True, forces the definite integral to be evaluated numerically.
             The current numerical method used is scipy.integrate.quad.
-            If False, try to calculate using interpolation information.
-            Currently, only available for spline and linear interpolation. If
-            unavailable, calculate numerically anyways.
+            If False, try to calculate using the precomputed analytical
+            antiderivative when available (all 1-D array-based methods).
+            Falls back to numerical integration otherwise.
 
         Returns
         -------
@@ -3320,113 +3055,20 @@ def integral(self, a, b, numerical=False):  # pylint: disable=too-many-statement
         integration_sign = np.sign(b - a)
         if integration_sign == -1:
             a, b = b, a
-        # Different implementations depending on interpolation
-        if self.__interpolation__ == "spline" and numerical is False:
-            x_data = self.x_array
-            y_data = self.y_array
-            coeffs = self.__spline_coefficients__
-            ans = 0
-            # Check to see if interval starts before point data
-            if a < x_data[0]:
-                if self.__extrapolation__ == "constant":
-                    ans += y_data[0] * (min(x_data[0], b) - a)
-                elif self.__extrapolation__ == "natural":
-                    c = coeffs[:, 0]
-                    sub_a = a - x_data[0]
-                    sub_b = min(b, x_data[0]) - x_data[0]
-                    ans += (
-                        (c[3] * sub_b**4) / 4
-                        + (c[2] * sub_b**3 / 3)
-                        + (c[1] * sub_b**2 / 2)
-                        + c[0] * sub_b
-                    )
-                    ans -= (
-                        (c[3] * sub_a**4) / 4
-                        + (c[2] * sub_a**3 / 3)
-                        + (c[1] * sub_a**2 / 2)
-                        + c[0] * sub_a
-                    )
-                else:
-                    # self.__extrapolation__ = 'zero'
-                    pass
-
-            # Integrate in subintervals between xs of given data up to b
-            i = max(np.searchsorted(x_data, a, side="left") - 1, 0)
-            while i < len(x_data) - 1 and x_data[i] < b:
-                if x_data[i] <= a <= x_data[i + 1] and x_data[i] <= b <= x_data[i + 1]:
-                    sub_a = a - x_data[i]
-                    sub_b = b - x_data[i]
-                elif x_data[i] <= a <= x_data[i + 1]:
-                    sub_a = a - x_data[i]
-                    sub_b = x_data[i + 1] - x_data[i]
-                elif b <= x_data[i + 1]:
-                    sub_a = 0
-                    sub_b = b - x_data[i]
-                else:
-                    sub_a = 0
-                    sub_b = x_data[i + 1] - x_data[i]
-                c = coeffs[:, i]
-                ans += (
-                    (c[3] * sub_b**4) / 4
-                    + (c[2] * sub_b**3 / 3)
-                    + (c[1] * sub_b**2 / 2)
-                    + c[0] * sub_b
-                )
-                ans -= (
-                    (c[3] * sub_a**4) / 4
-                    + (c[2] * sub_a**3 / 3)
-                    + (c[1] * sub_a**2 / 2)
-                    + c[0] * sub_a
-                )
-                i += 1
-            # Check to see if interval ends after point data
-            if b > x_data[-1]:
-                if self.__extrapolation__ == "constant":
-                    ans += y_data[-1] * (b - max(x_data[-1], a))
-                elif self.__extrapolation__ == "natural":
-                    c = coeffs[:, -1]
-                    sub_a = max(x_data[-1], a) - x_data[-2]
-                    sub_b = b - x_data[-2]
-                    ans -= (
-                        (c[3] * sub_a**4) / 4
-                        + (c[2] * sub_a**3 / 3)
-                        + (c[1] * sub_a**2 / 2)
-                        + c[0] * sub_a
-                    )
-                    ans += (
-                        (c[3] * sub_b**4) / 4
-                        + (c[2] * sub_b**3 / 3)
-                        + (c[1] * sub_b**2 / 2)
-                        + c[0] * sub_b
-                    )
-                else:
-                    # self.__extrapolation__ = 'zero'
-                    pass
-        elif self.__interpolation__ == "linear" and numerical is False:
-            # Integrate from a to b using np.trapezoid
-            x_data = self.x_array
-            y_data = self.y_array
-            # Get data in interval
-            x_integration_data = x_data[(x_data >= a) & (x_data <= b)]
-            y_integration_data = y_data[(x_data >= a) & (x_data <= b)]
-            # Add integration limits to data
-            if self.__extrapolation__ == "zero":
-                if a >= x_data[0]:
-                    x_integration_data = np.concatenate(([a], x_integration_data))
-                    y_integration_data = np.concatenate(([self(a)], y_integration_data))
-                if b <= x_data[-1]:
-                    x_integration_data = np.concatenate((x_integration_data, [b]))
-                    y_integration_data = np.concatenate((y_integration_data, [self(b)]))
-            else:
-                x_integration_data = np.concatenate(([a], x_integration_data))
-                y_integration_data = np.concatenate(([self(a)], y_integration_data))
-                x_integration_data = np.concatenate((x_integration_data, [b]))
-                y_integration_data = np.concatenate((y_integration_data, [self(b)]))
-            ans = trapezoid(y_integration_data, x_integration_data)
-        else:
-            # Integrate numerically
-            ans, _ = integrate.quad(self, a, b, epsabs=1e-4, epsrel=1e-3, limit=1000)
-        return integration_sign * ans
+        elif integration_sign == 0:
+            return 0.0  # b == a
+
+        if (
+            not numerical
+            and self._source_type is SourceType.ARRAY
+            and self.__dom_dim__ == 1
+        ):
+            ans = self._evaluator.definite_integral(a, b)
+            return float(integration_sign * ans)
+
+        ans, _ = integrate.quad(self, a, b, epsabs=1e-4, epsrel=1e-3, limit=1000)
+
+        return float(integration_sign * ans)
 
     def differentiate(self, x, dx=1e-6, order=1):
         """Differentiate a Function object at a given point.
@@ -3436,7 +3078,7 @@ def differentiate(self, x, dx=1e-6, order=1):
         x : float
             Point at which to differentiate.
         dx : float
-            Step size to use for numerical differentiation.
+            Step size to use for numerical differentiation (fallback).
         order : int
             Order of differentiation.
 
@@ -3445,6 +3087,15 @@ def differentiate(self, x, dx=1e-6, order=1):
         ans : float
             Evaluated derivative.
         """
+        if self._source_type is SourceType.ARRAY and self.__dom_dim__ == 1:
+            try:
+                if order == 1:
+                    return float(self._evaluator.derivative(x))
+                else:
+                    return float(self._evaluator.second_derivative(x))
+            except (NotImplementedError, AttributeError):
+                pass
+
         match order:
             case 1:
                 return (self.get_value_opt(x + dx) - self.get_value_opt(x - dx)) / (
@@ -3460,13 +3111,24 @@ def differentiate(self, x, dx=1e-6, order=1):
     def differentiate_complex_step(self, x, dx=1e-200, order=1):
         """Differentiate a Function object at a given point using the complex
         step method. This method can be faster than ``Function.differentiate``
-        since it requires only one evaluation of the function. However, the
-        evaluated function must accept complex numbers as input.
+        since it requires only one evaluation of the function.
+
+        Callable sources support this method only when they accept complex
+        inputs, preserve the imaginary perturbation, and are analytic near the
+        evaluation point. For sampled 1-D sources, complex propagation exists
+        specifically for this method and is not a general complex-plane
+        interpolation contract. The real component selects the interpolation
+        or extrapolation segment, while the full complex value is evaluated by
+        that segment's polynomial. Sampled N-D sources do not support complex
+        coordinates. Avoid evaluating at points where the selected
+        interpolation or extrapolation is not differentiable, such as a
+        slope-changing linear interpolation knot.
 
         Parameters
         ----------
         x : float
-            Point at which to differentiate.
+            Real scalar point at which to differentiate. This method supports
+            1-D Functions only.
         dx : float, optional
             Step size to use for numerical differentiation, by default 1e-200.
         order : int, optional
@@ -3476,7 +3138,7 @@ def differentiate_complex_step(self, x, dx=1e-200, order=1):
         Returns
         -------
         float
-            The real part of the derivative of the function at the given point.
+            First derivative of the function at the given point.
 
         References
         ----------
@@ -3503,11 +3165,10 @@ def identity_function(self):
         result : Function
             A Function object that corresponds to the identity mapping.
         """
-
-        # Check if Function object source is array
         if self._source_type is SourceType.ARRAY:
-            return Function(
-                np.column_stack((self.x_array, self.x_array)),
+            return Function._from_sorted_arrays(
+                self.x_array,
+                self.x_array,
                 inputs=self.__inputs__,
                 outputs=f"identity of {self.__outputs__}",
                 interpolation="linear",
@@ -3518,9 +3179,10 @@ def identity_function(self):
                 lambda x: x,
                 inputs=self.__inputs__,
                 outputs=f"identity of {self.__outputs__}",
+                validate=False,
             )
 
-    def derivative_function(self):
+    def derivative_function(self, order=1):
         """Returns a Function object which gives the derivative of the Function object.
 
         Returns
@@ -3528,27 +3190,54 @@ def derivative_function(self):
         result : Function
             A Function object which gives the derivative of self.
         """
-        # Check if Function object source is array
-        if self._source_type is SourceType.ARRAY:
-            # Operate on grid values
-            ys = np.diff(self.y_array) / np.diff(self.x_array)
-            xs = self.source[:-1, 0] + np.diff(self.x_array) / 2
-            source = np.column_stack((xs, ys))
-            # Retrieve inputs, outputs and interpolation
-            inputs = self.__inputs__[:]
+        inputs = self.__inputs__[:]
+
+        if order == 1:
             outputs = f"d({self.__outputs__[0]})/d({inputs[0]})"
+        elif order == 2:
+            outputs = f"d^2({self.__outputs__[0]})/d({inputs[0]})^2"
+        else:
+            raise NotImplementedError(
+                "Only first and second derivatives are supported."
+            )
+
+        if self._source_type is SourceType.SCALAR:
+            return Function(0.0, inputs, outputs, validate=False)
+
+        if self._source_type is SourceType.ARRAY:
+            if self.__dom_dim__ == 1 and hasattr(self._evaluator, "derivative"):
+                xs = self.x_array
+                ys = (
+                    self._evaluator.derivative(xs)
+                    if order == 1
+                    else self._evaluator.second_derivative(xs)
+                )
+                return Function._from_sorted_arrays(
+                    xs,
+                    ys,
+                    inputs,
+                    outputs,
+                    self.__interpolation__,
+                    self.__extrapolation__,
+                )
+            else:
+                dy = np.gradient(self.y_array, self.x_array)
+                ys = dy if order == 1 else np.gradient(dy, self.x_array)
+                source = np.column_stack((self.x_array, ys))
         else:
 
             def source_function(x):
-                return self.differentiate(x)
+                return self.differentiate(x, order=order)
 
             source = source_function
-            inputs = self.__inputs__[:]
-            outputs = f"d({self.__outputs__[0]})/d({inputs[0]})"
 
-        # Create new Function object
         return Function(
-            source, inputs, outputs, self.__interpolation__, self.__extrapolation__
+            source,
+            inputs,
+            outputs,
+            self.__interpolation__,
+            self.__extrapolation__,
+            validate=False,
         )
 
     def integral_function(self, lower=None, upper=None, datapoints=100):
@@ -3575,15 +3264,32 @@ def integral_function(self, lower=None, upper=None, datapoints=100):
         result : Function
             The integral of the Function object.
         """
+        if self._source_type is SourceType.SCALAR:
+            c = self._scalar_value
+            lower = 0 if lower is None else lower
+            return Function(
+                lambda x: c * (x - lower),
+                inputs=self.__inputs__,
+                outputs=[o + " Integral" for o in self.__outputs__],
+                validate=False,
+            )
+
         if self._source_type is SourceType.ARRAY:
             lower = self.source[0, 0] if lower is None else lower
             upper = self.source[-1, 0] if upper is None else upper
+
             x_data = np.linspace(lower, upper, datapoints)
-            y_data = np.zeros(datapoints)
-            for i in range(datapoints):
-                y_data[i] = self.integral(lower, x_data[i])
-            return Function(
-                np.column_stack((x_data, y_data)),
+
+            if self.__dom_dim__ == 1:
+                y_data = self._evaluator.definite_integral(lower, x_data)
+            else:
+                raise NotImplementedError(
+                    "Integral function is only implemented for 1-D array-based Functions."
+                )
+
+            return Function._from_sorted_arrays(
+                x_data,
+                y_data,
                 inputs=self.__inputs__,
                 outputs=[o + " Integral" for o in self.__outputs__],
             )
@@ -3593,6 +3299,7 @@ def integral_function(self, lower=None, upper=None, datapoints=100):
                 lambda x: self.integral(lower, x),
                 inputs=self.__inputs__,
                 outputs=[o + " Integral" for o in self.__outputs__],
+                validate=False,
             )
 
     def isbijective(self):
@@ -3787,16 +3494,23 @@ def average_function(self, lower=None):
             The average of the Function object.
         """
         if self._source_type is SourceType.ARRAY:
-            if lower is None:
-                lower = self.source[0, 0]
+            lower = self.source[0, 0] if lower is None else lower
             upper = self.source[-1, 0]
+
             x_data = np.linspace(lower, upper, 100)
-            y_data = np.zeros(100)
-            y_data[0] = self.source[:, 1][0]
-            for i in range(1, 100):
-                y_data[i] = self.average(lower, x_data[i])
-            return Function(
-                np.concatenate(([x_data], [y_data])).transpose(),
+            y_data = np.zeros_like(x_data)
+
+            y_data[0] = self.get_value_opt(lower)
+
+            if self.__dom_dim__ == 1:
+                integrals = self._evaluator.definite_integral(lower, x_data[1:])
+                y_data[1:] = integrals / (x_data[1:] - lower)
+            else:
+                y_data[1:] = np.array([self.average(lower, x) for x in x_data[1:]])
+
+            return Function._from_sorted_arrays(
+                x_data,
+                y_data,
                 inputs=self.__inputs__,
                 outputs=[o + " Average" for o in self.__outputs__],
             )
@@ -3806,6 +3520,7 @@ def average_function(self, lower=None):
                 lambda x: self.average(lower, x),
                 inputs=self.__inputs__,
                 outputs=[o + " Average" for o in self.__outputs__],
+                validate=False,
             )
 
     def compose(self, func, extrapolate=False):
@@ -3846,8 +3561,9 @@ def compose(self, func, extrapolate=False):
                         f"the domain of the Function {self.x_initial, self.x_final}."
                     )
 
-            return Function(
-                np.concatenate(([func.x_array], [self(func.y_array)])).T,
+            return Function._from_sorted_arrays(
+                func.x_array,
+                self(func.y_array),
                 inputs=func.__inputs__,
                 outputs=self.__outputs__,
                 interpolation=self.__interpolation__,
@@ -3860,6 +3576,7 @@ def compose(self, func, extrapolate=False):
                 outputs=self.__outputs__,
                 interpolation=self.__interpolation__,
                 extrapolation=self.__extrapolation__,
+                validate=False,
             )
 
     def savetxt(
@@ -3911,7 +3628,7 @@ def savetxt(
         header_line = delimiter.join(self.__inputs__ + self.__outputs__)
 
         # create the datapoints
-        if self._source_type is SourceType.CALLABLE:
+        if self._source_type is not SourceType.ARRAY:
             if lower is None or upper is None or samples is None:  # pragma: no cover
                 raise ValueError(
                     "If the source is a callable, lower, upper and samples"
@@ -3939,8 +3656,34 @@ def savetxt(
     def __is_single_element_array(var):
         return isinstance(var, np.ndarray) and var.size == 1
 
+    @classmethod
+    def __is_scalar_operand(cls, var):
+        return isinstance(var, NUMERICAL_TYPES) or cls.__is_single_element_array(var)
+
+    @staticmethod
+    def __scalar_operand_value(var):
+        return var.item() if isinstance(var, np.ndarray) else var
+
+    @staticmethod
+    def __is_vector_argument(var):
+        return hasattr(var, "__iter__") and np.ndim(var) > 0
+
+    @staticmethod
+    def __is_vectorized_operand(var):
+        if isinstance(var, Function):
+            return var._source_type is SourceType.ARRAY or getattr(
+                var, "__vectorized_callable__", False
+            )
+        return getattr(var, "__vectorized_callable__", False)
+
     # Input validators
-    def __validate_source(self, source):  # pylint: disable=too-many-statements
+    @staticmethod
+    def __validate_source(  # pylint: disable=too-many-statements
+        source,
+        inputs=None,
+        outputs=None,
+        interpolation=None,
+    ):
         """Used to validate the source parameter for creating a Function object.
 
         Parameters
@@ -3949,11 +3692,18 @@ def __validate_source(self, source):  # pylint: disable=too-many-statements
             The source data of the Function object. This can be a numpy array,
             a callable function, a string or Path object to a csv or txt file,
             a Function object, or a list of numbers.
+        inputs : list of str, None
+            Existing input labels. CSV headers may populate this if None.
+        outputs : list of str, None
+            Existing output labels. CSV headers may populate this if None.
+        interpolation : str, None
+            Interpolation method. ``regular_grid`` enables tuple processing.
 
         Returns
         -------
-        np.ndarray, callable
-            The validated source parameter.
+        tuple
+            ``(source, inputs, outputs, grid_axes, grid_data)`` where grid
+            values are only populated for regular-grid sources.
 
         Raises
         ------
@@ -3962,7 +3712,7 @@ def __validate_source(self, source):  # pylint: disable=too-many-statements
             or a callable function.
         """
         if isinstance(source, Function):
-            return source.get_source()
+            return source.get_source(), inputs, outputs, None, None
 
         if isinstance(source, (str, Path)):
             # Read csv or txt files and create a numpy array
@@ -3976,10 +3726,10 @@ def __validate_source(self, source):  # pylint: disable=too-many-statements
                 source = np.loadtxt(data, delimiter=",", dtype=np.float64)
 
                 if len(source[0]) == len(header):
-                    if self.__inputs__ is None:
-                        self.__inputs__ = header[:-1]
-                    if self.__outputs__ is None:
-                        self.__outputs__ = [header[-1]]
+                    if inputs is None:
+                        inputs = header[:-1]
+                    if outputs is None:
+                        outputs = [header[-1]]
             except Exception as e:  # pragma: no cover
                 raise ValueError(
                     "Could not read the csv or txt file to create Function source."
@@ -3987,8 +3737,12 @@ def __validate_source(self, source):  # pylint: disable=too-many-statements
 
         if isinstance(source, Iterable):
             # Triggers an error if source is not a list of numbers
-            if self.__interpolation__ == "regular_grid":
-                return self.__process_grid_source(source)
+            if (
+                isinstance(interpolation, str)
+                and interpolation.lower() == "regular_grid"
+            ):
+                source, grid_axes, grid_data = Function.__process_grid_source(source)
+                return source, inputs, outputs, grid_axes, grid_data
 
             source = np.array(source, dtype=np.float64)
 
@@ -4006,19 +3760,15 @@ def __validate_source(self, source):  # pylint: disable=too-many-statements
                         "must be greater than or equal to the number of columns."
                     )
 
-            return source
+            return source, inputs, outputs, None, None
 
         if isinstance(source, NUMERICAL_TYPES):
-            # Convert number source into vectorized lambda function
-            temp = 1 * source
-
-            def source_function(_):
-                return temp
-
-            return source_function
+            # Return scalar directly — set_source will handle it as SCALAR
+            scalar = complex(source) if np.iscomplexobj(source) else float(source)
+            return scalar, inputs, outputs, None, None
 
         # If source is a callable function
-        return source
+        return source, inputs, outputs, None, None
 
     def __validate_inputs(self, inputs):
         """Used to validate the inputs parameter for creating a Function object.
@@ -4088,6 +3838,8 @@ def __validate_outputs(self, outputs):
             return outputs
 
     def __validate_interpolation(self, interpolation):
+        if isinstance(interpolation, str):
+            interpolation = interpolation.lower()
         if self.__dom_dim__ == 1:
             # possible interpolation values: linear, polynomial, akima and spline
             if interpolation is None:
@@ -4097,6 +3849,7 @@ def __validate_interpolation(self, interpolation):
                 "linear",
                 "polynomial",
                 "akima",
+                "pchip",
             ]:
                 warnings.warn(
                     "Interpolation method set to 'spline' because the "
@@ -4124,6 +3877,8 @@ def __validate_interpolation(self, interpolation):
         return interpolation
 
     def __validate_extrapolation(self, extrapolation):
+        if isinstance(extrapolation, str):
+            extrapolation = extrapolation.lower()
         if self.__dom_dim__ == 1:
             if extrapolation is None:
                 extrapolation = "constant"
@@ -4146,7 +3901,7 @@ def __validate_extrapolation(self, extrapolation):
                 extrapolation = "natural"
         return extrapolation
 
-    def to_dict(self, **kwargs):  # pylint: disable=unused-argument
+    def to_dict(self, **kwargs):
         """Serializes the Function instance to a dictionary.
 
         Returns
@@ -4169,8 +3924,223 @@ def to_dict(self, **kwargs):  # pylint: disable=unused-argument
             "outputs": self.__outputs__,
             "interpolation": self.__interpolation__,
             "extrapolation": self.__extrapolation__,
+            "vectorized_callable": self.__vectorized_callable__,
         }
 
+    @classmethod
+    def from_grid(
+        cls,
+        grid_data,
+        axes,
+        inputs=None,
+        outputs=None,
+        interpolation="regular_grid",
+        extrapolation="constant",
+        flatten_for_compatibility=True,
+        **kwargs,
+    ):  # pylint: disable=too-many-statements #TODO: Refactor this method into smaller methods
+        """Creates a Function from N-dimensional grid data.
+
+        This method is designed for structured grid data, such as CFD simulation
+        results where values are computed on a regular grid. It uses
+        scipy.interpolate.RegularGridInterpolator for efficient interpolation.
+
+        Parameters
+        ----------
+        grid_data : ndarray
+            N-dimensional array containing the function values on the grid.
+            For example, for a 3D function Cd(M, Re, α), this would be a 3D array
+            where grid_data[i, j, k] = Cd(M[i], Re[j], α[k]).
+        axes : list of ndarray
+            List of 1D arrays defining the grid points along each axis.
+            Each array should be sorted in ascending order.
+            For example: [M_axis, Re_axis, alpha_axis].
+        inputs : list of str, optional
+            Names of the input variables. If None, generic names will be used.
+            For example: ['Mach', 'Reynolds', 'Alpha'].
+        outputs : str, optional
+            Name of the output variable. For example: 'Cd'.
+        interpolation : str, optional
+            Interpolation method. Default is 'regular_grid'.
+            Currently only 'regular_grid' is supported for grid data.
+        extrapolation : str, optional
+            Extrapolation behavior. Default is ``'constant'`` which clamps to
+            edge values. Supported options are::
+
+                'constant'
+                    Use nearest edge value for out-of-bounds points (clamp).
+                'zero'
+                    Return zero for out-of-bounds points.
+                'natural'
+                    Use the interpolator's natural behavior: when the
+                    underlying ``RegularGridInterpolator`` is created with
+                    ``fill_value=None`` and ``method='linear'``, this results
+                    in linear extrapolation based on the edge gradients.
+
+            If an unsupported extrapolation value is supplied a ``ValueError``
+            is raised.
+        flatten_for_compatibility : bool, optional
+            If True (default), creates flattened ``_domain``, ``_image``, and
+            ``source`` arrays for backward compatibility with existing Function
+            methods and serialization. For large N-dimensional grids (e.g.,
+            100x100x100 points), this requires O(n^d) additional memory where n
+            is the typical axis length and d is the number of dimensions.
+            Set to False to skip this flattening and reduce memory usage if
+            compatibility with legacy code paths is not required.
+        **kwargs : dict, optional
+            Additional arguments passed to the Function constructor.
+
+        Returns
+        -------
+        Function
+            A Function object using RegularGridInterpolator for evaluation.
+
+        Notes
+        -----
+        - Grid data must be on a regular (structured) grid.
+        - For unstructured data, use the regular Function constructor with
+          scattered points.
+        - Extrapolation with 'constant' mode uses the nearest edge values,
+          which is appropriate for aerodynamic coefficients where extrapolation
+          beyond the data range should be avoided.
+
+        Examples
+        --------
+        >>> import numpy as np
+        >>> # Create 3D drag coefficient data
+        >>> mach = np.array([0.0, 0.5, 1.0, 1.5, 2.0])
+        >>> reynolds = np.array([1e5, 5e5, 1e6])
+        >>> alpha = np.array([0.0, 2.0, 4.0, 6.0])
+        >>> # Create a simple drag coefficient function
+        >>> M, Re, A = np.meshgrid(mach, reynolds, alpha, indexing='ij')
+        >>> cd_data = 0.3 + 0.1 * M + 1e-7 * Re + 0.01 * A
+        >>> # Create Function object
+        >>> cd_func = Function.from_grid(
+        ...     cd_data,
+        ...     [mach, reynolds, alpha],
+        ...     inputs=['Mach', 'Reynolds', 'Alpha'],
+        ...     outputs='Cd'
+        ... )
+        >>> # Evaluate at a point
+        >>> cd_func(1.2, 3e5, 3.0)
+        0.48000000000000004
+
+        """
+        if isinstance(interpolation, str):
+            interpolation = interpolation.lower()
+        if isinstance(extrapolation, str):
+            extrapolation = extrapolation.lower()
+
+        # Validate inputs
+        if not isinstance(grid_data, np.ndarray):
+            grid_data = np.array(grid_data)
+
+        if not isinstance(axes, (list, tuple)):
+            raise ValueError("axes must be a list or tuple of 1D arrays")
+
+        # Ensure all axes are numpy arrays
+        axes = [
+            np.array(axis) if not isinstance(axis, np.ndarray) else axis
+            for axis in axes
+        ]
+
+        # Check dimensions match
+        if len(axes) != grid_data.ndim:
+            raise ValueError(
+                f"Number of axes ({len(axes)}) must match grid_data dimensions "
+                f"({grid_data.ndim})"
+            )
+
+        # Check each axis matches corresponding grid dimension and is sorted
+        for i, axis in enumerate(axes):
+            if len(axis) != grid_data.shape[i]:
+                raise ValueError(
+                    f"Axis {i} has {len(axis)} points but grid dimension {i} "
+                    f"has {grid_data.shape[i]} points"
+                )
+            # Check if axis is sorted in ascending order
+            if not np.all(np.diff(axis) > 0):
+                warnings.warn(
+                    f"Axis {i} is not strictly sorted in ascending order. "
+                    "RegularGridInterpolator requires sorted axes. "
+                    "This may cause unexpected interpolation results.",
+                    UserWarning,
+                )
+
+        # Set default inputs if not provided
+        if inputs is None:
+            inputs = [f"x{i}" for i in range(len(axes))]
+        elif len(inputs) != len(axes):
+            raise ValueError(
+                f"Number of inputs ({len(inputs)}) must match number of axes ({len(axes)})"
+            )
+
+        func = cls.__new__(cls)
+
+        allowed_extrap = ("constant", "zero", "natural")
+        if extrapolation not in allowed_extrap:
+            raise ValueError(
+                "Unsupported extrapolation for grid interpolation. "
+                f"Supported values: {allowed_extrap}"
+            )
+
+        func._grid_axes = axes
+        func._grid_data = grid_data
+
+        func._evaluator = build_interpolation_evaluator(
+            method="regular_grid",
+            extrap_method=extrapolation,
+            dom_dim=len(axes),
+            grid_axes=axes,
+            grid_data=grid_data,
+        )
+
+        if flatten_for_compatibility:
+            mesh = np.meshgrid(*axes, indexing="ij")
+            domain_points = np.column_stack([m.ravel() for m in mesh])
+            func._domain = domain_points
+            func._image = grid_data.ravel()
+            func.source = np.column_stack([domain_points, func._image])
+        else:
+            func._domain = None
+            func._image = None
+            func.source = None
+
+        func.__inputs__ = inputs
+        func.__outputs__ = outputs if outputs is not None else "f"
+        func.__interpolation__ = interpolation
+        func.__extrapolation__ = extrapolation
+        func.__vectorized_callable__ = False
+        func.title = kwargs.get("title", None)
+        func.__img_dim__ = 1
+        func.__cropped_domain__ = None
+        func._source_type = SourceType.ARRAY
+        func.__dom_dim__ = len(axes)
+
+        func.x_array = axes[0]
+        func.x_initial, func.x_final = float(axes[0][0]), float(axes[0][-1])
+
+        if flatten_for_compatibility:
+            func.y_array = func._image
+            func.y_initial = float(func._image.min())
+            func.y_final = float(func._image.max())
+        else:
+            func.y_array = None
+            func.y_initial = float(grid_data.min())
+            func.y_final = float(grid_data.max())
+
+        if len(axes) > 2:
+            func.z_array = axes[2]
+            func.z_initial, func.z_final = axes[2][0], axes[2][-1]
+
+        func.set_inputs(inputs)
+        func.set_outputs(outputs)
+        func.set_title(func.title)
+
+        func.set_get_value_opt()
+
+        return func
+
     @classmethod
     def from_dict(cls, func_dict):
         """Creates a Function instance from a dictionary.
@@ -4182,7 +4152,8 @@ def from_dict(cls, func_dict):
         """
         source = func_dict["source"]
         if func_dict["interpolation"] is None and func_dict["extrapolation"] is None:
-            source = from_hex_decode(source)
+            if isinstance(source, str):
+                source = from_hex_decode(source)
 
         return cls(
             source=source,
@@ -4191,61 +4162,87 @@ def from_dict(cls, func_dict):
             inputs=func_dict["inputs"],
             outputs=func_dict["outputs"],
             title=func_dict["title"],
+            vectorized_callable=func_dict.get("vectorized_callable", False),
         )
 
     @staticmethod
     def __make_arith_lambda(
         operator, func, other, func_dim, other_dim=0, reverse=False
     ):
-        """Creates a lambda function for arithmetic operations
-        that can be used with the Function class. This is used to
-        operate between multidimensional sets of data.
+        # pylint: disable=function-redefined
+        """Creates a callable for arithmetic operations between
+        multidimensional functions, without using eval.
 
         Parameters
         ----------
-        operator : function
-            The mathematical operation to be performed.
-        func : function
-            The first function to be operated on.
-        other : function
-            The second function to be operated on.
+        operator : callable
+            The binary mathematical operation to perform.
+        func : callable
+            The first operand (always self's get_value_opt).
+        other : callable or scalar
+            The second operand.
         func_dim : int
-            The dimension of the first function (i.e. its number
-            of parameters).
+            Number of positional input parameters of func.
         other_dim : int, optional
-            The dimension of the second function (i.e. its number
-            of parameters). The default is 0, which is interpreted
-            as a scalar.
+            Number of positional input parameters of other. 0 means scalar.
         reverse : bool, optional
-            If True, the order of the functions is reversed in
-            the operation. The default is False.
+            If True, reverses operand order: operator(other(...), func(...)).
         """
-        if func_dim == 1 and other_dim == 1:
-            # Use of python lambda for speed
-            if reverse:
-                return lambda x: operator(other(x), func(x))
-            else:
-                return lambda x: operator(func(x), other(x))
+        # Optimized 1D×1D case: avoid *args overhead
+        if func_dim == 1:
+            if other_dim == 1:
+                if reverse:
+
+                    def result(x):
+                        return operator(other(x), func(x))
+                else:
+
+                    def result(x):
+                        return operator(func(x), other(x))
 
-        max_dim = max(func_dim, other_dim)
-        params = [f"x{i}" for i in range(max_dim)]
-        param_str = ", ".join(params)
+                result.__dom_dim__ = 1
+                return result
+            elif other_dim == 0:
+                if reverse:
 
+                    def result(x):
+                        return operator(other, func(x))
+                else:
+
+                    def result(x):
+                        return operator(func(x), other)
+
+                result.__dom_dim__ = 1
+                return result
+
+        # Scalar case
         if other_dim == 0:
             if reverse:
-                expr = f"lambda {param_str}: operator(other, func({param_str}))"
+
+                def result(*args):
+                    return operator(other, func(*args[:func_dim]))
             else:
-                expr = f"lambda {param_str}: operator(func({param_str}), other)"
+
+                def result(*args):
+                    return operator(func(*args[:func_dim]), other)
+
+            result.__dom_dim__ = func_dim
+
+            return result
+
+        # General multidimensional callable case
+        if reverse:
+
+            def result(*args):
+                return operator(other(*args[:other_dim]), func(*args[:func_dim]))
         else:
-            func_args = ", ".join(params[:func_dim])
-            other_args = ", ".join(params[:other_dim])
-            if reverse:
-                expr = f"lambda {param_str}: operator(other({other_args}), func({func_args}))"
-            else:
-                expr = f"lambda {param_str}: operator(func({func_args}), other({other_args}))"
 
-        # pylint: disable=eval-used
-        return eval(expr, {"func": func, "other": other, "operator": operator})
+            def result(*args):
+                return operator(func(*args[:func_dim]), other(*args[:other_dim]))
+
+        result.__dom_dim__ = max(func_dim, other_dim)
+
+        return result
 
 
 def funcify_method(*args, **kwargs):  # pylint: disable=too-many-statements
@@ -4329,7 +4326,7 @@ class funcify_method_decorator:
         class.
         """
 
-        # pylint: disable=C0103,R0903
+        # pylint: disable=C0103
         def __init__(self, func):
             self.func = func
             self.attrname = None
@@ -4361,7 +4358,6 @@ def source_function(*_):
 
                     source = source_function
                     val = Function(source, *args, **kwargs)
-                # pylint: disable=W0201
                 val.__doc__ = self.__doc__
                 val.__cached__ = True
                 cache[self.attrname] = val
@@ -4399,6 +4395,6 @@ def reset_funcified_methods(instance):
 
     results = doctest.testmod()
     if results.failed < 1:
-        print(f"All the {results.attempted} tests passed!")
+        logger.info("All the %d tests passed!", results.attempted)
     else:
-        print(f"{results.failed} out of {results.attempted} tests failed.")
+        logger.error("%d out of %d tests failed.", results.failed, results.attempted)
diff --git a/rocketpy/mathutils/vector_matrix.py b/rocketpy/mathutils/vector_matrix.py
index 9c8bde144..c24d36605 100644
--- a/rocketpy/mathutils/vector_matrix.py
+++ b/rocketpy/mathutils/vector_matrix.py
@@ -1,9 +1,11 @@
+import logging
 from cmath import isclose
-from functools import cached_property
 from itertools import product
 
 from rocketpy.tools import euler313_to_quaternions, normalize_quaternions
 
+logger = logging.getLogger(__name__)
+
 
 class Vector:
     """Pure python basic R3 vector class designed for simple operations.
@@ -94,6 +96,7 @@ class Vector:
     """
 
     __array_ufunc__ = None
+    __slots__ = ("components", "x", "y", "z", "_unit_vector", "_cross_matrix")
 
     def __init__(self, components):
         """Vector class constructor.
@@ -151,15 +154,17 @@ def __call__(self, *args):
     def __len__(self):
         return 3
 
-    @cached_property
+    @property
     def unit_vector(self):
         """R3 vector with the same direction of self, but normalized."""
-        try:
-            return self / abs(self)
-        except ZeroDivisionError:
-            return self
-
-    @cached_property
+        if not hasattr(self, "_unit_vector"):
+            try:
+                self._unit_vector = self / abs(self)
+            except ZeroDivisionError:
+                self._unit_vector = self
+        return self._unit_vector
+
+    @property
     def cross_matrix(self):
         """Skew symmetric matrix used for cross product.
 
@@ -176,9 +181,12 @@ def cross_matrix(self):
         >>> (v ^ u) == v.cross_matrix @ u
         True
         """
-        return Matrix(
-            [[0, -self.z, self.y], [self.z, 0, -self.x], [-self.y, self.x, 0]]
-        )
+        if not hasattr(self, "_cross_matrix"):
+            self._cross_matrix = Matrix(
+                [[0, -self.z, self.y], [self.z, 0, -self.x], [-self.y, self.x, 0]]
+            )
+
+        return self._cross_matrix
 
     def __abs__(self):
         """R3 vector norm, magnitude or absolute value."""
@@ -557,6 +565,27 @@ class Matrix:
     """
 
     __array_ufunc__ = None
+    __slots__ = (
+        "components",
+        "x",
+        "y",
+        "z",
+        "xx",
+        "xy",
+        "xz",
+        "yx",
+        "yy",
+        "yz",
+        "zx",
+        "zy",
+        "zz",
+        "_shape",
+        "_trace",
+        "_transpose",
+        "_det",
+        "_is_diagonal",
+        "_inverse",
+    )
 
     def __init__(self, components):
         """Matrix class constructor.
@@ -627,33 +656,39 @@ def __len__(self):
         """Adds support for the len() function."""
         return 3
 
-    @cached_property
+    @property
     def shape(self):
         """tuple: Shape of the matrix."""
         return (3, 3)
 
-    @cached_property
+    @property
     def trace(self):
         """Matrix trace, sum of its diagonal components."""
-        return self.xx + self.yy + self.zz
+        if not hasattr(self, "_trace"):
+            self._trace = self.xx + self.yy + self.zz
+        return self._trace
 
-    @cached_property
+    @property
     def transpose(self):
         """Matrix transpose."""
-        return Matrix(
-            [
-                [self.xx, self.yx, self.zx],
-                [self.xy, self.yy, self.zy],
-                [self.xz, self.yz, self.zz],
-            ]
-        )
+        if not hasattr(self, "_transpose"):
+            self._transpose = Matrix(
+                [
+                    [self.xx, self.yx, self.zx],
+                    [self.xy, self.yy, self.zy],
+                    [self.xz, self.yz, self.zz],
+                ]
+            )
+        return self._transpose
 
-    @cached_property
+    @property
     def det(self):
         """Matrix determinant."""
-        return abs(self)
+        if not hasattr(self, "_det"):
+            self._det = abs(self)
+        return self._det
 
-    @cached_property
+    @property
     def is_diagonal(self):
         """Boolean indicating if matrix is diagonal.
 
@@ -676,14 +711,15 @@ def is_diagonal(self):
         >>> M.is_diagonal
         False
         """
-        for i, j in product(range(3), range(3)):
-            if i == j:
-                continue
-            if abs(self[i, j]) > 1e-6:
-                return False
-        return True
-
-    @cached_property
+        if not hasattr(self, "_is_diagonal"):
+            self._is_diagonal = all(
+                abs(self[i, j]) <= 1e-6
+                for i, j in product(range(3), range(3))
+                if i != j
+            )
+        return self._is_diagonal
+
+    @property
     def inverse(self):
         """Matrix inverse.
 
@@ -703,23 +739,25 @@ def inverse(self):
         ZeroDivisionError
             If the matrix is singular.
         """
-        ixx = self.yy * self.zz - self.zy * self.yz
-        iyx = self.zx * self.yz - self.yx * self.zz
-        izx = self.yx * self.zy - self.zx * self.yy
-        ixy = self.zy * self.xz - self.xy * self.zz
-        iyy = self.xx * self.zz - self.zx * self.xz
-        izy = self.zx * self.xy - self.xx * self.zy
-        ixz = self.xy * self.yz - self.yy * self.xz
-        iyz = self.yx * self.xz - self.yz * self.xx
-        izz = self.xx * self.yy - self.yx * self.xy
-        det = self.xx * ixx + self.xy * iyx + self.xz * izx
-        return Matrix(
-            [
-                [ixx / det, ixy / det, ixz / det],
-                [iyx / det, iyy / det, iyz / det],
-                [izx / det, izy / det, izz / det],
-            ]
-        )
+        if not hasattr(self, "_inverse"):
+            ixx = self.yy * self.zz - self.zy * self.yz
+            iyx = self.zx * self.yz - self.yx * self.zz
+            izx = self.yx * self.zy - self.zx * self.yy
+            ixy = self.zy * self.xz - self.xy * self.zz
+            iyy = self.xx * self.zz - self.zx * self.xz
+            izy = self.zx * self.xy - self.xx * self.zy
+            ixz = self.xy * self.yz - self.yy * self.xz
+            iyz = self.yx * self.xz - self.yz * self.xx
+            izz = self.xx * self.yy - self.yx * self.xy
+            det = self.xx * ixx + self.xy * iyx + self.xz * izx
+            self._inverse = Matrix(
+                [
+                    [ixx / det, ixy / det, ixz / det],
+                    [iyx / det, iyy / det, iyz / det],
+                    [izx / det, izy / det, izz / det],
+                ]
+            )
+        return self._inverse
 
     def __abs__(self):
         """Matrix determinant."""
@@ -1108,6 +1146,6 @@ def from_dict(cls, data):
 
     results = doctest.testmod()
     if results.failed < 1:
-        print(f"All the {results.attempted} tests passed!")
+        logger.info("All the %d tests passed!", results.attempted)
     else:
-        print(f"{results.failed} out of {results.attempted} tests failed.")
+        logger.error("%d out of %d tests failed.", results.failed, results.attempted)
diff --git a/rocketpy/motors/__init__.py b/rocketpy/motors/__init__.py
index b13ff9392..56f24dddf 100644
--- a/rocketpy/motors/__init__.py
+++ b/rocketpy/motors/__init__.py
@@ -4,6 +4,7 @@
 from .liquid_motor import LiquidMotor
 from .motor import GenericMotor, Motor
 from .point_mass_motor import PointMassMotor
+from .ring_cluster_motor import RingClusterMotor
 from .solid_motor import SolidMotor
 from .tank import (
     LevelBasedTank,
diff --git a/rocketpy/motors/fluid.py b/rocketpy/motors/fluid.py
index 33d882abf..e18f31f41 100644
--- a/rocketpy/motors/fluid.py
+++ b/rocketpy/motors/fluid.py
@@ -112,20 +112,20 @@ def get_time_variable_density(self, temperature, pressure):
         Function
             Density of the fluid in kg/m³ as function of time.
         """
-        is_temperature_callable = callable(temperature.source)
-        is_pressure_callable = callable(pressure.source)
-        if is_temperature_callable and is_pressure_callable:
+        is_temperature_array = temperature.is_array_source()
+        is_pressure_array = pressure.is_array_source()
+        if not is_temperature_array and not is_pressure_array:
             return Function(
                 lambda time: self.density_function.get_value(
-                    temperature.source(time), pressure.source(time)
+                    temperature.get_value_opt(time), pressure.get_value_opt(time)
                 ),
                 inputs="Time (s)",
                 outputs="Density (kg/m³)",
             )
 
-        if is_temperature_callable or is_pressure_callable:
+        if not is_temperature_array or not is_pressure_array:
             time_scale = (
-                temperature.x_array if not is_temperature_callable else pressure.x_array
+                temperature.x_array if is_temperature_array else pressure.x_array
             )
         else:
             time_scale = np.unique(
@@ -164,7 +164,7 @@ def __str__(self):
 
         return f"Fluid: {self.name}"
 
-    def to_dict(self, **kwargs):  # pylint: disable=unused-argument
+    def to_dict(self, **kwargs):
         discretize = kwargs.get("discretize", False)
 
         density = self.density
diff --git a/rocketpy/motors/hybrid_motor.py b/rocketpy/motors/hybrid_motor.py
index efee47867..eb63a3a3b 100644
--- a/rocketpy/motors/hybrid_motor.py
+++ b/rocketpy/motors/hybrid_motor.py
@@ -198,7 +198,6 @@ class HybridMotor(Motor):
         Grain length remains constant throughout the burn. Default is True.
     """
 
-    # pylint: disable=too-many-arguments
     def __init__(  # pylint: disable=too-many-arguments
         self,
         thrust_source,
diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py
index ea42dd71c..9ecb001dd 100644
--- a/rocketpy/motors/motor.py
+++ b/rocketpy/motors/motor.py
@@ -322,7 +322,7 @@ class Function. Thrust units are Newtons.
         # Handle burn_time input
         self.burn_time = burn_time
 
-        if callable(self.thrust.source):
+        if not self.thrust.is_array_source():
             self.thrust.set_discrete(*self.burn_time, 50, self.interpolate, "zero")
 
         # Reshape thrust_source if needed
@@ -372,7 +372,7 @@ def burn_time(self, burn_time):
         if burn_time:
             self._burn_time = tuple_handler(burn_time)
         else:
-            if not callable(self.thrust.source):
+            if self.thrust.is_array_source():
                 self._burn_time = (self.thrust.x_array[0], self.thrust.x_array[-1])
             else:  # pragma: no cover
                 raise ValueError(
diff --git a/rocketpy/motors/ring_cluster_motor.py b/rocketpy/motors/ring_cluster_motor.py
new file mode 100644
index 000000000..af0d544c3
--- /dev/null
+++ b/rocketpy/motors/ring_cluster_motor.py
@@ -0,0 +1,368 @@
+# pylint: disable=invalid-name
+import matplotlib.pyplot as plt
+import numpy as np
+
+from ..mathutils.function import Function, funcify_method
+from ..tools import parallel_axis_theorem_from_com
+from .motor import Motor
+
+
+class RingClusterMotor(Motor):
+    """
+    A class representing a cluster of N identical motors arranged symmetrically.
+
+    This class models a ring (annular) cluster configuration where a specific
+    number of identical motors (N >= 2) are arranged symmetrically along a
+    circular perimeter of a given radius. Note that this model assumes no
+    central motor is present along the rocket's longitudinal axis. The total
+    inertia tensors (Ixx and Iyy) are computed by explicitly summing the
+    contribution of each individual motor based on its angular position,
+    ensuring mathematical accuracy for all configurations, including the
+    asymmetric transverse inertia case of N=2.
+
+    Attributes
+    ----------
+    motor : SolidMotor
+        The single motor instance used in the cluster.
+    number : int
+        The number of motors in the cluster.
+    radius : float
+        The radial distance from the rocket's central axis to the center of each motor.
+    """
+
+    def __init__(self, motor, number, radius):
+        """
+        Initialize the ClusterMotor.
+
+        Parameters
+        ----------
+        motor : SolidMotor
+            The base motor to be clustered.
+        number : int
+            Number of motors. Must be >= 2.
+        radius : float
+            Distance from center of rocket to center of motor (m).
+        """
+        if not isinstance(number, int):
+            raise TypeError(f"number must be an int, got {type(number).__name__}")
+        if number < 2:
+            raise ValueError("number must be >= 2 for a ClusterMotor")
+        if not isinstance(radius, (int, float)):
+            raise TypeError(
+                f"radius must be a real number, got {type(radius).__name__}"
+            )
+        if radius < 0:
+            raise ValueError("radius must be non-negative")
+
+        self.motor = motor
+        self.number = number
+        self.radius = float(radius)
+        dry_inertia_cluster = self._calculate_dry_inertia()
+
+        # Use a thrust source scaled by the number of motors so that
+        # all thrust-derived quantities computed by the base Motor class
+        # correspond to the full cluster rather than a single motor.
+        scaled_thrust_source = motor.thrust * number
+
+        super().__init__(
+            thrust_source=scaled_thrust_source,
+            nozzle_radius=motor.nozzle_radius,
+            burn_time=motor.burn_time,
+            dry_mass=motor.dry_mass * number,
+            dry_inertia=dry_inertia_cluster,
+            center_of_dry_mass_position=motor.center_of_dry_mass_position,
+            coordinate_system_orientation=motor.coordinate_system_orientation,
+            reference_pressure=motor.reference_pressure,
+            interpolation_method="linear",
+        )
+
+        # The cluster has ``number`` nozzles, so its total exit area (used for
+        # the pressure-thrust / vacuum-thrust correction, which must be
+        # consistent with the thrust that was scaled by ``number``) is
+        # ``number`` times a single nozzle's area. ``nozzle_radius`` is kept as
+        # the single-nozzle radius.
+        self.nozzle_area = np.pi * motor.nozzle_radius**2 * number
+
+        self._setup_grain_properties()
+        self._propellant_mass = self.motor.propellant_mass * self.number
+        self._propellant_initial_mass = self.number * self.motor.propellant_initial_mass
+        self._center_of_propellant_mass = self.motor.center_of_propellant_mass
+        self._evaluate_propellant_inertia()
+
+    def _evaluate_propellant_inertia(self):
+        """Calculates the dynamic inertia of the propellant using Steiner's theorem."""
+        self._propellant_I_11 = self.motor.propellant_I_11 * self.number
+        self._propellant_I_22 = self.motor.propellant_I_22 * self.number
+
+        angles = np.linspace(0, 2 * np.pi, self.number, endpoint=False)
+        for angle in angles:
+            x = self.radius * np.cos(angle)
+            y = self.radius * np.sin(angle)
+
+            self._propellant_I_11 += self.motor.propellant_mass * (y**2)
+            self._propellant_I_22 += self.motor.propellant_mass * (x**2)
+
+        Izz_term1 = self.motor.propellant_I_33 * self.number
+        Izz_term2 = self.motor.propellant_mass * (self.number * self.radius**2)
+        self._propellant_I_33 = Izz_term1 + Izz_term2
+
+        zero_func = Function(0)
+        self._propellant_I_12 = zero_func
+        self._propellant_I_13 = zero_func
+        self._propellant_I_23 = zero_func
+
+    @funcify_method("Time (s)", "Inertia I_22 (kg m²)")
+    def I_22(self):
+        """Assembled (dry + propellant) transverse inertia about the e_2 axis.
+
+        Overrides :meth:`Motor.I_22`, which returns ``I_11`` directly on the
+        assumption that the motor is axisymmetric. That assumption does not hold
+        for every ring cluster, so ``I_22`` is computed here from the
+        separately-evaluated ``_22`` components (see
+        :meth:`_evaluate_propellant_inertia` and :meth:`_calculate_dry_inertia`).
+
+        When ``I_22`` equals ``I_11``
+        -----------------------------
+        The relevant property is not continuous axisymmetry (which a discrete
+        cluster of ``number`` motors never has for finite ``number``) but
+        *transverse isotropy* of the inertia tensor: ``I_11 == I_22`` and
+        ``I_12 == 0``, i.e. every transverse axis is a principal axis with the
+        same moment. A rigid body has this whenever it possesses a discrete
+        rotational-symmetry axis of order ``n >= 3`` -- geometric axisymmetry is
+        sufficient but not necessary.
+
+        For a ring cluster the motors sit at angles ``theta_k = 2*pi*k/number``,
+        ``k = 0 .. number-1``, all at radius ``radius``. The transverse
+        anisotropy is driven by
+
+            I_22 - I_11  proportional to  sum_k (x_k**2 - y_k**2)
+                         = radius**2 * sum_k cos(2*theta_k)
+                         = radius**2 * Re( sum_k exp(i * 4*pi*k / number) ).
+
+        That geometric series vanishes unless ``exp(i*4*pi/number) == 1``, i.e.
+        unless ``number`` divides 2. Hence:
+
+        * ``number == 2`` -- the ``m = 2`` angular term does not cancel
+          (``sum cos(2*theta_k) == 2``); the cluster is transversely anisotropic
+          and ``I_22 != I_11``. This is the case this override exists for.
+        * ``number >= 3`` -- the term cancels exactly for *every* such value
+          (odd, even, prime alike); ``I_22 == I_11`` analytically, and this
+          method returns the same value as :meth:`I_11` up to floating-point
+          round-off.
+
+        Note that parity or primality of ``number`` is irrelevant: three or more
+        equally-spaced motors already annihilate the ``m = 2`` harmonic, so e.g.
+        ``number == 3`` and ``number == 5`` are both transversely isotropic. The
+        sole non-trivial anisotropic configuration (given the ``number >= 2``
+        constraint enforced in ``__init__``) is ``number == 2``.
+
+        The implementation nonetheless sums every motor's contribution
+        explicitly rather than special-casing ``number == 2``, so the result is
+        exact for all configurations.
+        """
+        prop_I_22 = parallel_axis_theorem_from_com(
+            self.propellant_I_22,
+            self.propellant_mass,
+            self.center_of_propellant_mass - self.center_of_mass,
+        )
+        dry_I_22 = parallel_axis_theorem_from_com(
+            self.dry_I_22,
+            self.dry_mass,
+            self.center_of_dry_mass_position - self.center_of_mass,
+        )
+        return prop_I_22 + dry_I_22
+
+    def _setup_grain_properties(self):
+        """Copies the grain properties from the base motor."""
+        self.throat_radius = self.motor.throat_radius
+        self.grain_number = self.motor.grain_number
+        self.grain_density = self.motor.grain_density
+        self.grain_outer_radius = self.motor.grain_outer_radius
+        self.grain_initial_inner_radius = self.motor.grain_initial_inner_radius
+        self.grain_initial_height = self.motor.grain_initial_height
+        self.grains_center_of_mass_position = self.motor.grains_center_of_mass_position
+
+    @property
+    def thrust(self):
+        return self._thrust
+
+    @thrust.setter
+    def thrust(self, value):
+        self._thrust = value
+
+    @property
+    def propellant_mass(self):
+        return self._propellant_mass
+
+    @propellant_mass.setter
+    def propellant_mass(self, value):
+        self._propellant_mass = value
+
+    @property
+    def propellant_initial_mass(self):
+        return self._propellant_initial_mass
+
+    @propellant_initial_mass.setter
+    def propellant_initial_mass(self, value):
+        self._propellant_initial_mass = value
+
+    @property
+    def center_of_propellant_mass(self):
+        return self._center_of_propellant_mass
+
+    @center_of_propellant_mass.setter
+    def center_of_propellant_mass(self, value):
+        self._center_of_propellant_mass = value
+
+    @property
+    def propellant_I_11(self):
+        return self._propellant_I_11
+
+    @propellant_I_11.setter
+    def propellant_I_11(self, value):
+        self._propellant_I_11 = value
+
+    @property
+    def propellant_I_22(self):
+        return self._propellant_I_22
+
+    @propellant_I_22.setter
+    def propellant_I_22(self, value):
+        self._propellant_I_22 = value
+
+    @property
+    def propellant_I_33(self):
+        return self._propellant_I_33
+
+    @propellant_I_33.setter
+    def propellant_I_33(self, value):
+        self._propellant_I_33 = value
+
+    @property
+    def propellant_I_12(self):
+        return self._propellant_I_12
+
+    @propellant_I_12.setter
+    def propellant_I_12(self, value):
+        self._propellant_I_12 = value
+
+    @property
+    def propellant_I_13(self):
+        return self._propellant_I_13
+
+    @propellant_I_13.setter
+    def propellant_I_13(self, value):
+        self._propellant_I_13 = value
+
+    @property
+    def propellant_I_23(self):
+        return self._propellant_I_23
+
+    @propellant_I_23.setter
+    def propellant_I_23(self, value):
+        self._propellant_I_23 = value
+
+    @property
+    def exhaust_velocity(self):
+        return self.motor.exhaust_velocity
+
+    def _calculate_dry_inertia(self):
+        Ixx_loc = self.motor.dry_I_11
+        Iyy_loc = self.motor.dry_I_22
+        Izz_loc = self.motor.dry_I_33
+        m_dry = self.motor.dry_mass
+
+        Izz_cluster = self.number * Izz_loc + self.number * m_dry * (self.radius**2)
+        Ixx_cluster = self.number * Ixx_loc
+        Iyy_cluster = self.number * Iyy_loc
+
+        angles = np.linspace(0, 2 * np.pi, self.number, endpoint=False)
+        for angle in angles:
+            x = self.radius * np.cos(angle)
+            y = self.radius * np.sin(angle)
+            Ixx_cluster += m_dry * (y**2)
+            Iyy_cluster += m_dry * (x**2)
+
+        return (Ixx_cluster, Iyy_cluster, Izz_cluster)
+
+    def info(self, *args, **kwargs):
+        print("Cluster Configuration:")
+        print(f" - Motors: {self.number} x {type(self.motor).__name__}")
+        print(f" - Radial Distance: {self.radius} m")
+        return self.motor.info(*args, **kwargs)
+
+    def to_dict(self, **kwargs):
+        data = super().to_dict(**kwargs)
+        data.update(
+            {
+                "motor": self.motor,
+                "number": self.number,
+                "radius": self.radius,
+            }
+        )
+        return data
+
+    @classmethod
+    def from_dict(cls, data):
+        return cls(
+            motor=data["motor"],
+            number=data["number"],
+            radius=data["radius"],
+        )
+
+    def draw_cluster_layout(self, rocket_radius=None, show=True):
+        """Draw the geometric layout of the clustered motors."""
+        fig, ax = plt.subplots(figsize=(6, 6))
+        ax.plot(0, 0, "k+", markersize=10, label="Central axis")
+        if rocket_radius:
+            rocket_tube = plt.Circle(
+                (0, 0),
+                rocket_radius,
+                color="black",
+                fill=False,
+                linestyle="--",
+                linewidth=2,
+                label="Rocket",
+            )
+            ax.add_patch(rocket_tube)
+            limit = rocket_radius * 1.2
+        else:
+            limit = self.radius * 2
+        self._draw_engines(ax)
+        ax.set_aspect("equal", "box")
+        ax.set_xlim(-limit, limit)
+        ax.set_ylim(-limit, limit)
+        ax.set_xlabel("Position X (m)")
+        ax.set_ylabel("Position Y (m)")
+        ax.set_title(f"Cluster Configuration : {self.number} engines")
+        ax.grid(True, linestyle=":", alpha=0.6)
+        ax.legend(loc="upper right")
+        if show:
+            plt.show()
+        return fig, ax
+
+    def _draw_engines(self, ax):
+        """Draws the individual engines of the cluster."""
+        motor_outer_radius = self.grain_outer_radius
+        angles = np.linspace(0, 2 * np.pi, self.number, endpoint=False)
+
+        for i, angle in enumerate(angles):
+            x = self.radius * np.cos(angle)
+            y = self.radius * np.sin(angle)
+            motor_circle = plt.Circle(
+                (x, y),
+                motor_outer_radius,
+                color="red",
+                alpha=0.5,
+                label="Engine" if i == 0 else "",
+            )
+            ax.add_patch(motor_circle)
+            ax.text(
+                x,
+                y,
+                str(i + 1),
+                color="white",
+                ha="center",
+                va="center",
+                fontweight="bold",
+            )
diff --git a/rocketpy/motors/solid_motor.py b/rocketpy/motors/solid_motor.py
index 590a02511..6d1582109 100644
--- a/rocketpy/motors/solid_motor.py
+++ b/rocketpy/motors/solid_motor.py
@@ -483,7 +483,7 @@ def center_of_propellant_mass(self):
         center_of_mass = np.full_like(time_source, self.grains_center_of_mass_position)
         return np.column_stack((time_source, center_of_mass))
 
-    # pylint: disable=too-many-arguments, too-many-statements
+    # pylint: disable=too-many-statements
     def evaluate_geometry(self):
         """Calculates grain inner radius and grain height as a function of time
         by assuming that every propellant mass burnt is exhausted. In order to
diff --git a/rocketpy/motors/tank.py b/rocketpy/motors/tank.py
index 6f3496b32..0d6b329a4 100644
--- a/rocketpy/motors/tank.py
+++ b/rocketpy/motors/tank.py
@@ -6,7 +6,7 @@
 from ..mathutils.function import Function, funcify_method
 from ..plots.tank_plots import _TankPlots
 from ..prints.tank_prints import _TankPrints
-from ..tools import deprecated, tuple_handler
+from ..tools import tuple_handler
 
 
 class Tank(ABC):
@@ -1025,17 +1025,6 @@ def _discretize_fluid_inputs(self):
             )
             self._gas_density.set_discrete_based_on_model(self.gas_mass_flow_rate_in)
 
-    @deprecated(
-        "Should not be a public member of the class.",
-        "1.12.0",
-        "_discretize_fluid_inputs",
-    )
-    def discretize_flow(self):
-        """Discretizes the mass flow rate inputs according to the flux time and
-        the discretize parameter.
-        """
-        self._discretize_fluid_inputs()
-
     def to_dict(self, **kwargs):
         data = super().to_dict(**kwargs)
         data.update(
@@ -1288,17 +1277,6 @@ def _discretize_fluid_inputs(self):
             self._liquid_density.set_discrete_based_on_model(self.ullage)
             self._gas_density.set_discrete_based_on_model(self.ullage)
 
-    @deprecated(
-        "Should not be a public member of the class.",
-        "1.12.0",
-        "_discretize_fluid_inputs",
-    )
-    def discretize_ullage(self):
-        """Discretizes the ullage input according to the flux time
-        and the discretize parameter.
-        """
-        self._discretize_fluid_inputs()
-
     def to_dict(self, **kwargs):
         data = super().to_dict(**kwargs)
         data.update({"ullage": self.ullage})
@@ -1542,17 +1520,6 @@ def gas_height(self):
             self.liquid_level
         )
 
-    @deprecated(
-        "Should not be a public member of the class.",
-        "1.12.0",
-        "_discretize_fluid_inputs",
-    )
-    def discretize_liquid_height(self):
-        """Discretizes the liquid height input according to the flux time
-        and the discretize parameter.
-        """
-        self._discretize_fluid_inputs()
-
     def _discretize_fluid_inputs(self):
         """Uniformly discretizes the parameter of inputs of fluid data ."""
         if self.discretize:
@@ -1835,17 +1802,6 @@ def gas_height(self):
             )
         return gas_height
 
-    @deprecated(
-        "Should not be a public member of the class.",
-        "1.12.0",
-        "_discretize_fluid_inputs",
-    )
-    def discretize_masses(self):
-        """Discretizes the fluid mass inputs according to the flux time
-        and the discretize parameter.
-        """
-        self._discretize_fluid_inputs()
-
     def _discretize_fluid_inputs(self):
         """Uniformly discretizes the parameter of inputs of fluid data ."""
         if self.discretize:
diff --git a/rocketpy/motors/tank_geometry.py b/rocketpy/motors/tank_geometry.py
index 485f57b09..e71f862ca 100644
--- a/rocketpy/motors/tank_geometry.py
+++ b/rocketpy/motors/tank_geometry.py
@@ -1,3 +1,5 @@
+import logging
+import warnings
 from functools import cached_property
 
 import numpy as np
@@ -7,6 +9,8 @@
 from ..plots.tank_geometry_plots import _TankGeometryPlots
 from ..prints.tank_geometry_prints import _TankGeometryPrints
 
+logger = logging.getLogger(__name__)
+
 try:
     from functools import cache
 except ImportError:
@@ -384,14 +388,21 @@ class inherits from the TankGeometry class. See the TankGeometry class
     for more information on its attributes and methods.
     """
 
-    def __init__(self, radius, height, spherical_caps=False, geometry_dict=None):
+    def __init__(
+        self,
+        radius_function=None,
+        height=None,
+        spherical_caps=False,
+        geometry_dict=None,
+        **kwargs,
+    ):
         """Initialize CylindricalTank class. The zero reference point of the
         cylinder is its center (i.e. half of its height). Therefore the its
         height coordinate span is (-height/2, height/2).
 
         Parameters
         ----------
-        radius : float
+        radius_function : int, float
             Radius of the cylindrical tank, in meters.
         height : float
             Height of the cylindrical tank, in meters.
@@ -401,17 +412,52 @@ def __init__(self, radius, height, spherical_caps=False, geometry_dict=None):
             will have flat caps at the top and bottom. Defaults to False.
         geometry_dict : Union[dict, None], optional
             Dictionary containing the geometry of the tank. See TankGeometry.
-        """
+
+        Notes
+        -----
+        The ``radius`` keyword argument is deprecated. Use ``radius_function``
+        instead.
+        """
+        if "radius" in kwargs:
+            if radius_function is not None:
+                raise TypeError(
+                    "Cannot specify both 'radius_function' and deprecated "
+                    "'radius' arguments. Use 'radius_function' instead."
+                )
+            warnings.warn(
+                "The 'radius' argument in CylindricalTank is deprecated in v1.13.0 "
+                "and will be removed in v2.0.0. Use 'radius_function' instead.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            radius_function = kwargs.pop("radius")
+        if kwargs:
+            raise TypeError(
+                "CylindricalTank.__init__() got unexpected keyword argument(s): "
+                f"{', '.join(map(repr, kwargs))}"
+            )
+        if radius_function is None:
+            raise TypeError(
+                "CylindricalTank.__init__() missing required argument: "
+                "'radius_function'"
+            )
+        if height is None:
+            raise TypeError(
+                "CylindricalTank.__init__() missing required argument: 'height'"
+            )
         geometry_dict = geometry_dict or {}
         super().__init__(geometry_dict)
-        self.__input_radius = radius
+        self.radius_function = radius_function
         self.height = height
         self.has_caps = False
         if spherical_caps:
-            self.add_geometry((-height / 2 + radius, height / 2 - radius), radius)
+            self.add_geometry(
+                (-height / 2 + radius_function, height / 2 - radius_function),
+                radius_function,
+            )
             self.add_spherical_caps()
         else:
-            self.add_geometry((-height / 2, height / 2), radius)
+            self.add_geometry((-height / 2, height / 2), radius_function)
 
     def add_spherical_caps(self):
         """
@@ -420,15 +466,16 @@ def add_spherical_caps(self):
         part. The height is not modified, meaning that the total volume of
         the tank will decrease.
         """
-        print(
-            "Warning: Adding spherical caps to the tank will not modify the "
-            + f"total height of the tank {self.height} m. "
-            + "Its cylindrical portion height will be reduced to "
-            + f"{self.height - 2 * self.__input_radius} m."
+        logger.warning(
+            "Adding spherical caps to the tank will not modify the total height "
+            "of the tank (%.4f m). Its cylindrical portion height will be "
+            "reduced to %s m.",
+            self.height,
+            self.height - 2 * self.radius_function,
         )
 
         if not self.has_caps:
-            radius = self.__input_radius
+            radius = self.radius_function
             height = self.height
             bottom_cap_range = (-height / 2, -height / 2 + radius)
             upper_cap_range = (height / 2 - radius, height / 2)
@@ -447,7 +494,7 @@ def upper_cap_radius(h):
 
     def to_dict(self, **kwargs):
         data = {
-            "radius": self.__input_radius,
+            "radius_function": self.radius_function,
             "height": self.height,
             "spherical_caps": self.has_caps,
         }
@@ -459,7 +506,18 @@ def to_dict(self, **kwargs):
 
     @classmethod
     def from_dict(cls, data):
-        return cls(data["radius"], data["height"], data["spherical_caps"])
+        if "radius_function" in data:
+            radius_function = data["radius_function"]
+        else:
+            warnings.warn(
+                "The 'radius' key in CylindricalTank serialized data is "
+                "deprecated in v1.13.0 and will be removed in v2.0.0. "
+                "Use 'radius_function' instead.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            radius_function = data["radius"]
+        return cls(radius_function, data["height"], data["spherical_caps"])
 
 
 class SphericalTank(TankGeometry):
@@ -468,25 +526,55 @@ class SphericalTank(TankGeometry):
     inherits from the TankGeometry class. See the TankGeometry class for
     more information on its attributes and methods."""
 
-    def __init__(self, radius, geometry_dict=None):
+    def __init__(self, radius_function=None, geometry_dict=None, **kwargs):
         """Initialize SphericalTank class. The zero reference point of the
         sphere is its center (i.e. half of its height). Therefore, its height
-        coordinate ranges between (-radius, radius).
+        coordinate ranges between (-radius_function, radius_function).
 
         Parameters
         ----------
-        radius : float
-            Radius of the spherical tank.
+        radius_function : int, float
+            Radius of the spherical tank, in meters.
         geometry_dict : Union[dict, None], optional
             Dictionary containing the geometry of the tank. See TankGeometry.
-        """
+
+        Notes
+        -----
+        The ``radius`` keyword argument is deprecated. Use ``radius_function``
+        instead.
+        """
+        if "radius" in kwargs:
+            if radius_function is not None:
+                raise TypeError(
+                    "Cannot specify both 'radius_function' and deprecated "
+                    "'radius' arguments. Use 'radius_function' instead."
+                )
+            warnings.warn(
+                "The 'radius' argument in SphericalTank is deprecated in v1.13.0 "
+                "and will be removed in v2.0.0. Use 'radius_function' instead.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            radius_function = kwargs.pop("radius")
+        if kwargs:
+            raise TypeError(
+                "SphericalTank.__init__() got unexpected keyword argument(s): "
+                f"{', '.join(map(repr, kwargs))}"
+            )
+        if radius_function is None:
+            raise TypeError(
+                "SphericalTank.__init__() missing required argument: 'radius_function'"
+            )
         geometry_dict = geometry_dict or {}
         super().__init__(geometry_dict)
-        self.__input_radius = radius
-        self.add_geometry((-radius, radius), lambda h: (radius**2 - h**2) ** 0.5)
+        self.radius_function = radius_function
+        self.add_geometry(
+            (-radius_function, radius_function),
+            lambda h: (radius_function**2 - h**2) ** 0.5,
+        )
 
     def to_dict(self, **kwargs):
-        data = {"radius": self.__input_radius}
+        data = {"radius_function": self.radius_function}
 
         if kwargs.get("include_outputs", False):
             data.update(super().to_dict(**kwargs))
@@ -495,4 +583,15 @@ def to_dict(self, **kwargs):
 
     @classmethod
     def from_dict(cls, data):
-        return cls(data["radius"])
+        if "radius_function" in data:
+            radius_function = data["radius_function"]
+        else:
+            warnings.warn(
+                "The 'radius' key in SphericalTank serialized data is "
+                "deprecated in v1.13.0 and will be removed in v2.0.0. "
+                "Use 'radius_function' instead.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            radius_function = data["radius"]
+        return cls(radius_function)
diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py
index 397ad8f55..eb97ce19b 100644
--- a/rocketpy/plots/aero_surface_plots.py
+++ b/rocketpy/plots/aero_surface_plots.py
@@ -1,3 +1,5 @@
+# pylint: disable=too-many-statements
+
 from abc import ABC, abstractmethod
 
 import matplotlib.pyplot as plt
@@ -139,14 +141,97 @@ class _FinsPlots(_AeroSurfacePlots):
     """Abstract class that contains all fin plots. This class inherits from the
     _AeroSurfacePlots class."""
 
-    @abstractmethod
-    def draw(self, *, filename=None):
-        pass
+    def airfoil(self, *, filename=None):
+        """Plots the airfoil information when the fin has an airfoil shape. If
+        the fin does not have an airfoil shape, this method does nothing.
+
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
+        Returns
+        -------
+        None
+        """
+
+        if self.aero_surface.airfoil:
+            print("Airfoil lift curve:")
+            self.aero_surface.airfoil_cl.plot_1d(force_data=True, filename=filename)
+
+    def roll(self, *, filename=None):
+        """Plots the roll parameters of the fin set.
+
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
+        Returns
+        -------
+        None
+        """
+        print("Roll parameters:")
+        self.aero_surface.roll_parameters[0](filename=filename)
+        self.aero_surface.roll_parameters[1](filename=filename)
+
+    def lift(self, *, filename=None):
+        """Plots the lift coefficient of the aero surface as a function of Mach
+        and the angle of attack. A 3D plot is expected. See the rocketpy.Function
+        class for more information on how this plot is made. Also, this method
+        plots the lift coefficient considering a single fin and the lift
+        coefficient considering all fins.
+
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
+        Returns
+        -------
+        None
+        """
+        print("Lift coefficient:")
+        self.aero_surface.cl(filename=filename)
+        self.aero_surface.clalpha_single_fin(filename=filename)
+        self.aero_surface.clalpha_multiple_fins(filename=filename)
+
+    def all(self, *, filename=None):
+        """Plots all available fin plots.
+
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
+        Returns
+        -------
+        None
+        """
+        self.draw(filename=filename)
+        self.airfoil(filename=filename)
+        self.roll(filename=filename)
+        self.lift(filename=filename)
 
-    def airfoil(self):
+
+class _FinPlots(_AeroSurfacePlots):
+    """Abstract class that contains all fin plots. This class inherits from the
+    _AeroSurfacePlots class."""
+
+    def airfoil(self, *, filename=None):
         """Plots the airfoil information when the fin has an airfoil shape. If
         the fin does not have an airfoil shape, this method does nothing.
 
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
         Returns
         -------
         None
@@ -154,52 +239,68 @@ def airfoil(self):
 
         if self.aero_surface.airfoil:
             print("Airfoil lift curve:")
-            self.aero_surface.airfoil_cl.plot_1d(force_data=True)
+            self.aero_surface.airfoil_cl.plot_1d(force_data=True, filename=filename)
 
-    def roll(self):
+    def roll(self, *, filename=None):
         """Plots the roll parameters of the fin set.
 
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
         Returns
         -------
         None
         """
         print("Roll parameters:")
-        self.aero_surface.roll_parameters[0]()
-        self.aero_surface.roll_parameters[1]()
+        self.aero_surface.roll_parameters[0](filename=filename)
+        self.aero_surface.roll_parameters[1](filename=filename)
 
-    def lift(self):
+    def lift(self, *, filename=None):
         """Plots the lift coefficient of the aero surface as a function of Mach
         and the angle of attack. A 3D plot is expected. See the rocketpy.Function
         class for more information on how this plot is made. Also, this method
         plots the lift coefficient considering a single fin and the lift
         coefficient considering all fins.
 
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
         Returns
         -------
         None
         """
         print("Lift coefficient:")
-        self.aero_surface.cl()
-        self.aero_surface.clalpha_single_fin()
-        self.aero_surface.clalpha_multiple_fins()
+        self.aero_surface.cl(filename=filename)
+        self.aero_surface.clalpha_single_fin(filename=filename)
 
-    def all(self):
+    def all(self, *, filename=None):
         """Plots all available fin plots.
 
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved.
+
         Returns
         -------
         None
         """
-        self.draw()
-        self.airfoil()
-        self.roll()
-        self.lift()
+        self.draw(filename=filename)
+        self.airfoil(filename=filename)
+        self.roll(filename=filename)
+        self.lift(filename=filename)
 
 
 class _TrapezoidalFinsPlots(_FinsPlots):
     """Class that contains all trapezoidal fin plots."""
 
-    # pylint: disable=too-many-statements
     def draw(self, *, filename=None):
         """Draw the fin shape along with some important information, including
         the center line, the quarter line and the center of pressure position.
@@ -325,10 +426,129 @@ def draw(self, *, filename=None):
         show_or_save_plot(filename)
 
 
+class _TrapezoidalFinPlots(_FinPlots):
+    """Class that contains all trapezoidal fin plots."""
+
+    def draw(self, *, filename=None):
+        """Draw the fin shape along with some important information, including
+        the center line, the quarter line and the center of pressure position.
+
+        Returns
+        -------
+        None
+        """
+        # Color cycle [#348ABD, #A60628, #7A68A6, #467821, #D55E00, #CC79A7,
+        # #56B4E9, #009E73, #F0E442, #0072B2]
+        # Fin
+        leading_edge = plt.Line2D(
+            (0, self.aero_surface.sweep_length),
+            (0, self.aero_surface.span),
+            color="#A60628",
+        )
+        tip = plt.Line2D(
+            (
+                self.aero_surface.sweep_length,
+                self.aero_surface.sweep_length + self.aero_surface.tip_chord,
+            ),
+            (self.aero_surface.span, self.aero_surface.span),
+            color="#A60628",
+        )
+        back_edge = plt.Line2D(
+            (
+                self.aero_surface.sweep_length + self.aero_surface.tip_chord,
+                self.aero_surface.root_chord,
+            ),
+            (self.aero_surface.span, 0),
+            color="#A60628",
+        )
+        root = plt.Line2D((self.aero_surface.root_chord, 0), (0, 0), color="#A60628")
+
+        # Center and Quarter line
+        center_line = plt.Line2D(
+            (
+                self.aero_surface.root_chord / 2,
+                self.aero_surface.sweep_length + self.aero_surface.tip_chord / 2,
+            ),
+            (0, self.aero_surface.span),
+            color="#7A68A6",
+            alpha=0.35,
+            linestyle="--",
+            label="Center Line",
+        )
+        quarter_line = plt.Line2D(
+            (
+                self.aero_surface.root_chord / 4,
+                self.aero_surface.sweep_length + self.aero_surface.tip_chord / 4,
+            ),
+            (0, self.aero_surface.span),
+            color="#7A68A6",
+            alpha=1,
+            linestyle="--",
+            label="Quarter Line",
+        )
+
+        # Center of pressure
+        cp_point = [self.aero_surface.cpz, self.aero_surface.Yma]
+
+        # Mean Aerodynamic Chord
+        yma_start = (
+            self.aero_surface.sweep_length
+            * (self.aero_surface.root_chord + 2 * self.aero_surface.tip_chord)
+            / (3 * (self.aero_surface.root_chord + self.aero_surface.tip_chord))
+        )
+        yma_end = (
+            2 * self.aero_surface.root_chord**2
+            + self.aero_surface.root_chord * self.aero_surface.sweep_length
+            + 2 * self.aero_surface.root_chord * self.aero_surface.tip_chord
+            + 2 * self.aero_surface.sweep_length * self.aero_surface.tip_chord
+            + 2 * self.aero_surface.tip_chord**2
+        ) / (3 * (self.aero_surface.root_chord + self.aero_surface.tip_chord))
+        yma_line = plt.Line2D(
+            (yma_start, yma_end),
+            (self.aero_surface.Yma, self.aero_surface.Yma),
+            color="#467821",
+            linestyle="--",
+            label="Mean Aerodynamic Chord",
+        )
+
+        # Plotting
+        fig = plt.figure(figsize=(7, 4))
+        with plt.style.context("bmh"):
+            ax = fig.add_subplot(111)
+
+        # Fin
+        ax.add_line(leading_edge)
+        ax.add_line(tip)
+        ax.add_line(back_edge)
+        ax.add_line(root)
+
+        ax.add_line(center_line)
+        ax.add_line(quarter_line)
+        ax.add_line(yma_line)
+        ax.scatter(*cp_point, label="Center of Pressure", color="red", s=100, zorder=10)
+        ax.scatter(*cp_point, facecolors="none", edgecolors="red", s=500, zorder=10)
+
+        # Plot settings
+        xlim = (
+            self.aero_surface.root_chord
+            if self.aero_surface.sweep_length + self.aero_surface.tip_chord
+            < self.aero_surface.root_chord
+            else self.aero_surface.sweep_length + self.aero_surface.tip_chord
+        )
+        ax.set_xlim(0, xlim * 1.1)
+        ax.set_ylim(0, self.aero_surface.span * 1.1)
+        ax.set_xlabel("Root chord (m)")
+        ax.set_ylabel("Span (m)")
+        ax.set_title("Trapezoidal Fin Cross Section")
+        ax.legend(bbox_to_anchor=(1.05, 1.0), loc="upper left")
+
+        plt.tight_layout()
+        show_or_save_plot(filename)
+
+
 class _EllipticalFinsPlots(_FinsPlots):
     """Class that contains all elliptical fin plots."""
 
-    # pylint: disable=too-many-statements
     def draw(self, *, filename=None):
         """Draw the fin shape along with some important information.
         These being: the center line and the center of pressure position.
@@ -404,10 +624,153 @@ def draw(self, *, filename=None):
         show_or_save_plot(filename)
 
 
+class _EllipticalFinPlots(_FinPlots):
+    """Class that contains all elliptical fin plots."""
+
+    def draw(self, *, filename=None):
+        """Draw the fin shape along with some important information.
+        These being: the center line and the center of pressure position.
+
+        Returns
+        -------
+        None
+        """
+        # Ellipse
+        ellipse = Ellipse(
+            (self.aero_surface.root_chord / 2, 0),
+            self.aero_surface.root_chord,
+            self.aero_surface.span * 2,
+            fill=False,
+            edgecolor="#A60628",
+            linewidth=2,
+        )
+
+        # Mean Aerodynamic Chord # From Barrowman's theory
+        yma_length = 8 * self.aero_surface.root_chord / (3 * np.pi)
+        yma_start = (self.aero_surface.root_chord - yma_length) / 2
+        yma_end = (
+            self.aero_surface.root_chord
+            - (self.aero_surface.root_chord - yma_length) / 2
+        )
+        yma_line = plt.Line2D(
+            (yma_start, yma_end),
+            (self.aero_surface.Yma, self.aero_surface.Yma),
+            label="Mean Aerodynamic Chord",
+            color="#467821",
+        )
+
+        # Center Line
+        center_line = plt.Line2D(
+            (self.aero_surface.root_chord / 2, self.aero_surface.root_chord / 2),
+            (0, self.aero_surface.span),
+            color="#7A68A6",
+            alpha=0.35,
+            linestyle="--",
+            label="Center Line",
+        )
+
+        # Center of pressure
+        cp_point = [self.aero_surface.cpz, self.aero_surface.Yma]
+
+        # Plotting
+        fig = plt.figure(figsize=(7, 4))
+        with plt.style.context("bmh"):
+            ax = fig.add_subplot(111)
+        ax.add_patch(ellipse)
+        ax.add_line(yma_line)
+        ax.add_line(center_line)
+        ax.scatter(*cp_point, label="Center of Pressure", color="red", s=100, zorder=10)
+        ax.scatter(*cp_point, facecolors="none", edgecolors="red", s=500, zorder=10)
+
+        # Plot settings
+        ax.set_xlim(0, self.aero_surface.root_chord)
+        ax.set_ylim(0, self.aero_surface.span * 1.1)
+        ax.set_xlabel("Root chord (m)")
+        ax.set_ylabel("Span (m)")
+        ax.set_title("Elliptical Fin Cross Section")
+        ax.legend(bbox_to_anchor=(1.05, 1.0), loc="upper left")
+
+        plt.tight_layout()
+        show_or_save_plot(filename)
+
+
 class _FreeFormFinsPlots(_FinsPlots):
     """Class that contains all free form fin plots."""
 
-    # pylint: disable=too-many-statements
+    def draw(self, *, filename=None):
+        """Draw the fin shape along with some important information.
+        These being: the center line and the center of pressure position.
+
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved. Supported file endings are:
+            eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff
+            and webp (these are the formats supported by matplotlib).
+
+        Returns
+        -------
+        None
+        """
+        # Color cycle [#348ABD, #A60628, #7A68A6, #467821, #D55E00, #CC79A7,
+        # #56B4E9, #009E73, #F0E442, #0072B2]
+
+        # Center of pressure
+        cp_point = [self.aero_surface.cpz, self.aero_surface.Yma]
+
+        # Mean Aerodynamic Chord
+        yma_line = plt.Line2D(
+            (
+                self.aero_surface.mac_lead,
+                self.aero_surface.mac_lead + self.aero_surface.mac_length,
+            ),
+            (self.aero_surface.Yma, self.aero_surface.Yma),
+            color="#467821",
+            linestyle="--",
+            label="Mean Aerodynamic Chord",
+        )
+
+        # Plotting
+        fig = plt.figure(figsize=(7, 4))
+        with plt.style.context("bmh"):
+            ax = fig.add_subplot(111)
+
+        # Fin
+        ax.scatter(
+            self.aero_surface.shape_vec[0],
+            self.aero_surface.shape_vec[1],
+            color="#A60628",
+        )
+        ax.plot(
+            self.aero_surface.shape_vec[0],
+            self.aero_surface.shape_vec[1],
+            color="#A60628",
+        )
+        # line from the last point to the first point
+        ax.plot(
+            [self.aero_surface.shape_vec[0][-1], self.aero_surface.shape_vec[0][0]],
+            [self.aero_surface.shape_vec[1][-1], self.aero_surface.shape_vec[1][0]],
+            color="#A60628",
+        )
+
+        ax.add_line(yma_line)
+        ax.scatter(*cp_point, label="Center of Pressure", color="red", s=100, zorder=10)
+        ax.scatter(*cp_point, facecolors="none", edgecolors="red", s=500, zorder=10)
+
+        # Plot settings
+        ax.set_xlabel("Root chord (m)")
+        ax.set_ylabel("Span (m)")
+        ax.set_title("Free Form Fin Cross Section")
+        ax.legend(bbox_to_anchor=(1.05, 1.0), loc="upper left")
+
+        plt.tight_layout()
+        show_or_save_plot(filename)
+
+
+class _FreeFormFinPlots(_FinPlots):
+    """Class that contains all free form fin plots."""
+
     def draw(self, *, filename=None):
         """Draw the fin shape along with some important information.
         These being: the center line and the center of pressure position.
diff --git a/rocketpy/plots/assets/default_rocket.stl b/rocketpy/plots/assets/default_rocket.stl
new file mode 100644
index 000000000..a8ee68d7c
--- /dev/null
+++ b/rocketpy/plots/assets/default_rocket.stl
@@ -0,0 +1,674 @@
+solid default_rocket
+  facet normal 0.991445 0.130526 0.000000
+    outer loop
+      vertex 0.300000 0.000000 0.000000
+      vertex 0.289778 0.077646 0.000000
+      vertex 0.289778 0.077646 3.000000
+    endloop
+  endfacet
+  facet normal 0.991445 0.130526 -0.000000
+    outer loop
+      vertex 0.300000 0.000000 0.000000
+      vertex 0.289778 0.077646 3.000000
+      vertex 0.300000 0.000000 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.289778 0.077646 0.000000
+      vertex 0.300000 0.000000 0.000000
+    endloop
+  endfacet
+  facet normal 0.950301 0.125109 0.285090
+    outer loop
+      vertex 0.300000 0.000000 3.000000
+      vertex 0.289778 0.077646 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.923880 0.382683 0.000000
+    outer loop
+      vertex 0.289778 0.077646 0.000000
+      vertex 0.259808 0.150000 0.000000
+      vertex 0.259808 0.150000 3.000000
+    endloop
+  endfacet
+  facet normal 0.923880 0.382683 -0.000000
+    outer loop
+      vertex 0.289778 0.077646 0.000000
+      vertex 0.259808 0.150000 3.000000
+      vertex 0.289778 0.077646 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.259808 0.150000 0.000000
+      vertex 0.289778 0.077646 0.000000
+    endloop
+  endfacet
+  facet normal 0.885539 0.366802 0.285090
+    outer loop
+      vertex 0.289778 0.077646 3.000000
+      vertex 0.259808 0.150000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.793353 0.608761 0.000000
+    outer loop
+      vertex 0.259808 0.150000 0.000000
+      vertex 0.212132 0.212132 0.000000
+      vertex 0.212132 0.212132 3.000000
+    endloop
+  endfacet
+  facet normal 0.793353 0.608761 -0.000000
+    outer loop
+      vertex 0.259808 0.150000 0.000000
+      vertex 0.212132 0.212132 3.000000
+      vertex 0.259808 0.150000 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.212132 0.212132 0.000000
+      vertex 0.259808 0.150000 0.000000
+    endloop
+  endfacet
+  facet normal 0.760430 0.583498 0.285090
+    outer loop
+      vertex 0.259808 0.150000 3.000000
+      vertex 0.212132 0.212132 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.608761 0.793353 0.000000
+    outer loop
+      vertex 0.212132 0.212132 0.000000
+      vertex 0.150000 0.259808 0.000000
+      vertex 0.150000 0.259808 3.000000
+    endloop
+  endfacet
+  facet normal 0.608761 0.793353 -0.000000
+    outer loop
+      vertex 0.212132 0.212132 0.000000
+      vertex 0.150000 0.259808 3.000000
+      vertex 0.212132 0.212132 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.150000 0.259808 0.000000
+      vertex 0.212132 0.212132 0.000000
+    endloop
+  endfacet
+  facet normal 0.583498 0.760430 0.285090
+    outer loop
+      vertex 0.212132 0.212132 3.000000
+      vertex 0.150000 0.259808 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.382683 0.923880 0.000000
+    outer loop
+      vertex 0.150000 0.259808 0.000000
+      vertex 0.077646 0.289778 0.000000
+      vertex 0.077646 0.289778 3.000000
+    endloop
+  endfacet
+  facet normal 0.382683 0.923880 -0.000000
+    outer loop
+      vertex 0.150000 0.259808 0.000000
+      vertex 0.077646 0.289778 3.000000
+      vertex 0.150000 0.259808 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.077646 0.289778 0.000000
+      vertex 0.150000 0.259808 0.000000
+    endloop
+  endfacet
+  facet normal 0.366802 0.885539 0.285090
+    outer loop
+      vertex 0.150000 0.259808 3.000000
+      vertex 0.077646 0.289778 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.130526 0.991445 0.000000
+    outer loop
+      vertex 0.077646 0.289778 0.000000
+      vertex 0.000000 0.300000 0.000000
+      vertex 0.000000 0.300000 3.000000
+    endloop
+  endfacet
+  facet normal 0.130526 0.991445 -0.000000
+    outer loop
+      vertex 0.077646 0.289778 0.000000
+      vertex 0.000000 0.300000 3.000000
+      vertex 0.077646 0.289778 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.000000 0.300000 0.000000
+      vertex 0.077646 0.289778 0.000000
+    endloop
+  endfacet
+  facet normal 0.125109 0.950301 0.285090
+    outer loop
+      vertex 0.077646 0.289778 3.000000
+      vertex 0.000000 0.300000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.130526 0.991445 0.000000
+    outer loop
+      vertex 0.000000 0.300000 0.000000
+      vertex -0.077646 0.289778 0.000000
+      vertex -0.077646 0.289778 3.000000
+    endloop
+  endfacet
+  facet normal -0.130526 0.991445 0.000000
+    outer loop
+      vertex 0.000000 0.300000 0.000000
+      vertex -0.077646 0.289778 3.000000
+      vertex 0.000000 0.300000 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.077646 0.289778 0.000000
+      vertex 0.000000 0.300000 0.000000
+    endloop
+  endfacet
+  facet normal -0.125109 0.950301 0.285090
+    outer loop
+      vertex 0.000000 0.300000 3.000000
+      vertex -0.077646 0.289778 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.382683 0.923880 0.000000
+    outer loop
+      vertex -0.077646 0.289778 0.000000
+      vertex -0.150000 0.259808 0.000000
+      vertex -0.150000 0.259808 3.000000
+    endloop
+  endfacet
+  facet normal -0.382683 0.923880 0.000000
+    outer loop
+      vertex -0.077646 0.289778 0.000000
+      vertex -0.150000 0.259808 3.000000
+      vertex -0.077646 0.289778 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.150000 0.259808 0.000000
+      vertex -0.077646 0.289778 0.000000
+    endloop
+  endfacet
+  facet normal -0.366802 0.885539 0.285090
+    outer loop
+      vertex -0.077646 0.289778 3.000000
+      vertex -0.150000 0.259808 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.608761 0.793353 0.000000
+    outer loop
+      vertex -0.150000 0.259808 0.000000
+      vertex -0.212132 0.212132 0.000000
+      vertex -0.212132 0.212132 3.000000
+    endloop
+  endfacet
+  facet normal -0.608761 0.793353 0.000000
+    outer loop
+      vertex -0.150000 0.259808 0.000000
+      vertex -0.212132 0.212132 3.000000
+      vertex -0.150000 0.259808 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.212132 0.212132 0.000000
+      vertex -0.150000 0.259808 0.000000
+    endloop
+  endfacet
+  facet normal -0.583498 0.760430 0.285090
+    outer loop
+      vertex -0.150000 0.259808 3.000000
+      vertex -0.212132 0.212132 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.793353 0.608761 0.000000
+    outer loop
+      vertex -0.212132 0.212132 0.000000
+      vertex -0.259808 0.150000 0.000000
+      vertex -0.259808 0.150000 3.000000
+    endloop
+  endfacet
+  facet normal -0.793353 0.608761 0.000000
+    outer loop
+      vertex -0.212132 0.212132 0.000000
+      vertex -0.259808 0.150000 3.000000
+      vertex -0.212132 0.212132 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.259808 0.150000 0.000000
+      vertex -0.212132 0.212132 0.000000
+    endloop
+  endfacet
+  facet normal -0.760430 0.583498 0.285090
+    outer loop
+      vertex -0.212132 0.212132 3.000000
+      vertex -0.259808 0.150000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.923880 0.382683 0.000000
+    outer loop
+      vertex -0.259808 0.150000 0.000000
+      vertex -0.289778 0.077646 0.000000
+      vertex -0.289778 0.077646 3.000000
+    endloop
+  endfacet
+  facet normal -0.923880 0.382683 0.000000
+    outer loop
+      vertex -0.259808 0.150000 0.000000
+      vertex -0.289778 0.077646 3.000000
+      vertex -0.259808 0.150000 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.289778 0.077646 0.000000
+      vertex -0.259808 0.150000 0.000000
+    endloop
+  endfacet
+  facet normal -0.885539 0.366802 0.285090
+    outer loop
+      vertex -0.259808 0.150000 3.000000
+      vertex -0.289778 0.077646 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.991445 0.130526 0.000000
+    outer loop
+      vertex -0.289778 0.077646 0.000000
+      vertex -0.300000 0.000000 0.000000
+      vertex -0.300000 0.000000 3.000000
+    endloop
+  endfacet
+  facet normal -0.991445 0.130526 0.000000
+    outer loop
+      vertex -0.289778 0.077646 0.000000
+      vertex -0.300000 0.000000 3.000000
+      vertex -0.289778 0.077646 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.300000 0.000000 0.000000
+      vertex -0.289778 0.077646 0.000000
+    endloop
+  endfacet
+  facet normal -0.950301 0.125109 0.285090
+    outer loop
+      vertex -0.289778 0.077646 3.000000
+      vertex -0.300000 0.000000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.991445 -0.130526 0.000000
+    outer loop
+      vertex -0.300000 0.000000 0.000000
+      vertex -0.289778 -0.077646 0.000000
+      vertex -0.289778 -0.077646 3.000000
+    endloop
+  endfacet
+  facet normal -0.991445 -0.130526 0.000000
+    outer loop
+      vertex -0.300000 0.000000 0.000000
+      vertex -0.289778 -0.077646 3.000000
+      vertex -0.300000 0.000000 3.000000
+    endloop
+  endfacet
+  facet normal -0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.289778 -0.077646 0.000000
+      vertex -0.300000 0.000000 0.000000
+    endloop
+  endfacet
+  facet normal -0.950301 -0.125109 0.285090
+    outer loop
+      vertex -0.300000 0.000000 3.000000
+      vertex -0.289778 -0.077646 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.923880 -0.382683 0.000000
+    outer loop
+      vertex -0.289778 -0.077646 0.000000
+      vertex -0.259808 -0.150000 0.000000
+      vertex -0.259808 -0.150000 3.000000
+    endloop
+  endfacet
+  facet normal -0.923880 -0.382683 0.000000
+    outer loop
+      vertex -0.289778 -0.077646 0.000000
+      vertex -0.259808 -0.150000 3.000000
+      vertex -0.289778 -0.077646 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.259808 -0.150000 0.000000
+      vertex -0.289778 -0.077646 0.000000
+    endloop
+  endfacet
+  facet normal -0.885539 -0.366802 0.285090
+    outer loop
+      vertex -0.289778 -0.077646 3.000000
+      vertex -0.259808 -0.150000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.793353 -0.608761 0.000000
+    outer loop
+      vertex -0.259808 -0.150000 0.000000
+      vertex -0.212132 -0.212132 0.000000
+      vertex -0.212132 -0.212132 3.000000
+    endloop
+  endfacet
+  facet normal -0.793353 -0.608761 0.000000
+    outer loop
+      vertex -0.259808 -0.150000 0.000000
+      vertex -0.212132 -0.212132 3.000000
+      vertex -0.259808 -0.150000 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.212132 -0.212132 0.000000
+      vertex -0.259808 -0.150000 0.000000
+    endloop
+  endfacet
+  facet normal -0.760430 -0.583498 0.285090
+    outer loop
+      vertex -0.259808 -0.150000 3.000000
+      vertex -0.212132 -0.212132 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.608761 -0.793353 0.000000
+    outer loop
+      vertex -0.212132 -0.212132 0.000000
+      vertex -0.150000 -0.259808 0.000000
+      vertex -0.150000 -0.259808 3.000000
+    endloop
+  endfacet
+  facet normal -0.608761 -0.793353 0.000000
+    outer loop
+      vertex -0.212132 -0.212132 0.000000
+      vertex -0.150000 -0.259808 3.000000
+      vertex -0.212132 -0.212132 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.150000 -0.259808 0.000000
+      vertex -0.212132 -0.212132 0.000000
+    endloop
+  endfacet
+  facet normal -0.583498 -0.760430 0.285090
+    outer loop
+      vertex -0.212132 -0.212132 3.000000
+      vertex -0.150000 -0.259808 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.382683 -0.923880 0.000000
+    outer loop
+      vertex -0.150000 -0.259808 0.000000
+      vertex -0.077646 -0.289778 0.000000
+      vertex -0.077646 -0.289778 3.000000
+    endloop
+  endfacet
+  facet normal -0.382683 -0.923880 0.000000
+    outer loop
+      vertex -0.150000 -0.259808 0.000000
+      vertex -0.077646 -0.289778 3.000000
+      vertex -0.150000 -0.259808 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.077646 -0.289778 0.000000
+      vertex -0.150000 -0.259808 0.000000
+    endloop
+  endfacet
+  facet normal -0.366802 -0.885539 0.285090
+    outer loop
+      vertex -0.150000 -0.259808 3.000000
+      vertex -0.077646 -0.289778 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal -0.130526 -0.991445 0.000000
+    outer loop
+      vertex -0.077646 -0.289778 0.000000
+      vertex -0.000000 -0.300000 0.000000
+      vertex -0.000000 -0.300000 3.000000
+    endloop
+  endfacet
+  facet normal -0.130526 -0.991445 0.000000
+    outer loop
+      vertex -0.077646 -0.289778 0.000000
+      vertex -0.000000 -0.300000 3.000000
+      vertex -0.077646 -0.289778 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex -0.000000 -0.300000 0.000000
+      vertex -0.077646 -0.289778 0.000000
+    endloop
+  endfacet
+  facet normal -0.125109 -0.950301 0.285090
+    outer loop
+      vertex -0.077646 -0.289778 3.000000
+      vertex -0.000000 -0.300000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.130526 -0.991445 0.000000
+    outer loop
+      vertex -0.000000 -0.300000 0.000000
+      vertex 0.077646 -0.289778 0.000000
+      vertex 0.077646 -0.289778 3.000000
+    endloop
+  endfacet
+  facet normal 0.130526 -0.991445 0.000000
+    outer loop
+      vertex -0.000000 -0.300000 0.000000
+      vertex 0.077646 -0.289778 3.000000
+      vertex -0.000000 -0.300000 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 -0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.077646 -0.289778 0.000000
+      vertex -0.000000 -0.300000 0.000000
+    endloop
+  endfacet
+  facet normal 0.125109 -0.950301 0.285090
+    outer loop
+      vertex -0.000000 -0.300000 3.000000
+      vertex 0.077646 -0.289778 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.382683 -0.923880 0.000000
+    outer loop
+      vertex 0.077646 -0.289778 0.000000
+      vertex 0.150000 -0.259808 0.000000
+      vertex 0.150000 -0.259808 3.000000
+    endloop
+  endfacet
+  facet normal 0.382683 -0.923880 0.000000
+    outer loop
+      vertex 0.077646 -0.289778 0.000000
+      vertex 0.150000 -0.259808 3.000000
+      vertex 0.077646 -0.289778 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.150000 -0.259808 0.000000
+      vertex 0.077646 -0.289778 0.000000
+    endloop
+  endfacet
+  facet normal 0.366802 -0.885539 0.285090
+    outer loop
+      vertex 0.077646 -0.289778 3.000000
+      vertex 0.150000 -0.259808 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.608761 -0.793353 0.000000
+    outer loop
+      vertex 0.150000 -0.259808 0.000000
+      vertex 0.212132 -0.212132 0.000000
+      vertex 0.212132 -0.212132 3.000000
+    endloop
+  endfacet
+  facet normal 0.608761 -0.793353 0.000000
+    outer loop
+      vertex 0.150000 -0.259808 0.000000
+      vertex 0.212132 -0.212132 3.000000
+      vertex 0.150000 -0.259808 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.212132 -0.212132 0.000000
+      vertex 0.150000 -0.259808 0.000000
+    endloop
+  endfacet
+  facet normal 0.583498 -0.760430 0.285090
+    outer loop
+      vertex 0.150000 -0.259808 3.000000
+      vertex 0.212132 -0.212132 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.793353 -0.608761 0.000000
+    outer loop
+      vertex 0.212132 -0.212132 0.000000
+      vertex 0.259808 -0.150000 0.000000
+      vertex 0.259808 -0.150000 3.000000
+    endloop
+  endfacet
+  facet normal 0.793353 -0.608761 0.000000
+    outer loop
+      vertex 0.212132 -0.212132 0.000000
+      vertex 0.259808 -0.150000 3.000000
+      vertex 0.212132 -0.212132 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.259808 -0.150000 0.000000
+      vertex 0.212132 -0.212132 0.000000
+    endloop
+  endfacet
+  facet normal 0.760430 -0.583498 0.285090
+    outer loop
+      vertex 0.212132 -0.212132 3.000000
+      vertex 0.259808 -0.150000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.923880 -0.382683 0.000000
+    outer loop
+      vertex 0.259808 -0.150000 0.000000
+      vertex 0.289778 -0.077646 0.000000
+      vertex 0.289778 -0.077646 3.000000
+    endloop
+  endfacet
+  facet normal 0.923880 -0.382683 0.000000
+    outer loop
+      vertex 0.259808 -0.150000 0.000000
+      vertex 0.289778 -0.077646 3.000000
+      vertex 0.259808 -0.150000 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.289778 -0.077646 0.000000
+      vertex 0.259808 -0.150000 0.000000
+    endloop
+  endfacet
+  facet normal 0.885539 -0.366802 0.285090
+    outer loop
+      vertex 0.259808 -0.150000 3.000000
+      vertex 0.289778 -0.077646 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+  facet normal 0.991445 -0.130526 0.000000
+    outer loop
+      vertex 0.289778 -0.077646 0.000000
+      vertex 0.300000 0.000000 0.000000
+      vertex 0.300000 0.000000 3.000000
+    endloop
+  endfacet
+  facet normal 0.991445 -0.130526 0.000000
+    outer loop
+      vertex 0.289778 -0.077646 0.000000
+      vertex 0.300000 0.000000 3.000000
+      vertex 0.289778 -0.077646 3.000000
+    endloop
+  endfacet
+  facet normal 0.000000 0.000000 -1.000000
+    outer loop
+      vertex 0.000000 0.000000 0.000000
+      vertex 0.300000 0.000000 0.000000
+      vertex 0.289778 -0.077646 0.000000
+    endloop
+  endfacet
+  facet normal 0.950301 -0.125109 0.285090
+    outer loop
+      vertex 0.289778 -0.077646 3.000000
+      vertex 0.300000 0.000000 3.000000
+      vertex 0.000000 0.000000 4.000000
+    endloop
+  endfacet
+endsolid default_rocket
diff --git a/rocketpy/plots/compare/compare_flights.py b/rocketpy/plots/compare/compare_flights.py
index 4ff064858..20a613eec 100644
--- a/rocketpy/plots/compare/compare_flights.py
+++ b/rocketpy/plots/compare/compare_flights.py
@@ -1,15 +1,19 @@
 # TODO: remove this disable once the code is refactored
 # pylint: disable=nested-min-max
+import logging
+
 import matplotlib.pyplot as plt
 import numpy as np
 
 from ..plot_helpers import show_or_save_fig, show_or_save_plot
 from .compare import Compare
 
+logger = logging.getLogger(__name__)
+
 # TODO: needs to refactor this class to use the show_or_save_plot
 
 
-class CompareFlights(Compare):  # pylint: disable=too-many-public-methods
+class CompareFlights(Compare):
     """A class to compare the results of multiple flights.
 
     Parameters
@@ -94,7 +98,7 @@ def __process_savefig(self, filename, fig):
         """
         show_or_save_fig(fig, filename)
         if filename:
-            print("Plot saved to file: " + filename)
+            logger.info("Plot saved to file: %s", filename)
         else:
             plt.show()
 
@@ -1084,7 +1088,7 @@ def stability_margin(
         None
         """
 
-        print("This method is not implemented yet")
+        logger.warning("This method is not implemented yet.")
 
     def attitude_frequency(
         self,
@@ -1123,7 +1127,7 @@ def attitude_frequency(
         None
         """
 
-        print("This method is not implemented yet")
+        logger.warning("This method is not implemented yet.")
 
     @staticmethod
     def compare_trajectories_3d(  # pylint: disable=too-many-statements
@@ -1339,7 +1343,7 @@ def trajectories_2d(self, plane="xy", figsize=(7, 7), legend=None, filename=None
 
         func(flights, names_list, figsize, legend, filename)
 
-    def __plot_xy(  # pylint: disable=too-many-statements
+    def __plot_xy(
         self, flights, names_list, figsize=(7, 7), legend=None, filename=None
     ):
         """Creates a 2D trajectory plot in the X-Y plane that is the combination
@@ -1400,7 +1404,7 @@ def __plot_xy(  # pylint: disable=too-many-statements
         # Save figure
         self.__process_savefig(filename, fig)
 
-    def __plot_xz(  # pylint: disable=too-many-statements
+    def __plot_xz(
         self, flights, names_list, figsize=(7, 7), legend=None, filename=None
     ):
         """Creates a 2D trajectory plot in the X-Z plane that is the combination
@@ -1466,7 +1470,7 @@ def __plot_xz(  # pylint: disable=too-many-statements
         # Save figure
         show_or_save_plot(filename)
 
-    def __plot_yz(  # pylint: disable=too-many-statements
+    def __plot_yz(
         self, flights, names_list, figsize=(7, 7), legend=None, filename=None
     ):
         """Creates a 2D trajectory plot in the Y-Z plane that is the combination
diff --git a/rocketpy/plots/environment_analysis_plots.py b/rocketpy/plots/environment_analysis_plots.py
index 743e6b443..f043fa491 100644
--- a/rocketpy/plots/environment_analysis_plots.py
+++ b/rocketpy/plots/environment_analysis_plots.py
@@ -194,7 +194,7 @@ def average_surface_temperature_evolution(
         self,
         *,
         filename=None,
-    ):  # pylint: disable=too-many-statements
+    ):
         """Plots average temperature progression throughout the day, including
         sigma contours.
 
@@ -269,7 +269,7 @@ def average_surface_temperature_evolution(
 
     def average_surface10m_wind_speed_evolution(
         self, wind_speed_limit=False, *, filename=None
-    ):  # pylint: disable=too-many-statements
+    ):
         """Plots average surface wind speed progression throughout the day,
         including sigma contours.
 
@@ -371,7 +371,7 @@ def average_surface100m_wind_speed_evolution(
         self,
         *,
         filename=None,
-    ):  # pylint: disable=too-many-statements
+    ):
         """Plots average surface wind speed progression throughout the day, including
         sigma contours.
 
@@ -957,7 +957,7 @@ def average_wind_rose_specific_hour(self, hour, fig=None, *, filename=None):
         )
         show_or_save_plot(filename)
 
-    def average_wind_rose_grid(self, *, filename=None):  # pylint: disable=too-many-statements
+    def average_wind_rose_grid(self, *, filename=None):
         """Plot wind roses for all hours of a day, in a grid like plot.
 
         Parameters
@@ -1683,7 +1683,7 @@ def wind_heading_profile_grid(self, clear_range_limits=False, *, filename=None):
         fig.supylabel(f"Altitude AGL ({self.env_analysis.unit_system['length']})")
         show_or_save_plot(filename)
 
-    def animate_wind_speed_profile(self, clear_range_limits=False):  # pylint: disable=too-many-statements
+    def animate_wind_speed_profile(self, clear_range_limits=False):
         """Animation of how wind profile evolves throughout an average day.
 
         Parameters
@@ -1763,7 +1763,7 @@ def update(frame):
         plt.close(fig)
         return HTML(animation.to_jshtml())
 
-    def animate_wind_heading_profile(self, clear_range_limits=False):  # pylint: disable=too-many-statements
+    def animate_wind_heading_profile(self, clear_range_limits=False):
         """Animation of how the wind heading profile evolves throughout an
         average day. Each frame is a different hour of the day.
 
diff --git a/rocketpy/plots/environment_plots.py b/rocketpy/plots/environment_plots.py
index 4b8a91e15..add5e4efb 100644
--- a/rocketpy/plots/environment_plots.py
+++ b/rocketpy/plots/environment_plots.py
@@ -33,6 +33,30 @@ def __init__(self, environment):
         self.grid = np.linspace(environment.elevation, environment.max_expected_height)
         self.environment = environment
 
+    def _break_direction_wraparound(self, directions, altitudes):
+        """Inserts NaN into direction and altitude arrays at 0°/360° wraparound
+        points so matplotlib does not draw a horizontal line across the plot.
+
+        Parameters
+        ----------
+        directions : numpy.ndarray
+            Wind direction values in degrees, dtype float.
+        altitudes : numpy.ndarray
+            Altitude values corresponding to each direction, dtype float.
+
+        Returns
+        -------
+        directions : numpy.ndarray
+            Direction array with NaN inserted at wraparound points.
+        altitudes : numpy.ndarray
+            Altitude array with NaN inserted at wraparound points.
+        """
+        wrap_threshold = 180  # degrees; half the full circle
+        wrap_indices = np.where(np.abs(np.diff(directions)) > wrap_threshold)[0] + 1
+        directions = np.insert(directions, wrap_indices, np.nan)
+        altitudes = np.insert(altitudes, wrap_indices, np.nan)
+        return directions, altitudes
+
     def __wind(self, ax):
         """Adds wind speed and wind direction graphs to the same axis.
 
@@ -55,9 +79,14 @@ def __wind(self, ax):
         ax.set_xlabel("Wind Speed (m/s)", color="#ff7f0e")
         ax.tick_params("x", colors="#ff7f0e")
         axup = ax.twiny()
+        directions = np.array(
+            [self.environment.wind_direction(i) for i in self.grid], dtype=float
+        )
+        altitudes = np.array(self.grid, dtype=float)
+        directions, altitudes = self._break_direction_wraparound(directions, altitudes)
         axup.plot(
-            [self.environment.wind_direction(i) for i in self.grid],
-            self.grid,
+            directions,
+            altitudes,
             color="#1f77b4",
             label="Wind Direction",
         )
@@ -311,9 +340,14 @@ def ensemble_member_comparison(self, *, filename=None):
         ax8 = plt.subplot(324)
         for i in range(self.environment.num_ensemble_members):
             self.environment.select_ensemble_member(i)
+            dirs = np.array(
+                [self.environment.wind_direction(j) for j in self.grid], dtype=float
+            )
+            alts = np.array(self.grid, dtype=float)
+            dirs, alts = self._break_direction_wraparound(dirs, alts)
             ax8.plot(
-                [self.environment.wind_direction(i) for i in self.grid],
-                self.grid,
+                dirs,
+                alts,
                 label=i,
             )
         ax8.set_ylabel("Height Above Sea Level (m)")
diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py
index 7eb41a8b2..71ec03053 100644
--- a/rocketpy/plots/flight_plots.py
+++ b/rocketpy/plots/flight_plots.py
@@ -1,10 +1,21 @@
+# pylint: disable=too-many-lines
+
+import logging
+import os
+import time
+from collections.abc import Mapping, Sequence
 from functools import cached_property
+from importlib import resources
 
 import matplotlib.pyplot as plt
 import numpy as np
+from matplotlib.colors import to_rgb
 
+from ..tools import import_optional_dependency
 from .plot_helpers import show_or_save_plot
 
+logger = logging.getLogger(__name__)
+
 
 class _FlightPlots:
     """Class that holds plot methods for Flight class.
@@ -133,6 +144,1997 @@ def trajectory_3d(self, *, filename=None):  # pylint: disable=too-many-statement
         ax1.set_box_aspect(None, zoom=0.95)  # 95% for label adjustment
         show_or_save_plot(filename)
 
+    def _resolve_animation_model_path(self, file_name):
+        """Resolve model path, defaulting to the built-in STL when omitted."""
+        if file_name is not None:
+            return file_name
+
+        return str(
+            resources.files("rocketpy.plots").joinpath("assets/default_rocket.stl")
+        )
+
+    def _validate_animation_inputs(self, file_name, start, stop, time_step):
+        """Validate shared input parameters for 3D animation methods."""
+        if time_step <= 0:
+            raise ValueError(
+                f"Invalid time_step: {time_step}. It must be greater than 0."
+            )
+
+        if stop is None:
+            stop = self.flight.t_final
+
+        if (
+            start < 0
+            or stop < 0
+            or start > self.flight.t_final
+            or stop > self.flight.t_final
+            or start >= stop
+        ):
+            raise ValueError(
+                f"Invalid animation time range: start={start}, stop={stop}. "
+                f"Both must be within [0, {self.flight.t_final}] and start < stop."
+            )
+
+        if not os.path.isfile(file_name):
+            raise FileNotFoundError(
+                f"Could not find the 3D model file: '{file_name}'. "
+                "Provide a valid .stl file path."
+            )
+
+        return stop
+
+    @staticmethod
+    def _rotation_matrix_from_quaternion(q0, q1, q2, q3):
+        """Return the body-to-inertial homogeneous rotation matrix."""
+        quaternion = np.asarray([q0, q1, q2, q3], dtype=float)
+        norm = np.linalg.norm(quaternion)
+        if norm == 0:
+            return np.eye(4)
+
+        q0, q1, q2, q3 = quaternion / norm
+        rotation = np.array(
+            [
+                [
+                    1 - 2 * (q2 * q2 + q3 * q3),
+                    2 * (q1 * q2 - q0 * q3),
+                    2 * (q1 * q3 + q0 * q2),
+                ],
+                [
+                    2 * (q1 * q2 + q0 * q3),
+                    1 - 2 * (q1 * q1 + q3 * q3),
+                    2 * (q2 * q3 - q0 * q1),
+                ],
+                [
+                    2 * (q1 * q3 - q0 * q2),
+                    2 * (q2 * q3 + q0 * q1),
+                    1 - 2 * (q1 * q1 + q2 * q2),
+                ],
+            ]
+        )
+        transformation = np.eye(4)
+        transformation[:3, :3] = rotation
+        return transformation
+
+    def _animation_position(self, time_value):
+        """Return the rocket position in the East-North-Up AGL frame, in m."""
+        return np.array(
+            [
+                self.flight.x(time_value),
+                self.flight.y(time_value),
+                self.flight.z(time_value) - self.flight.env.elevation,
+            ]
+        )
+
+    def _animation_velocity(self, time_value):
+        """Return inertial East-North-Up velocity at ``time_value``, in m/s."""
+        return np.array(
+            [
+                self.flight.vx(time_value),
+                self.flight.vy(time_value),
+                self.flight.vz(time_value),
+            ]
+        )
+
+    def _animation_wind(self, time_value):
+        """Return the wind velocity in the East-North-Up frame, in m/s."""
+        return np.array(
+            [
+                self.flight.wind_velocity_x(time_value),
+                self.flight.wind_velocity_y(time_value),
+                0.0,
+            ]
+        )
+
+    @staticmethod
+    def _safe_unit_vector(vector, fallback=(0.0, 0.0, 1.0)):
+        """Normalize a vector, returning a finite fallback for zero magnitude."""
+        vector = np.asarray(vector, dtype=float)
+        norm = np.linalg.norm(vector)
+        if not np.isfinite(norm) or norm <= np.finfo(float).eps:
+            return np.asarray(fallback, dtype=float)
+        return vector / norm
+
+    def _animation_transformation(self, time_value, position=None):
+        """Return the body-to-inertial transform at ``time_value``."""
+        transformation = self._rotation_matrix_from_quaternion(
+            self.flight.e0(time_value),
+            self.flight.e1(time_value),
+            self.flight.e2(time_value),
+            self.flight.e3(time_value),
+        )
+        if position is not None:
+            transformation[:3, 3] = position
+        return transformation
+
+    @classmethod
+    def _direction_arrow(cls, pyvista, direction, scale, start=(0, 0, 0)):
+        """Create a slender, constant-length arrow for a vector direction."""
+        return pyvista.Arrow(
+            start=start,
+            direction=cls._safe_unit_vector(direction),
+            scale=scale,
+            shaft_radius=0.018,
+            tip_radius=0.055,
+            tip_length=0.18,
+            shaft_resolution=16,
+            tip_resolution=20,
+        )
+
+    @staticmethod
+    def _animation_color_scheme():
+        """Return the color scheme shared by both PyVista animations.
+
+        Keep animation colors in this single dictionary so the complete visual
+        scheme can be adjusted without searching through either scene builder.
+        """
+        colors = {
+            # Background gradient
+            "day_bottom": "#8FB3C9",
+            "day_top": "#C6DCE8",
+            "night_bottom": "#0A0F14",
+            "night_top": "#17222C",
+            "space_bottom": "#020611",
+            "space_top": "#09172A",
+            # Scientific overlays and UI
+            "panel_background": "#f7f7f759",
+            "panel_border": "#45535E",
+            "panel_text": "#192229",
+            "label_text": "#1B242A",
+            "axes": "#536B7A",
+            "control_on": "#5E8E76",
+            "control_off": "#59636C",
+            "control_background": "#C8D0D6",
+            "slider_tube": (0.29, 0.34, 0.38),
+            "slider_handle": (0.67, 0.72, 0.76),
+            "slider_selected": (0.38, 0.56, 0.64),
+            "chart_cursor": "#D55E00",
+            "chart_altitude": "#0072B2",
+            "chart_speed": "#009E73",
+            "chart_acceleration": "#D55E00",
+            "scalar_cmap": "viridis",
+            # Trajectory scene
+            "ground": "#D8DED8",
+            "ground_grid": "#89968F",
+            "simulated_path": "#5B6573",
+            "flown_path": "#009E73",
+            "velocity": "#E69F00",
+            "wind": "#CC79A7",
+            "rocket": "#D1D6DA",
+            "rocket_legend": "#596873",
+            "ground_projection": "#56B4E9",
+            "marker_outline": "#17202A",
+            "event_start": "#0072B2",
+            "event_burnout": "#E69F00",
+            "event_apogee": "#F0E442",
+            "event_parachute_trigger": "#CC79A7",
+            "event_parachute_open": "#56B4E9",
+            "event_end": "#D55E00",
+            # Attitude reference scene
+            "reference_grid": "#83919B",
+            "horizon": "#AAB5BD",
+            "body_x": "#B96565",
+            "body_y": "#6D9B7D",
+            "body_z": "#668BAE",
+            "center_of_mass": "#F0E442",
+            "center_of_pressure": "#D55E00",
+        }
+        return colors
+
+    def _animation_event_markers(self, start, stop, colors):
+        """Return significant flight event times, labels and display colors."""
+        events = [(start, "Start", colors["event_start"])]
+
+        burn_out_time = self.flight.rocket.motor.burn_out_time
+        if start < burn_out_time < stop:
+            events.append((burn_out_time, "Motor burnout", colors["event_burnout"]))
+
+        if start < self.flight.apogee_time < stop:
+            events.append((self.flight.apogee_time, "Apogee", colors["event_apogee"]))
+
+        for trigger_time, parachute in self.flight.parachute_events:
+            if start < trigger_time < stop:
+                events.append(
+                    (
+                        trigger_time,
+                        f"{parachute.name} trigger",
+                        colors["event_parachute_trigger"],
+                    )
+                )
+            deployment_time = trigger_time + parachute.lag
+            if start < deployment_time < stop:
+                events.append(
+                    (
+                        deployment_time,
+                        f"{parachute.name} open",
+                        colors["event_parachute_open"],
+                    )
+                )
+
+        events.append((stop, "End", colors["event_end"]))
+        return sorted(events, key=lambda event: event[0])
+
+    @staticmethod
+    def _polyline(pyvista, points, *, closed=False):
+        """Build a connected ``PolyData`` line, optionally closed."""
+        points = np.asarray(points, dtype=float).reshape((-1, 3))
+        if len(points) < 2:
+            return pyvista.PolyData(points)
+
+        if closed:
+            connectivity = np.concatenate(
+                ([len(points) + 1], np.arange(len(points)), [0])
+            )
+        else:
+            connectivity = np.concatenate(([len(points)], np.arange(len(points))))
+        return pyvista.PolyData(points, lines=connectivity)
+
+    def _animation_scalar(self, time_value, color_by):
+        """Return a trajectory coloring scalar at ``time_value``."""
+        evaluators = {
+            "speed": lambda: float(self.flight.speed(time_value)),
+            "mach": lambda: float(self.flight.mach_number(time_value)),
+            "dynamic_pressure": lambda: float(self.flight.dynamic_pressure(time_value)),
+            "acceleration": lambda: float(self.flight.acceleration(time_value)),
+            "altitude": lambda: float(self._animation_position(time_value)[2]),
+        }
+        return evaluators[color_by]()
+
+    @staticmethod
+    def _animation_scalar_metadata(color_by):
+        """Return display label and SI unit for a trajectory scalar."""
+        return {
+            "speed": ("Speed", "m/s"),
+            "mach": ("Mach number", "-"),
+            "dynamic_pressure": ("Dynamic pressure", "Pa"),
+            "acceleration": ("Acceleration", "m/s²"),
+            "altitude": ("Altitude AGL", "m"),
+        }[color_by]
+
+    @classmethod
+    def _polyline_with_scalars(cls, pyvista, points, scalars, scalar_name):
+        """Build a polyline carrying one point scalar array."""
+        mesh = cls._polyline(pyvista, points)
+        mesh.point_data[scalar_name] = np.asarray(scalars, dtype=float)
+        return mesh
+
+    @staticmethod
+    def _dashed_polyline(  # pylint: disable=too-many-statements
+        pyvista, points, *, scalars=None, scalar_name=None, dash_count=32
+    ):
+        """Build an arc-length-spaced dashed line with optional point scalars."""
+        points = np.asarray(points, dtype=float).reshape((-1, 3))
+        scalar_values = None if scalars is None else np.asarray(scalars, dtype=float)
+        if len(points) < 2:
+            mesh = pyvista.PolyData(points)
+            if scalar_values is not None:
+                mesh.point_data[scalar_name] = scalar_values
+            return mesh
+
+        cumulative_distance = np.concatenate(
+            ([0.0], np.cumsum(np.linalg.norm(np.diff(points, axis=0), axis=1)))
+        )
+        distinct = np.concatenate(
+            ([True], np.diff(cumulative_distance) > np.finfo(float).eps)
+        )
+        points = points[distinct]
+        cumulative_distance = cumulative_distance[distinct]
+        if scalar_values is not None:
+            scalar_values = scalar_values[distinct]
+        if len(points) < 2 or cumulative_distance[-1] <= np.finfo(float).eps:
+            mesh = pyvista.PolyData(points)
+            if scalar_values is not None:
+                mesh.point_data[scalar_name] = scalar_values
+            return mesh
+
+        dash_count = max(1, min(int(dash_count), len(points) - 1))
+        dash_unit = cumulative_distance[-1] / (2 * dash_count - 1)
+        dash_distances = np.column_stack(
+            (
+                2 * np.arange(dash_count) * dash_unit,
+                (2 * np.arange(dash_count) + 1) * dash_unit,
+            )
+        ).ravel()
+        dashed_points = np.column_stack(
+            [
+                np.interp(dash_distances, cumulative_distance, points[:, axis])
+                for axis in range(3)
+            ]
+        )
+        starts = 2 * np.arange(dash_count)
+        connectivity = np.column_stack(
+            (np.full(dash_count, 2), starts, starts + 1)
+        ).ravel()
+        mesh = pyvista.PolyData(dashed_points, lines=connectivity)
+        if scalar_values is not None:
+            mesh.point_data[scalar_name] = np.interp(
+                dash_distances, cumulative_distance, scalar_values
+            )
+        return mesh
+
+    def _animation_kinematic_series(self, times):
+        """Return altitude, speed and acceleration histories in SI units."""
+        return [
+            (
+                "Altitude AGL (m)",
+                np.array([self._animation_position(t)[2] for t in times]),
+                "chart_altitude",
+            ),
+            (
+                "Speed (m/s)",
+                np.array([self.flight.speed(t) for t in times]),
+                "chart_speed",
+            ),
+            (
+                "Acceleration (m/s²)",
+                np.array([self.flight.acceleration(t) for t in times]),
+                "chart_acceleration",
+            ),
+        ]
+
+    def _animation_attitude_series(self, times):
+        """Return aerodynamic-angle, Euler-angle and body-rate histories."""
+        return [
+            (
+                "Aerodynamic angles (deg)",
+                [
+                    (
+                        "Angle of attack",
+                        [self.flight.angle_of_attack(t) for t in times],
+                    ),
+                    ("Sideslip", [self.flight.angle_of_sideslip(t) for t in times]),
+                ],
+            ),
+            (
+                "3-1-3 Euler angles (deg)",
+                [
+                    ("Precession ψ", [self.flight.psi(t) for t in times]),
+                    ("Nutation θ", [self.flight.theta(t) for t in times]),
+                    ("Spin φ", [self.flight.phi(t) for t in times]),
+                ],
+            ),
+            (
+                "Body angular rates (deg/s)",
+                [
+                    ("Pitch ω1", np.degrees([self.flight.w1(t) for t in times])),
+                    ("Yaw ω2", np.degrees([self.flight.w2(t) for t in times])),
+                    ("Roll ω3", np.degrees([self.flight.w3(t) for t in times])),
+                ],
+            ),
+        ]
+
+    @staticmethod
+    def _add_animation_charts(  # pylint: disable=too-many-statements
+        pyvista,
+        plotter,
+        times,
+        chart_series,
+        colors,
+        *,
+        attitude=False,
+        compact=False,
+    ):
+        """Add compact PyVista history charts and return their time cursors."""
+        cursors = []
+        line_colors = (
+            colors["body_x"],
+            colors["body_y"],
+            colors["body_z"],
+        )
+        chart_width = 0.247 if compact else 0.312
+        chart_x = 0.743 if compact and attitude else 0.01
+        locations = ((chart_x, 0.12), (chart_x, 0.38), (chart_x, 0.64))
+        size = (chart_width, 0.243)
+
+        for index, series in enumerate(chart_series):
+            chart = pyvista.Chart2D(size=size, loc=locations[index])
+            chart.title = series[0]
+            chart.background_color = colors["panel_background"]
+            chart.border_color = colors["panel_border"]
+            chart.x_axis.label = "Time (s)"
+            chart.x_axis.label_size = 12
+            chart.y_axis.label_size = 12
+            chart.x_axis.tick_label_size = 11
+            chart.y_axis.tick_label_size = 11
+
+            if attitude:
+                values = []
+                for line_index, (label, line_values) in enumerate(series[1]):
+                    line_values = np.asarray(line_values, dtype=float)
+                    values.append(line_values)
+                    chart.line(
+                        times,
+                        line_values,
+                        color=line_colors[line_index],
+                        width=1.5,
+                        label=label,
+                    )
+                all_values = np.concatenate(values)
+            else:
+                all_values = np.asarray(series[1], dtype=float)
+                chart.line(
+                    times,
+                    all_values,
+                    color=colors[series[2]],
+                    width=1.7,
+                )
+
+            finite_values = all_values[np.isfinite(all_values)]
+            if finite_values.size:
+                value_min, value_max = np.min(finite_values), np.max(finite_values)
+            else:
+                value_min, value_max = 0.0, 1.0
+            if np.isclose(value_min, value_max):
+                value_min -= 0.5
+                value_max += 0.5
+            cursor = chart.line(
+                [times[0], times[0]],
+                [value_min, value_max],
+                color=colors["chart_cursor"],
+                width=1.2,
+            )
+            cursors.append((cursor, value_min, value_max))
+            plotter.add_chart(chart)
+        return cursors
+
+    @staticmethod
+    def _update_animation_chart_cursors(cursors, time_value):
+        """Move all chart cursors to the selected flight time."""
+        for cursor, value_min, value_max in cursors:
+            cursor.update([time_value, time_value], [value_min, value_max])
+
+    def _ground_bounds_from_spec(self, spec, fallback_bounds):
+        """Convert explicit ENU or latitude/longitude image bounds to ENU."""
+        bounds = spec.get("bounds")
+        if bounds is None:
+            return fallback_bounds
+        if len(bounds) != 4 or not np.all(np.isfinite(bounds)):
+            raise ValueError("ground image bounds must contain four finite values.")
+        west, east, south, north = map(float, bounds)
+        if not west < east or not south < north:
+            raise ValueError(
+                "ground image bounds must satisfy west < east and south < north."
+            )
+
+        coordinates = spec.get("coordinates", "enu").lower()
+        if coordinates == "enu":
+            return west, east, south, north
+        if coordinates != "latlon":
+            raise ValueError("ground image coordinates must be 'enu' or 'latlon'.")
+
+        latitude = float(self.flight.env.latitude)
+        longitude = float(self.flight.env.longitude)
+        earth_radius = 6_371_000.0
+        east_bounds = (
+            earth_radius
+            * np.cos(np.radians(latitude))
+            * np.radians(np.array([west, east]) - longitude)
+        )
+        north_bounds = earth_radius * np.radians(np.array([south, north]) - latitude)
+        return (*east_bounds, *north_bounds)
+
+    @staticmethod
+    def _interpolated_camera_path(camera_path, fraction):
+        """Interpolate a sequence of PyVista camera positions."""
+        if len(camera_path) < 2:
+            raise ValueError("camera_path must contain at least two camera positions.")
+        scaled = np.clip(fraction, 0, 1) * (len(camera_path) - 1)
+        lower = min(int(np.floor(scaled)), len(camera_path) - 2)
+        blend = scaled - lower
+        camera = []
+        for first, second in zip(
+            camera_path[lower], camera_path[lower + 1], strict=True
+        ):
+            camera.append(
+                tuple((1 - blend) * np.asarray(first) + blend * np.asarray(second))
+            )
+        return camera
+
+    @classmethod
+    def _update_animation_camera(
+        cls,
+        plotter,
+        mode,
+        position,
+        rotation,
+        scene_span,
+        time_value,
+        start,
+        stop,
+        camera_path,
+    ):
+        """Update the camera from a preset mode or deterministic path."""
+        fraction = 0 if stop == start else (time_value - start) / (stop - start)
+        if camera_path is not None:
+            if callable(camera_path):
+                plotter.camera_position = camera_path(time_value)
+            else:
+                plotter.camera_position = cls._interpolated_camera_path(
+                    camera_path, fraction
+                )
+            return
+        if mode == "static":
+            return
+
+        span = max(float(scene_span), 1.0)
+        if mode == "follow":
+            offset = np.array([0.65, -0.85, 0.45]) * span
+            camera_position = position + offset
+            view_up = (0, 0, 1)
+        elif mode == "ground":
+            camera_position = position + np.array([0, -0.8 * span, 0.18 * span])
+            camera_position[2] = max(camera_position[2], 0.08 * span)
+            view_up = (0, 0, 1)
+        else:  # body-fixed
+            offset = rotation[:3, :3] @ (np.array([0.7, -0.9, 0.35]) * span)
+            camera_position = position + offset
+            view_up = tuple(rotation[:3, :3] @ np.array([0, 0, 1]))
+        plotter.camera_position = [tuple(camera_position), tuple(position), view_up]
+
+    def _rocket_axial_display_coordinate(self, value, display_length):
+        """Map a rocket axial coordinate onto the centered display model."""
+        coordinates = [
+            float(position.z)
+            for _surface, position in self.flight.rocket.aerodynamic_surfaces
+        ]
+        coordinates.extend(
+            [
+                float(self.flight.rocket.center_of_dry_mass_position),
+            ]
+        )
+        coordinate_min = min(coordinates)
+        coordinate_max = max(coordinates)
+        physical_span = max(
+            coordinate_max - coordinate_min,
+            2 * float(self.flight.rocket.radius),
+            np.finfo(float).eps,
+        )
+        coordinate_center = 0.5 * (coordinate_min + coordinate_max)
+        orientation_sign = (
+            1
+            if self.flight.rocket.coordinate_system_orientation == "tail_to_nose"
+            else -1
+        )
+        return (
+            orientation_sign
+            * (float(value) - coordinate_center)
+            * (0.8 * display_length / physical_span)
+        )
+
+    def _animation_phase(self, time_value):
+        """Return a concise phase label for the telemetry overlay."""
+        if time_value <= self.flight.rocket.motor.burn_out_time:
+            return "POWERED ASCENT"
+        if time_value <= self.flight.apogee_time:
+            return "COAST"
+
+        deployed = any(
+            event_time + parachute.lag <= time_value
+            for event_time, parachute in self.flight.parachute_events
+        )
+        return "PARACHUTE DESCENT" if deployed else "DESCENT"
+
+    def _trajectory_telemetry(self, time_value):
+        """Format the trajectory animation's live telemetry panel."""
+        position = self._animation_position(time_value)
+        velocity = self._animation_velocity(time_value)
+        wind = self._animation_wind(time_value)
+        ground_range = np.linalg.norm(position[:2] - self._animation_position(0)[:2])
+        lines = [
+            self._animation_phase(time_value),
+            f"T+ {time_value:7.2f} s",
+            f"ALTITUDE   {position[2]:8.1f} m AGL",
+            f"SPEED      {np.linalg.norm(velocity):8.1f} m/s",
+            f"VERTICAL   {velocity[2]:+8.1f} m/s",
+            f"MACH       {self.flight.mach_number(time_value):8.2f}",
+            f"WIND       {np.linalg.norm(wind):8.1f} m/s",
+            f"RANGE      {ground_range:8.1f} m",
+        ]
+        width = max(map(len, lines))
+        return "\n".join(line.ljust(width) for line in lines)
+
+    def _rotation_telemetry(self, time_value, rotation, include_stability=False):
+        """Format the attitude animation's live telemetry panel."""
+        body_axis = rotation[:3, 2]
+        tilt = np.degrees(np.arccos(np.clip(body_axis[2], -1, 1)))
+        heading = np.degrees(np.arctan2(body_axis[0], body_axis[1])) % 360
+        velocity = self._animation_velocity(time_value)
+        wind = self._animation_wind(time_value)
+        angular_rates = np.degrees(
+            [
+                self.flight.w1(time_value),
+                self.flight.w2(time_value),
+                self.flight.w3(time_value),
+            ]
+        )
+        telemetry = (
+            f"ATTITUDE  T+ {time_value:7.2f} s\n"
+            f"TILT {tilt:7.2f} deg   HDG {heading:7.2f} deg\n"
+            f"SPEED {np.linalg.norm(velocity):7.1f} m/s  "
+            f"WIND {np.linalg.norm(wind):7.1f} m/s\n"
+            f"RATES P/Y/R {angular_rates[0]:+6.1f} / "
+            f"{angular_rates[1]:+6.1f} / {angular_rates[2]:+6.1f} deg/s"
+        )
+        if include_stability:
+            center_of_mass = self.flight.rocket.center_of_mass(time_value)
+            center_of_pressure = self.flight.rocket.cp_position(
+                self.flight.mach_number(time_value)
+            )
+            telemetry += (
+                f"\nCM {center_of_mass:+7.3f} m   CP {center_of_pressure:+7.3f} m"
+                f"\nSTATIC MARGIN {self.flight.rocket.static_margin(time_value):+6.2f} cal"
+            )
+        return telemetry
+
+    def _animation_background_palette(self, background_color=None, colors=None):
+        """Return launch and near-space background color pairs."""
+        colors = colors or self._animation_color_scheme()
+        if background_color is not None:
+            launch_color = np.asarray(to_rgb(background_color))
+            launch_bottom = launch_color
+            launch_top = launch_color
+        else:
+            local_date = getattr(self.flight.env, "local_date", None)
+            is_daylight = local_date is None or 6 <= local_date.hour < 20
+            if is_daylight:
+                launch_bottom = np.asarray(to_rgb(colors["day_bottom"]))
+                launch_top = np.asarray(to_rgb(colors["day_top"]))
+            else:
+                launch_bottom = np.asarray(to_rgb(colors["night_bottom"]))
+                launch_top = np.asarray(to_rgb(colors["night_top"]))
+
+        return {
+            "launch_bottom": launch_bottom,
+            "launch_top": launch_top,
+            "space_bottom": np.asarray(to_rgb(colors["space_bottom"])),
+            "space_top": np.asarray(to_rgb(colors["space_top"])),
+        }
+
+    @staticmethod
+    def _animation_background_at_altitude(palette, altitude_agl):
+        """Linearly blend the launch palette into near-space by 50 km AGL."""
+        blend = float(np.clip(altitude_agl / 50_000, 0, 1))
+        bottom = (1 - blend) * palette["launch_bottom"] + blend * palette[
+            "space_bottom"
+        ]
+        top = (1 - blend) * palette["launch_top"] + blend * palette["space_top"]
+        return tuple(bottom), tuple(top)
+
+    @classmethod
+    def _set_animation_background(cls, plotter, palette, altitude_agl):
+        """Update the scene background for the current altitude."""
+        bottom, top = cls._animation_background_at_altitude(palette, altitude_agl)
+        plotter.set_background(bottom, top=top)
+
+    @staticmethod
+    def _style_telemetry_actor(actor, colors):
+        """Give telemetry a compact Matplotlib-like annotation box."""
+        text_property = actor.GetTextProperty()
+        text_property.background_color = colors["panel_background"]
+        text_property.background_opacity = 0.88
+        text_property.show_frame = True
+        text_property.frame_color = colors["panel_border"]
+        text_property.frame_width = 1
+
+    @staticmethod
+    def _style_legend_actor(actor, colors):
+        """Give a PyVista legend compact scientific-plot styling."""
+        text_property = actor.GetEntryTextProperty()
+        text_property.SetFontSize(7)
+        text_property.SetColor(0.10, 0.14, 0.17)
+        actor.GetBoxProperty().SetColor(*to_rgb(colors["panel_border"]))
+        actor.SetPadding(2)
+
+    @classmethod
+    def _style_animation_plotter(
+        cls, plotter, palette, colors, *, show_kinematic_plots=False
+    ):
+        """Apply RocketPy's animation scene style."""
+        cls._set_animation_background(plotter, palette, 0)
+        # SSAA scales VTK's 2D chart layer independently from the 3D scene,
+        # shifting normalized chart positions by roughly half a viewport.
+        plotter.enable_anti_aliasing("msaa")
+        plotter.add_axes(
+            xlabel="E — East",
+            ylabel="N — North",
+            zlabel="U — Up",
+            color=colors["axes"],
+            line_width=1,
+            viewport=(
+                (0.63, 0.09, 0.76, 0.22)
+                if show_kinematic_plots
+                else (0.20, 0.09, 0.33, 0.22)
+            ),
+        )
+
+    @staticmethod
+    def _style_animation_slider(widget, colors):
+        """Apply a compact neutral style to a native PyVista slider."""
+        representation = widget.GetRepresentation()
+        representation.SetSliderLength(0.025)
+        representation.SetSliderWidth(0.012)
+        representation.SetTubeWidth(0.004)
+        representation.SetEndCapLength(0.006)
+        representation.SetEndCapWidth(0.012)
+        representation.GetTubeProperty().SetColor(*colors["slider_tube"])
+        representation.GetCapProperty().SetColor(*colors["slider_tube"])
+        representation.GetSliderProperty().SetColor(*colors["slider_handle"])
+        representation.GetSelectedProperty().SetColor(*colors["slider_selected"])
+
+    @staticmethod
+    def _animation_options(  # pylint: disable=too-many-statements
+        kwargs,
+    ):
+        """Remove and validate RocketPy-specific options from Plotter kwargs."""
+        options = {
+            "background_color": kwargs.pop("background_color", None),
+            "playback_controls": kwargs.pop("playback_controls", True),
+            "show_subrocket_point": kwargs.pop(
+                "show_subrocket_point",
+                kwargs.pop("show_subrocket_point", True),
+            ),
+            "ground_image": kwargs.pop("ground_image", None),
+            "ground_image_bounds": kwargs.pop("ground_image_bounds", None),
+            "ground_image_coordinates": kwargs.pop("ground_image_coordinates", "enu"),
+            "ground_image_flip_y": kwargs.pop("ground_image_flip_y", False),
+            "backend": kwargs.pop("backend", "auto"),
+            "force_external": kwargs.pop("force_external", False),
+            "shadows": kwargs.pop("shadows", False),
+            "trajectory_line_width": kwargs.pop("trajectory_line_width", 4),
+            "color_by": kwargs.pop("color_by", "speed"),
+            "show_kinematic_plots": kwargs.pop("show_kinematic_plots", False),
+            "camera_mode": kwargs.pop("camera_mode", "static"),
+            "camera_path": kwargs.pop("camera_path", None),
+            "show_attitude_plots": kwargs.pop("show_attitude_plots", False),
+            "show_cp_cm": kwargs.pop("show_cp_cm", False),
+            "export_file": kwargs.pop("export_file", None),
+            "export_fps": kwargs.pop("export_fps", 30),
+            "export_resolution": kwargs.pop("export_resolution", None),
+            "transparent_background": kwargs.pop("transparent_background", False),
+            "color_scheme": kwargs.pop("color_scheme", None),
+        }
+        valid_backends = {"auto", "none", "trame", "client"}
+        if options["backend"] not in valid_backends:
+            raise ValueError(
+                f"Invalid backend: {options['backend']!r}. Expected one of "
+                f"{sorted(valid_backends)}."
+            )
+        line_width = options["trajectory_line_width"]
+        if isinstance(line_width, bool) or not isinstance(line_width, (int, float)):
+            raise TypeError("trajectory_line_width must be a positive number.")
+        if line_width <= 0:
+            raise ValueError("trajectory_line_width must be greater than 0.")
+        options["trajectory_line_width"] = float(line_width)
+
+        color_by = options["color_by"]
+        if color_by is False or color_by is None:
+            options["color_by"] = None
+        elif not isinstance(color_by, str) or color_by.lower() not in {
+            "speed",
+            "mach",
+            "dynamic_pressure",
+            "acceleration",
+            "altitude",
+        }:
+            raise ValueError(
+                "color_by must be one of 'speed', 'mach', 'dynamic_pressure', "
+                "'acceleration', 'altitude', False or None."
+            )
+        else:
+            options["color_by"] = color_by.lower()
+
+        camera_mode = options["camera_mode"]
+        if camera_mode is True:
+            camera_mode = "follow"
+        elif camera_mode is False or camera_mode is None:
+            camera_mode = "static"
+        if not isinstance(camera_mode, str) or camera_mode.lower() not in {
+            "static",
+            "follow",
+            "ground",
+            "body",
+        }:
+            raise ValueError(
+                "camera_mode must be 'static', 'follow', 'ground', 'body', "
+                "True or False."
+            )
+        options["camera_mode"] = camera_mode.lower()
+
+        camera_path = options["camera_path"]
+        if (
+            camera_path is not None
+            and not callable(camera_path)
+            and (
+                isinstance(camera_path, (str, bytes))
+                or not isinstance(camera_path, Sequence)
+            )
+        ):
+            raise TypeError(
+                "camera_path must be callable or a camera-position sequence."
+            )
+
+        export_fps = options["export_fps"]
+        if isinstance(export_fps, bool) or not isinstance(export_fps, (int, float)):
+            raise TypeError("export_fps must be a positive number.")
+        if export_fps <= 0:
+            raise ValueError("export_fps must be greater than 0.")
+        options["export_fps"] = float(export_fps)
+
+        resolution = options["export_resolution"]
+        if resolution is not None:
+            if (
+                not isinstance(resolution, Sequence)
+                or len(resolution) != 2
+                or any(
+                    isinstance(value, bool) or int(value) <= 0 for value in resolution
+                )
+            ):
+                raise ValueError(
+                    "export_resolution must contain two positive integer values."
+                )
+            options["export_resolution"] = tuple(map(int, resolution))
+
+        export_file = options["export_file"]
+        if export_file is not None:
+            extension = os.path.splitext(os.fspath(export_file))[1].lower()
+            if extension not in {".gif", ".mp4"}:
+                raise ValueError("export_file must end in '.gif' or '.mp4'.")
+            if options["force_external"]:
+                raise ValueError("force_external cannot be used with export_file.")
+            if options["transparent_background"] and extension == ".mp4":
+                raise ValueError(
+                    "transparent_background is supported for GIF export, not MP4."
+                )
+            if (
+                extension == ".mp4"
+                and resolution is not None
+                and any(value % 2 for value in options["export_resolution"])
+            ):
+                raise ValueError("MP4 export_resolution values must be even.")
+
+        color_scheme = options["color_scheme"]
+        if color_scheme is not None and not isinstance(color_scheme, Mapping):
+            raise TypeError("color_scheme must be a mapping of palette keys to colors.")
+        return options
+
+    @classmethod
+    def _resolved_animation_colors(cls, override):
+        """Merge a user color override into the centralized default scheme."""
+        colors = cls._animation_color_scheme()
+        if override is None:
+            return colors
+        unknown = set(override) - set(colors)
+        if unknown:
+            raise ValueError(f"Unknown color_scheme keys: {sorted(unknown)}.")
+        colors.update(override)
+        return colors
+
+    @staticmethod
+    def _run_animation(  # pylint: disable=too-many-statements,too-many-locals
+        plotter,
+        update_frame,
+        start,
+        stop,
+        time_step,
+        playback_speed,
+        *,
+        colors,
+        playback_controls=True,
+        backend="auto",
+        force_external=False,
+        export_file=None,
+        export_fps=30,
+        transparent_background=False,
+    ):
+        """Add playback controls and start PyVista's timer-driven event loop."""
+        if playback_speed <= 0:
+            raise ValueError(
+                f"Invalid playback_speed: {playback_speed}. It must be greater than 0."
+            )
+
+        if export_file is not None:
+            plotter.image_transparent_background = bool(transparent_background)
+            extension = os.path.splitext(os.fspath(export_file))[1].lower()
+            transparent_gif = extension == ".gif" and transparent_background
+            if extension == ".gif" and not transparent_gif:
+                plotter.open_gif(os.fspath(export_file), fps=export_fps)
+            elif extension == ".mp4":
+                plotter.open_movie(
+                    os.fspath(export_file),
+                    framerate=int(round(export_fps)),
+                    macro_block_size=1,
+                )
+            duration = (stop - start) / playback_speed
+            frame_count = max(int(round(duration * export_fps)) + 1, 2)
+            transparent_frames = []
+            try:
+                for time_value in np.linspace(start, stop, frame_count):
+                    update_frame(float(time_value))
+                    if transparent_gif:
+                        transparent_frames.append(
+                            np.asarray(
+                                plotter.screenshot(
+                                    transparent_background=True,
+                                    return_img=True,
+                                )
+                            )
+                        )
+                    else:
+                        plotter.write_frame()
+            finally:
+                plotter.close()
+            if transparent_gif:
+                image_module = import_optional_dependency("PIL.Image")
+                palette_frames = []
+                for frame in transparent_frames:
+                    rgba_frame = image_module.fromarray(frame, mode="RGBA")
+                    alpha = rgba_frame.getchannel("A")
+                    palette_frame = rgba_frame.convert("RGB").convert(
+                        "P", palette=image_module.Palette.ADAPTIVE, colors=255
+                    )
+                    palette_frame.paste(255, mask=alpha.point(lambda value: value == 0))
+                    palette_frames.append(palette_frame)
+                palette_frames[0].save(
+                    os.fspath(export_file),
+                    save_all=True,
+                    append_images=palette_frames[1:],
+                    duration=round(1000 / export_fps),
+                    loop=0,
+                    disposal=2,
+                    transparency=255,
+                )
+            return os.fspath(export_file)
+
+        speed_values = sorted({0.5, 1.0, 2.0, 3.0, float(playback_speed)})
+        speed_labels = [f"{speed:g}x" for speed in speed_values]
+        state = {
+            "time": float(start),
+            "speed": float(playback_speed),
+            "playing": True,
+            "last_tick": time.perf_counter(),
+            "accumulator": 0.0,
+        }
+        controls = {}
+
+        def set_time(value):
+            state["time"] = float(np.clip(value, start, stop))
+            state["last_tick"] = time.perf_counter()
+            state["accumulator"] = 0.0
+            update_frame(state["time"])
+
+        if playback_controls:
+            controls["timeline"] = plotter.add_slider_widget(
+                set_time,
+                (start, stop),
+                value=start,
+                title="FLIGHT TIME (s)",
+                pointa=(0.14, 0.055),
+                pointb=(0.70, 0.055),
+                color=colors["axes"],
+                title_color=colors["axes"],
+                interaction_event="always",
+                style="modern",
+                fmt="%6.2f",
+                slider_width=0.018,
+                tube_width=0.006,
+            )
+            _FlightPlots._style_animation_slider(controls["timeline"], colors)
+
+            def set_speed(label):
+                state["speed"] = speed_values[speed_labels.index(label)]
+                state["last_tick"] = time.perf_counter()
+
+            controls["speed"] = plotter.add_text_slider_widget(
+                set_speed,
+                speed_labels,
+                value=speed_values.index(float(playback_speed)),
+                pointa=(0.77, 0.055),
+                pointb=(0.95, 0.055),
+                color=colors["axes"],
+                interaction_event="end",
+                style="modern",
+            )
+            _FlightPlots._style_animation_slider(controls["speed"], colors)
+
+            def set_playing(is_playing):
+                if is_playing and state["time"] >= stop:
+                    set_time(start)
+                    controls["timeline"].GetRepresentation().SetValue(start)
+                state["playing"] = bool(is_playing)
+                state["last_tick"] = time.perf_counter()
+
+            controls["play"] = plotter.add_checkbox_button_widget(
+                set_playing,
+                value=True,
+                position=(22, 24),
+                size=28,
+                border_size=2,
+                color_on=colors["control_on"],
+                color_off=colors["control_off"],
+                background_color=colors["control_background"],
+            )
+            plotter.add_text(
+                "PLAY / PAUSE",
+                position=(60, 30),
+                font_size=10,
+                color=colors["axes"],
+            )
+        else:
+            update_frame(start)
+
+        def advance(_step):
+            now = time.perf_counter()
+            elapsed = now - state["last_tick"]
+            state["last_tick"] = now
+            if not state["playing"]:
+                return
+
+            state["accumulator"] += elapsed * state["speed"]
+            if state["accumulator"] < time_step:
+                return
+
+            next_time = min(state["time"] + state["accumulator"], stop)
+            state["accumulator"] = 0.0
+            state["time"] = next_time
+            if playback_controls:
+                controls["timeline"].GetRepresentation().SetValue(next_time)
+            update_frame(next_time)
+            if next_time >= stop:
+                state["playing"] = False
+                if playback_controls:
+                    controls["play"].GetRepresentation().SetState(0)
+
+        plotter.add_timer_event(
+            max_steps=np.iinfo(np.int32).max,
+            duration=16,
+            callback=advance,
+        )
+        try:
+            show_kwargs = {"auto_close": False}
+            selected_backend = "none" if force_external else backend
+            if selected_backend != "auto":
+                show_kwargs["jupyter_backend"] = selected_backend
+            plotter.show(**show_kwargs)
+        finally:
+            plotter.close()
+        return None
+
+    def animate_trajectory(  # pylint: disable=too-many-statements,too-many-locals
+        self,
+        file_name=None,
+        start=0,
+        stop=None,
+        time_step=0.1,
+        playback_speed=1.0,
+        **kwargs,
+    ):
+        """Animate the 6-DOF trajectory and attitude using PyVista.
+
+        Parameters
+        ----------
+        file_name : str | None, optional
+            Path to a 3D model file representing the rocket, usually ``.stl``.
+            If None, RocketPy uses a built-in default STL model.
+            Default is None.
+        start : int, float, optional
+            Animation start time in seconds. Default is 0.
+        stop : int, float | None, optional
+            Animation end time in seconds. If None, uses ``flight.t_final``.
+            Default is None.
+        time_step : float, optional
+            Animation frame step in seconds. Must be greater than 0.
+            Default is 0.1.
+        playback_speed : float, optional
+            Ratio of simulation time to wall-clock playback time. For example,
+            ``2`` plays at twice real time. Must be greater than 0. Default is 1.
+        **kwargs : dict, optional
+            RocketPy animation options and additional keyword arguments passed
+            to :class:`pyvista.Plotter`. See Notes.
+
+        Other Parameters
+        ----------------
+        background_color : color-like | None, optional
+            Launch background override. None selects a daylight or night
+            palette. Default is None.
+        playback_controls : bool, optional
+            Display play/pause, timeline and playback-speed controls. Default
+            is True.
+        show_subrocket_point : bool, optional
+            Display the rocket's vertical projection on the ground plane.
+            Default is True.
+        ground_image : path-like | pyvista.Texture | mapping | None, optional
+            Ground texture. A mapping may define ``image``, ``bounds``,
+            ``coordinates`` (``"enu"`` or ``"latlon"``), and ``flip_y`` for
+            geographic placement. Default is None.
+        color_by : {"speed", "mach", "dynamic_pressure", "acceleration",
+            "altitude", False, None}, optional
+            Trajectory point scalar. Default is "speed".
+        show_kinematic_plots : bool, optional
+            Show altitude, speed and acceleration histories. Default is False.
+        camera_mode : {"static", "follow", "ground", "body"}, optional
+            Camera tracking preset. Default is "static".
+        camera_path : callable | sequence | None, optional
+            Custom camera function or interpolated camera positions. Default
+            is None.
+        backend : {"auto", "none", "trame", "client"}, optional
+            Visualization backend. Default is "auto".
+        force_external : bool, optional
+            Force rendering in an external window. Default is False.
+        shadows : bool, optional
+            Enable PyVista scene shadows. Scientific overlays remain unlit so
+            their colors stay camera-independent. Default is False.
+        trajectory_line_width : float, optional
+            Width of the flown trajectory; related path widths scale from this
+            value. Default is 4.
+        export_file : path-like | None, optional
+            Deterministic ``.gif`` or ``.mp4`` output. Default is None.
+        export_fps : float, optional
+            Export frame rate. Default is 30.
+        export_resolution : tuple[int, int] | None, optional
+            Export width and height in pixels. Default is None.
+        transparent_background : bool, optional
+            Enable GIF alpha transparency. Default is False.
+        color_scheme : mapping | None, optional
+            Overrides merged into the default animation color dictionary.
+            Default is None.
+
+        Notes
+        -----
+        Coordinates use the inertial East-North-Up frame and metres. Altitude
+        is above ground level. Wind arrows point in the direction the air is
+        moving. The rocket is display-scaled so it remains visible. Native
+        controls provide play/pause, time scrubbing and playback-speed
+        selection.
+
+        RocketPy options accepted through ``kwargs`` are ``background_color``,
+        ``playback_controls``, ``show_subrocket_point``, ``ground_image``,
+        ``color_by``, charts, camera, export, backend and styling options.
+        """
+        pyvista = import_optional_dependency("pyvista")
+        options = self._animation_options(kwargs)
+        colors = self._resolved_animation_colors(options["color_scheme"])
+        file_name = self._resolve_animation_model_path(file_name)
+        stop = self._validate_animation_inputs(file_name, start, stop, time_step)
+        if playback_speed <= 0:
+            raise ValueError(
+                f"Invalid playback_speed: {playback_speed}. It must be greater than 0."
+            )
+        frame_times = np.append(np.arange(start, stop, time_step), stop)
+        path_times = np.linspace(start, stop, min(max(len(frame_times), 120), 800))
+        path_points = np.array([self._animation_position(t) for t in path_times])
+        background_palette = self._animation_background_palette(
+            options["background_color"], colors
+        )
+
+        kwargs.setdefault("window_size", options["export_resolution"] or (1280, 800))
+        if options["export_file"] is not None:
+            kwargs["notebook"] = False
+            kwargs["off_screen"] = True
+        if options["force_external"]:
+            kwargs["notebook"] = False
+            kwargs["off_screen"] = False
+        plotter = pyvista.Plotter(**kwargs)
+        self._style_animation_plotter(
+            plotter,
+            background_palette,
+            colors,
+            show_kinematic_plots=options["show_kinematic_plots"],
+        )
+        if options["shadows"]:
+            plotter.enable_shadows()
+
+        base_rocket = pyvista.read(file_name)
+        base_rocket.translate(-np.asarray(base_rocket.center), inplace=True)
+        scene_span = max(np.ptp(path_points, axis=0).max(), 50.0)
+        display_length = max(base_rocket.length, scene_span * 0.025)
+        base_rocket.scale(display_length / base_rocket.length, inplace=True)
+
+        initial_position = self._animation_position(start)
+        rocket = base_rocket.transform(
+            self._animation_transformation(start, initial_position), inplace=False
+        )
+        color_by = options["color_by"]
+        scalar_name = None
+        scalar_values = None
+        scalar_clim = None
+        if color_by is not None:
+            scalar_label, scalar_unit = self._animation_scalar_metadata(color_by)
+            scalar_name = f"{scalar_label} ({scalar_unit})"
+            scalar_values = np.array(
+                [self._animation_scalar(t, color_by) for t in path_times]
+            )
+            finite_scalars = scalar_values[np.isfinite(scalar_values)]
+            scalar_clim = (
+                (float(np.min(finite_scalars)), float(np.max(finite_scalars)))
+                if finite_scalars.size
+                else (0.0, 1.0)
+            )
+            if np.isclose(*scalar_clim):
+                scalar_clim = (scalar_clim[0] - 0.5, scalar_clim[1] + 0.5)
+            simulated_path = self._dashed_polyline(
+                pyvista,
+                path_points,
+                scalars=scalar_values,
+                scalar_name=scalar_name,
+            )
+            flown_path = self._polyline_with_scalars(
+                pyvista,
+                [initial_position],
+                [self._animation_scalar(start, color_by)],
+                scalar_name,
+            )
+        else:
+            simulated_path = self._dashed_polyline(pyvista, path_points)
+            flown_path = self._polyline(pyvista, [initial_position])
+        velocity = self._animation_velocity(start)
+        velocity_arrow = self._direction_arrow(
+            pyvista,
+            velocity,
+            display_length * 1.8,
+            start=initial_position,
+        )
+        wind = self._animation_wind(start)
+        wind_arrow = self._direction_arrow(
+            pyvista,
+            wind,
+            display_length * 1.55,
+            start=initial_position,
+        )
+
+        horizontal_span = max(np.ptp(path_points[:, :2], axis=0).max() * 1.25, 50)
+        ground_center = np.mean(path_points[:, :2], axis=0)
+        fallback_bounds = (
+            ground_center[0] - horizontal_span / 2,
+            ground_center[0] + horizontal_span / 2,
+            ground_center[1] - horizontal_span / 2,
+            ground_center[1] + horizontal_span / 2,
+        )
+        image = options["ground_image"]
+        image_spec = {
+            "image": image,
+            "bounds": options["ground_image_bounds"],
+            "coordinates": options["ground_image_coordinates"],
+            "flip_y": options["ground_image_flip_y"],
+        }
+        if isinstance(image, Mapping):
+            image_spec.update(image)
+            image = image_spec.get("image")
+            if image is None:
+                raise ValueError("ground_image mapping must define an 'image' value.")
+        ground_bounds = self._ground_bounds_from_spec(image_spec, fallback_bounds)
+        west, east, south, north = ground_bounds
+        ground = pyvista.Plane(
+            center=((west + east) / 2, (south + north) / 2, 0),
+            direction=(0, 0, 1),
+            i_size=east - west,
+            j_size=north - south,
+            i_resolution=20,
+            j_resolution=20,
+        )
+        if image is None:
+            plotter.add_mesh(
+                ground,
+                color=colors["ground"],
+                opacity=0.55,
+                show_edges=True,
+                edge_color=colors["ground_grid"],
+                line_width=1,
+                lighting=False,
+            )
+        else:
+            texture = image
+            if isinstance(texture, (str, os.PathLike)):
+                texture = pyvista.read_texture(os.fspath(texture))
+            if image_spec.get("flip_y", False):
+                texture = texture.flip_y()
+            ground.texture_map_to_plane(use_bounds=True, inplace=True)
+            plotter.add_mesh(
+                ground,
+                texture=texture,
+                opacity=0.92,
+                lighting=False,
+            )
+        simulated_options = {
+            "opacity": 0.72,
+            "line_width": max(1, options["trajectory_line_width"] * 0.45),
+            "lighting": False,
+            "label": "Simulated path",
+        }
+        flown_options = {
+            "line_width": options["trajectory_line_width"],
+            "lighting": False,
+            "label": "Flown path",
+        }
+        simulated_options["color"] = colors["simulated_path"]
+        if color_by is None:
+            flown_options["color"] = colors["flown_path"]
+        else:
+            scalar_bar_args = {
+                "title": scalar_name,
+                "position_x": 0.76,
+                "position_y": 0.14,
+                "width": 0.2,
+                "height": 0.065,
+                "title_font_size": 10,
+                "label_font_size": 9,
+                "color": colors["panel_text"],
+            }
+            flown_options.update(
+                scalars=scalar_name,
+                cmap=colors["scalar_cmap"],
+                clim=scalar_clim,
+                show_scalar_bar=True,
+                scalar_bar_args=scalar_bar_args,
+            )
+        plotter.add_mesh(simulated_path, **simulated_options)
+        plotter.add_mesh(flown_path, **flown_options)
+        velocity_actor = plotter.add_mesh(
+            velocity_arrow,
+            color=colors["velocity"],
+            lighting=False,
+            label="Velocity direction",
+        )
+        velocity_actor.SetVisibility(bool(np.linalg.norm(velocity) > 1e-12))
+        wind_actor = plotter.add_mesh(
+            wind_arrow,
+            color=colors["wind"],
+            lighting=False,
+            label="Wind velocity (toward)",
+        )
+        wind_actor.SetVisibility(bool(np.linalg.norm(wind) > 1e-12))
+        plotter.add_mesh(
+            rocket,
+            color=colors["rocket"],
+            smooth_shading=True,
+            specular=0.18,
+            specular_power=18,
+            label="Rocket (not to scale)",
+        )
+
+        subrocket_point = None
+        if options["show_subrocket_point"]:
+            subrocket_point = pyvista.PolyData(
+                [initial_position[0], initial_position[1], 0]
+            )
+            plotter.add_mesh(
+                subrocket_point,
+                style="points",
+                color=colors["marker_outline"],
+                point_size=17,
+                render_points_as_spheres=True,
+                lighting=False,
+            )
+            plotter.add_mesh(
+                subrocket_point,
+                style="points",
+                color=colors["ground_projection"],
+                point_size=10,
+                render_points_as_spheres=True,
+                lighting=False,
+                label="Ground projection",
+            )
+
+        marker_events = self._animation_event_markers(start, stop, colors)
+        marker_points = np.array(
+            [self._animation_position(event_time) for event_time, _, _ in marker_events]
+        )
+        marker_labels = [label for _, label, _ in marker_events]
+        for point, (_, _label, color) in zip(marker_points, marker_events, strict=True):
+            plotter.add_points(
+                point[np.newaxis, :],
+                color=colors["marker_outline"],
+                point_size=16,
+                render_points_as_spheres=True,
+                lighting=False,
+            )
+            plotter.add_points(
+                point[np.newaxis, :],
+                color=color,
+                point_size=9,
+                render_points_as_spheres=True,
+                lighting=False,
+            )
+        plotter.add_point_labels(
+            marker_points,
+            marker_labels,
+            font_size=10,
+            text_color=colors["label_text"],
+            shape_color=colors["panel_background"],
+            shape_opacity=0.9,
+            point_size=0,
+            always_visible=True,
+        )
+
+        telemetry_position = (
+            "upper_right" if options["show_kinematic_plots"] else "upper_left"
+        )
+        legend_position = (
+            "lower right" if options["show_kinematic_plots"] else "upper right"
+        )
+        telemetry = plotter.add_text(
+            self._trajectory_telemetry(start),
+            position=telemetry_position,
+            font="courier",
+            font_size=9,
+            color=colors["panel_text"],
+            shadow=False,
+        )
+        self._style_telemetry_actor(telemetry, colors)
+        legend = plotter.add_legend(
+            labels=[
+                ["Simulated path", colors["simulated_path"]],
+                ["Flown path", colors["flown_path"]],
+                ["Velocity direction", colors["velocity"]],
+                ["Wind velocity (toward)", colors["wind"]],
+                *(
+                    [["Ground projection", colors["ground_projection"]]]
+                    if options["show_subrocket_point"]
+                    else []
+                ),
+                ["Rocket (not to scale)", colors["rocket_legend"]],
+            ],
+            bcolor=colors["panel_background"],
+            border=True,
+            background_opacity=0.88,
+            size=(0.145, 0.115),
+            loc=legend_position,
+        )
+        self._style_legend_actor(legend, colors)
+        if options["show_kinematic_plots"]:
+            # Keep the scene key directly above the scalar bar and speed
+            # selector instead of occupying chart space at the left.
+            legend.SetPosition(0.815, 0.225)
+        chart_cursors = []
+        if options["show_kinematic_plots"]:
+            chart_cursors = self._add_animation_charts(
+                pyvista,
+                plotter,
+                path_times,
+                self._animation_kinematic_series(path_times),
+                colors,
+            )
+        plotter.show_bounds(
+            ztitle="Altitude AGL (m)",
+            color=colors["axes"],
+            show_xaxis=False,
+            show_yaxis=False,
+            show_xlabels=False,
+            show_ylabels=False,
+            n_zlabels=5,
+            grid="back",
+            location="outer",
+        )
+        plotter.view_isometric()
+        plotter.set_viewup((0, 0, 1))
+        plotter.reset_camera()
+
+        def update_frame(time_value):
+            position = self._animation_position(time_value)
+            self._set_animation_background(
+                plotter, background_palette, max(position[2], 0)
+            )
+            transformed_rocket = base_rocket.transform(
+                self._animation_transformation(time_value, position), inplace=False
+            )
+            rocket.copy_from(transformed_rocket)
+            if subrocket_point is not None:
+                subrocket_point.copy_from(
+                    pyvista.PolyData([position[0], position[1], 0])
+                )
+
+            flown_points = path_points[path_times < time_value]
+            flown_points = np.vstack((flown_points, position))
+            if color_by is None:
+                updated_flown_path = self._polyline(pyvista, flown_points)
+            else:
+                flown_times = np.append(path_times[path_times < time_value], time_value)
+                flown_scalars = [
+                    self._animation_scalar(t, color_by) for t in flown_times
+                ]
+                updated_flown_path = self._polyline_with_scalars(
+                    pyvista, flown_points, flown_scalars, scalar_name
+                )
+            flown_path.copy_from(updated_flown_path)
+            current_velocity = self._animation_velocity(time_value)
+            velocity_arrow.copy_from(
+                self._direction_arrow(
+                    pyvista,
+                    current_velocity,
+                    display_length * 1.8,
+                    start=position,
+                )
+            )
+            velocity_actor.SetVisibility(bool(np.linalg.norm(current_velocity) > 1e-12))
+            current_wind = self._animation_wind(time_value)
+            wind_arrow.copy_from(
+                self._direction_arrow(
+                    pyvista,
+                    current_wind,
+                    display_length * 1.55,
+                    start=position,
+                )
+            )
+            wind_actor.SetVisibility(bool(np.linalg.norm(current_wind) > 1e-12))
+            telemetry.set_text(
+                telemetry_position, self._trajectory_telemetry(time_value)
+            )
+            self._update_animation_chart_cursors(chart_cursors, time_value)
+            self._update_animation_camera(
+                plotter,
+                options["camera_mode"],
+                position,
+                self._animation_transformation(time_value),
+                scene_span,
+                time_value,
+                start,
+                stop,
+                options["camera_path"],
+            )
+
+        return self._run_animation(
+            plotter,
+            update_frame,
+            start,
+            stop,
+            time_step,
+            playback_speed,
+            colors=colors,
+            playback_controls=options["playback_controls"],
+            backend=options["backend"],
+            force_external=options["force_external"],
+            export_file=options["export_file"],
+            export_fps=options["export_fps"],
+            transparent_background=options["transparent_background"],
+        )
+
+    def animate_rotate(  # pylint: disable=too-many-statements,too-many-locals
+        self,
+        file_name=None,
+        start=0,
+        stop=None,
+        time_step=0.1,
+        playback_speed=1.0,
+        **kwargs,
+    ):
+        """Animate rocket attitude in an inertial reference scene using PyVista.
+
+        Parameters
+        ----------
+        file_name : str | None, optional
+            Path to a 3D model file representing the rocket, usually ``.stl``.
+            If None, RocketPy uses a built-in default STL model.
+            Default is None.
+        start : int, float, optional
+            Animation start time in seconds. Default is 0.
+        stop : int, float | None, optional
+            Animation end time in seconds. If None, uses ``flight.t_final``.
+            Default is None.
+        time_step : float, optional
+            Animation frame step in seconds. Must be greater than 0.
+            Default is 0.1.
+        playback_speed : float, optional
+            Ratio of simulation time to wall-clock playback time. For example,
+            ``2`` plays at twice real time. Must be greater than 0. Default is 1.
+        **kwargs : dict, optional
+            RocketPy animation options and additional keyword arguments passed
+            to :class:`pyvista.Plotter`. See Notes.
+
+        Other Parameters
+        ----------------
+        background_color : color-like | None, optional
+            Launch background override. None selects a daylight or night
+            palette. Default is None.
+        playback_controls : bool, optional
+            Display play/pause, timeline and playback-speed controls. Default
+            is True.
+        backend : {"auto", "none", "trame", "client"}, optional
+            Visualization backend. Default is "auto".
+        force_external : bool, optional
+            Force rendering in an external window. Default is False.
+        shadows : bool, optional
+            Enable PyVista scene shadows. Body and direction overlays remain
+            unlit so their colors stay camera-independent. Default is False.
+        show_kinematic_plots : bool, optional
+            Show altitude, speed and acceleration histories. Default is False.
+        show_attitude_plots : bool, optional
+            Show aerodynamic angles, 3-1-3 Euler angles and body angular-rate
+            histories. Default is False.
+        show_cp_cm : bool, optional
+            Show dynamic center-of-mass and center-of-pressure markers and
+            telemetry. Default is False.
+        camera_mode : {"static", "follow", "ground", "body"}, optional
+            Camera tracking preset. Default is "static".
+        camera_path : callable | sequence | None, optional
+            Custom camera function or interpolated camera positions. Default
+            is None.
+        export_file : path-like | None, optional
+            Deterministic ``.gif`` or ``.mp4`` output. Default is None.
+        export_fps : float, optional
+            Export frame rate. Default is 30.
+        export_resolution : tuple[int, int] | None, optional
+            Export width and height in pixels. Default is None.
+        transparent_background : bool, optional
+            Enable GIF alpha transparency. Default is False.
+        color_scheme : mapping | None, optional
+            Overrides merged into the default animation color dictionary.
+            Default is None.
+
+        Notes
+        -----
+        The fixed reference frame is East-North-Up. Body X, Y and Z correspond
+        to pitch, yaw and roll axes respectively. Angular rates are displayed
+        in degrees per second. Velocity and wind arrows show inertial directions
+        at the selected time; the wind arrow points toward air motion. Native
+        controls provide play/pause, time scrubbing and playback-speed selection.
+
+        RocketPy options accepted through ``kwargs`` are ``background_color``,
+        ``playback_controls``, charts, stability markers, camera, export,
+        backend and styling options.
+        Trajectory-only options are accepted and ignored so shared option
+        dictionaries can be used with both animation methods.
+        """
+        pyvista = import_optional_dependency("pyvista")
+        options = self._animation_options(kwargs)
+        colors = self._resolved_animation_colors(options["color_scheme"])
+        file_name = self._resolve_animation_model_path(file_name)
+        stop = self._validate_animation_inputs(file_name, start, stop, time_step)
+        if playback_speed <= 0:
+            raise ValueError(
+                f"Invalid playback_speed: {playback_speed}. It must be greater than 0."
+            )
+        sample_count = min(max(int(np.ceil((stop - start) / time_step)) + 1, 120), 800)
+        history_times = np.linspace(start, stop, sample_count)
+        background_palette = self._animation_background_palette(
+            options["background_color"], colors
+        )
+
+        kwargs.setdefault("window_size", options["export_resolution"] or (1100, 800))
+        if options["export_file"] is not None:
+            kwargs["notebook"] = False
+            kwargs["off_screen"] = True
+        if options["force_external"]:
+            kwargs["notebook"] = False
+            kwargs["off_screen"] = False
+        plotter = pyvista.Plotter(**kwargs)
+        self._style_animation_plotter(
+            plotter,
+            background_palette,
+            colors,
+            show_kinematic_plots=options["show_kinematic_plots"],
+        )
+        if options["shadows"]:
+            plotter.enable_shadows()
+
+        base_rocket = pyvista.read(file_name)
+        base_rocket.translate(-np.asarray(base_rocket.center), inplace=True)
+        rocket = base_rocket.transform(
+            self._animation_transformation(start), inplace=False
+        )
+        reference_radius = base_rocket.length * 0.8
+        arrow_scale = base_rocket.length * 0.64
+        rotation = self._animation_transformation(start)
+        body_arrows = [
+            self._direction_arrow(pyvista, rotation[:3, index], arrow_scale)
+            for index in range(3)
+        ]
+        velocity = self._animation_velocity(start)
+        velocity_arrow = self._direction_arrow(pyvista, velocity, arrow_scale * 0.92)
+        wind = self._animation_wind(start)
+        wind_arrow = self._direction_arrow(pyvista, wind, arrow_scale * 0.82)
+
+        plotter.add_mesh(
+            pyvista.Sphere(
+                radius=reference_radius, theta_resolution=36, phi_resolution=18
+            ),
+            style="wireframe",
+            color=colors["reference_grid"],
+            opacity=0.11,
+            line_width=1,
+            lighting=False,
+        )
+        theta = np.linspace(0, 2 * np.pi, 121)[:-1]
+        horizon_points = np.column_stack(
+            (
+                reference_radius * np.cos(theta),
+                reference_radius * np.sin(theta),
+                np.zeros_like(theta),
+            )
+        )
+        plotter.add_mesh(
+            self._polyline(pyvista, horizon_points, closed=True),
+            color=colors["horizon"],
+            opacity=0.92,
+            line_width=3,
+            lighting=False,
+        )
+        plotter.add_mesh(
+            rocket,
+            color=colors["rocket"],
+            smooth_shading=True,
+            specular=0.18,
+            specular_power=18,
+        )
+        axis_colors = (colors["body_x"], colors["body_y"], colors["body_z"])
+        axis_labels = ("Body X — pitch", "Body Y — yaw", "Body Z — roll")
+        for arrow, color, label in zip(
+            body_arrows, axis_colors, axis_labels, strict=True
+        ):
+            plotter.add_mesh(
+                arrow,
+                color=color,
+                lighting=False,
+                label=label,
+            )
+        velocity_actor = plotter.add_mesh(
+            velocity_arrow,
+            color=colors["velocity"],
+            lighting=False,
+            label="Velocity direction",
+        )
+        velocity_actor.SetVisibility(bool(np.linalg.norm(velocity) > 1e-12))
+        wind_actor = plotter.add_mesh(
+            wind_arrow,
+            color=colors["wind"],
+            lighting=False,
+            label="Wind velocity (toward)",
+        )
+        wind_actor.SetVisibility(bool(np.linalg.norm(wind) > 1e-12))
+
+        center_of_mass_marker = None
+        center_of_pressure_marker = None
+        center_of_mass_connector = None
+        center_of_pressure_connector = None
+        if options["show_cp_cm"]:
+            marker_radius = base_rocket.length * 0.035
+            callout_offset = reference_radius * 0.30
+            center_of_mass = self.flight.rocket.center_of_mass(start)
+            center_of_pressure = self.flight.rocket.cp_position(
+                self.flight.mach_number(start)
+            )
+            cm_station = rotation[:3, :3] @ np.array(
+                [
+                    0,
+                    0,
+                    self._rocket_axial_display_coordinate(
+                        center_of_mass, base_rocket.length
+                    ),
+                ]
+            )
+            cp_station = rotation[:3, :3] @ np.array(
+                [
+                    0,
+                    0,
+                    self._rocket_axial_display_coordinate(
+                        center_of_pressure, base_rocket.length
+                    ),
+                ]
+            )
+            cm_position = cm_station + rotation[:3, 1] * callout_offset
+            cp_position = cp_station - rotation[:3, 1] * callout_offset
+            center_of_mass_marker = pyvista.Sphere(
+                radius=marker_radius, center=cm_position
+            )
+            center_of_pressure_marker = pyvista.Sphere(
+                radius=marker_radius, center=cp_position
+            )
+            plotter.add_mesh(
+                center_of_mass_marker,
+                color=colors["center_of_mass"],
+                lighting=False,
+                label="Center of mass",
+            )
+            plotter.add_mesh(
+                center_of_pressure_marker,
+                color=colors["center_of_pressure"],
+                lighting=False,
+                label="Center of pressure",
+            )
+            center_of_mass_connector = self._polyline(
+                pyvista, [cm_station, cm_position]
+            )
+            center_of_pressure_connector = self._polyline(
+                pyvista, [cp_station, cp_position]
+            )
+            plotter.add_mesh(
+                center_of_mass_connector,
+                color=colors["center_of_mass"],
+                line_width=3,
+                lighting=False,
+            )
+            plotter.add_mesh(
+                center_of_pressure_connector,
+                color=colors["center_of_pressure"],
+                line_width=3,
+                lighting=False,
+            )
+
+        telemetry_position = "upper_left"
+        legend_position = (
+            "upper center"
+            if options["show_kinematic_plots"] and options["show_attitude_plots"]
+            else "upper right"
+        )
+
+        telemetry = plotter.add_text(
+            self._rotation_telemetry(
+                start, rotation, include_stability=options["show_cp_cm"]
+            ),
+            position=telemetry_position,
+            font="courier",
+            font_size=8,
+            color=colors["panel_text"],
+            shadow=False,
+        )
+        self._style_telemetry_actor(telemetry, colors)
+        legend = plotter.add_legend(
+            labels=[
+                ["Body X — pitch", colors["body_x"]],
+                ["Body Y — yaw", colors["body_y"]],
+                ["Body Z — roll", colors["body_z"]],
+                ["Velocity direction", colors["velocity"]],
+                ["Wind velocity (toward)", colors["wind"]],
+                *(
+                    [
+                        ["Center of mass", colors["center_of_mass"]],
+                        ["Center of pressure", colors["center_of_pressure"]],
+                    ]
+                    if options["show_cp_cm"]
+                    else []
+                ),
+            ],
+            bcolor=colors["panel_background"],
+            border=True,
+            background_opacity=0.88,
+            size=(0.145, 0.14 if options["show_cp_cm"] else 0.105),
+            loc=legend_position,
+        )
+        self._style_legend_actor(legend, colors)
+        kinematic_cursors = []
+        attitude_cursors = []
+        dual_chart_columns = bool(
+            options["show_kinematic_plots"] and options["show_attitude_plots"]
+        )
+        if options["show_kinematic_plots"]:
+            kinematic_cursors = self._add_animation_charts(
+                pyvista,
+                plotter,
+                history_times,
+                self._animation_kinematic_series(history_times),
+                colors,
+                compact=dual_chart_columns,
+            )
+        if options["show_attitude_plots"]:
+            attitude_cursors = self._add_animation_charts(
+                pyvista,
+                plotter,
+                history_times,
+                self._animation_attitude_series(history_times),
+                colors,
+                attitude=True,
+                compact=dual_chart_columns,
+            )
+        plotter.view_isometric()
+        plotter.set_viewup((0, 0, 1))
+        plotter.reset_camera()
+
+        def update_frame(time_value):
+            current_rotation = self._animation_transformation(time_value)
+            position = self._animation_position(time_value)
+            self._set_animation_background(
+                plotter, background_palette, max(position[2], 0)
+            )
+            rocket.copy_from(base_rocket.transform(current_rotation, inplace=False))
+            for index, arrow in enumerate(body_arrows):
+                arrow.copy_from(
+                    self._direction_arrow(
+                        pyvista, current_rotation[:3, index], arrow_scale
+                    )
+                )
+            current_velocity = self._animation_velocity(time_value)
+            velocity_arrow.copy_from(
+                self._direction_arrow(pyvista, current_velocity, arrow_scale * 0.92)
+            )
+            velocity_actor.SetVisibility(bool(np.linalg.norm(current_velocity) > 1e-12))
+            current_wind = self._animation_wind(time_value)
+            wind_arrow.copy_from(
+                self._direction_arrow(pyvista, current_wind, arrow_scale * 0.82)
+            )
+            wind_actor.SetVisibility(bool(np.linalg.norm(current_wind) > 1e-12))
+            if center_of_mass_marker is not None:
+                center_of_mass = self.flight.rocket.center_of_mass(time_value)
+                center_of_pressure = self.flight.rocket.cp_position(
+                    self.flight.mach_number(time_value)
+                )
+                cm_station = current_rotation[:3, :3] @ np.array(
+                    [
+                        0,
+                        0,
+                        self._rocket_axial_display_coordinate(
+                            center_of_mass, base_rocket.length
+                        ),
+                    ]
+                )
+                cp_station = current_rotation[:3, :3] @ np.array(
+                    [
+                        0,
+                        0,
+                        self._rocket_axial_display_coordinate(
+                            center_of_pressure, base_rocket.length
+                        ),
+                    ]
+                )
+                cm_position = cm_station + current_rotation[:3, 1] * callout_offset
+                cp_position = cp_station - current_rotation[:3, 1] * callout_offset
+                center_of_mass_marker.copy_from(
+                    pyvista.Sphere(radius=marker_radius, center=cm_position)
+                )
+                center_of_pressure_marker.copy_from(
+                    pyvista.Sphere(radius=marker_radius, center=cp_position)
+                )
+                center_of_mass_connector.copy_from(
+                    self._polyline(pyvista, [cm_station, cm_position])
+                )
+                center_of_pressure_connector.copy_from(
+                    self._polyline(pyvista, [cp_station, cp_position])
+                )
+            telemetry.set_text(
+                telemetry_position,
+                self._rotation_telemetry(
+                    time_value,
+                    current_rotation,
+                    include_stability=options["show_cp_cm"],
+                ),
+            )
+            self._update_animation_chart_cursors(
+                [*kinematic_cursors, *attitude_cursors], time_value
+            )
+            self._update_animation_camera(
+                plotter,
+                options["camera_mode"],
+                np.zeros(3),
+                current_rotation,
+                reference_radius * 2.5,
+                time_value,
+                start,
+                stop,
+                options["camera_path"],
+            )
+
+        return self._run_animation(
+            plotter,
+            update_frame,
+            start,
+            stop,
+            time_step,
+            playback_speed,
+            colors=colors,
+            playback_controls=options["playback_controls"],
+            backend=options["backend"],
+            force_external=options["force_external"],
+            export_file=options["export_file"],
+            export_fps=options["export_fps"],
+            transparent_background=options["transparent_background"],
+        )
+
     def linear_kinematics_data(self, *, filename=None):  # pylint: disable=too-many-statements
         """Prints out all Kinematics graphs available about the Flight
 
@@ -411,16 +2413,20 @@ def rail_buttons_bending_moments(self, *, filename=None):
         None
         """
         if len(self.flight.rocket.rail_buttons) == 0:
-            print(
+            logger.warning(
                 "No rail buttons were defined. Skipping rail button bending moment plots."
             )
         elif self.flight.out_of_rail_time_index == 0:
-            print("No rail phase was found. Skipping rail button bending moment plots.")
+            logger.warning(
+                "No rail phase was found. Skipping rail button bending moment plots."
+            )
         else:
             # Check if button_height is defined
             rail_buttons_tuple = self.flight.rocket.rail_buttons[0]
             if rail_buttons_tuple.component.button_height is None:
-                print("Rail button height not defined. Skipping bending moment plots.")
+                logger.warning(
+                    "Rail button height not defined. Skipping bending moment plots."
+                )
             else:
                 plt.figure(figsize=(9, 3))
 
@@ -475,9 +2481,9 @@ def rail_buttons_forces(self, *, filename=None):  # pylint: disable=too-many-sta
         None
         """
         if len(self.flight.rocket.rail_buttons) == 0:
-            print("No rail buttons were defined. Skipping rail button plots.")
+            logger.warning("No rail buttons were defined. Skipping rail button plots.")
         elif self.flight.out_of_rail_time_index == 0:
-            print("No rail phase was found. Skipping rail button plots.")
+            logger.warning("No rail phase was found. Skipping rail button plots.")
         else:
             plt.figure(figsize=(9, 6))
 
@@ -722,7 +2728,7 @@ def energy_data(self, *, filename=None):  # pylint: disable=too-many-statements
         ax3 = plt.subplot(413)
         # Handle both array-based and callable-based Functions
         thrust_power = self.flight.thrust_power
-        if callable(thrust_power.source):
+        if not thrust_power.is_array_source():
             # For callable sources, discretize based on speed
             thrust_power = thrust_power.set_discrete_based_on_model(
                 self.flight.speed, mutate_self=False
@@ -743,7 +2749,7 @@ def energy_data(self, *, filename=None):  # pylint: disable=too-many-statements
         ax4 = plt.subplot(414)
         # Handle both array-based and callable-based Functions
         drag_power = self.flight.drag_power
-        if callable(drag_power.source):
+        if not drag_power.is_array_source():
             # For callable sources, discretize based on speed
             drag_power = drag_power.set_discrete_based_on_model(
                 self.flight.speed, mutate_self=False
@@ -771,6 +2777,18 @@ def energy_data(self, *, filename=None):  # pylint: disable=too-many-statements
         plt.subplots_adjust(hspace=1)
         show_or_save_plot(filename)
 
+    @staticmethod
+    def __signed_angle_ylim(angle_function, t_start, t_end, margin=5):
+        """Return ``(ymin, ymax)`` limits for a signed angle plotted between
+        ``t_start`` and ``t_end``. The range is based only on the samples inside
+        that time window so the full (positive and negative) oscillation is
+        visible, unlike a fixed floor at zero which would clip it.
+        """
+        data = angle_function[:, :]
+        mask = (data[:, 0] >= t_start) & (data[:, 0] <= t_end)
+        values = data[mask, 1] if np.any(mask) else data[:, 1]
+        return values.min() - margin, values.max() + margin
+
     def fluid_mechanics_data(self, *, filename=None):  # pylint: disable=too-many-statements
         """Prints out a summary of the Fluid Mechanics graphs available about
         the Flight
@@ -848,8 +2866,15 @@ def fluid_mechanics_data(self, *, filename=None):  # pylint: disable=too-many-st
         ax5.set_xlabel("Time (s)")
         ax5.set_ylabel("Partial Angle of Attack (°)")
         ax5.set_xlim(self.flight.out_of_rail_time, self.first_event_time)
+        # Partial angle of attack is a signed angle oscillating around zero, so
+        # scale to the data in the plotted window instead of flooring at 0
+        # (which would clip the negative half of the oscillation).
         ax5.set_ylim(
-            0, self.flight.partial_angle_of_attack(self.flight.out_of_rail_time) + 15
+            *self.__signed_angle_ylim(
+                self.flight.partial_angle_of_attack,
+                self.flight.out_of_rail_time,
+                self.first_event_time,
+            )
         )
         ax5.grid()
 
@@ -861,8 +2886,13 @@ def fluid_mechanics_data(self, *, filename=None):  # pylint: disable=too-many-st
         ax6.set_xlabel("Time (s)")
         ax6.set_ylabel("Angle of Sideslip (°)")
         ax6.set_xlim(self.flight.out_of_rail_time, self.first_event_time)
+        # Sideslip is also signed; keep the full oscillation visible.
         ax6.set_ylim(
-            0, self.flight.angle_of_sideslip(self.flight.out_of_rail_time) + 15
+            *self.__signed_angle_ylim(
+                self.flight.angle_of_sideslip,
+                self.flight.out_of_rail_time,
+                self.first_event_time,
+            )
         )
         ax6.grid()
 
@@ -1002,14 +3032,35 @@ def pressure_signals(self):
         None
         """
 
-        if len(self.flight.parachute_events) > 0:
-            for parachute in self.flight.rocket.parachutes:
-                print("\nParachute: ", parachute.name)
-                parachute.noise_signal_function()
-                parachute.noisy_pressure_signal_function()
-                parachute.clean_pressure_signal_function()
-        else:
-            print("\nRocket has no parachutes. No parachute plots available")
+        if len(self.flight.parachute_events) == 0:
+            logger.warning("Rocket has no parachutes. No parachute plots available.")
+            return
+
+        for parachute in self.flight.rocket.parachutes:
+            clean = parachute.clean_pressure_signal_function
+            noisy = parachute.noisy_pressure_signal_function
+            # Nothing was recorded (e.g. parachute never triggered)
+            if not isinstance(clean.source, np.ndarray) or clean.source.ndim != 2:
+                continue
+            time_signal = clean.source[:, 0]
+
+            plt.figure(figsize=(9, 4))
+            plt.plot(
+                time_signal, clean(time_signal), label="Without noise", linewidth=1.5
+            )
+            plt.plot(
+                time_signal,
+                noisy(time_signal),
+                label="With noise",
+                alpha=0.7,
+                linewidth=0.8,
+            )
+            plt.title(f"Parachute trigger pressure signal: {parachute.name}")
+            plt.xlabel("Time (s)")
+            plt.ylabel("Pressure (Pa)")
+            plt.legend()
+            plt.grid(True)
+            show_or_save_plot()
 
     def all(self):  # pylint: disable=too-many-statements
         """Prints out all plots available about the Flight.
@@ -1028,7 +3079,7 @@ def all(self):  # pylint: disable=too-many-statements
         print("\n\nAngular Position Plots\n")
         self.flight_path_angle_data()
 
-        print("\n\nPath, Attitude and Lateral Attitude Angle plots\n")
+        print("\n\nPath, Attitude and Lateral Attitude Angle Plots\n")
         self.attitude_data()
 
         print("\n\nTrajectory Angular Velocity and Acceleration Plots\n")
diff --git a/rocketpy/plots/monte_carlo_plots.py b/rocketpy/plots/monte_carlo_plots.py
index e9ce4ef3a..aeb0f2746 100644
--- a/rocketpy/plots/monte_carlo_plots.py
+++ b/rocketpy/plots/monte_carlo_plots.py
@@ -1,3 +1,4 @@
+import logging
 import urllib
 from pathlib import Path
 
@@ -14,6 +15,8 @@
 )
 from .plot_helpers import show_or_save_plot
 
+logger = logging.getLogger(__name__)
+
 
 class _MonteCarloPlots:
     """Class to plot the Monte Carlo analysis results."""
@@ -265,14 +268,14 @@ def ellipses(
             apogee_x = np.array(self.monte_carlo.results["apogee_x"])
             apogee_y = np.array(self.monte_carlo.results["apogee_y"])
         except KeyError:
-            print("No apogee data found. Skipping apogee ellipses.")
+            logger.warning("No apogee data found. Skipping apogee ellipses.")
             apogee_x = np.array([])
             apogee_y = np.array([])
         try:
             impact_x = np.array(self.monte_carlo.results["x_impact"])
             impact_y = np.array(self.monte_carlo.results["y_impact"])
         except KeyError:
-            print("No impact data found. Skipping impact ellipses.")
+            logger.warning("No impact data found. Skipping impact ellipses.")
             impact_x = np.array([])
             impact_y = np.array([])
 
@@ -557,7 +560,7 @@ def ellipses_comparison(
             other_apogee_x = np.array(other_monte_carlo.results["apogee_x"])
             other_apogee_y = np.array(other_monte_carlo.results["apogee_y"])
         except KeyError:
-            print("No apogee data found. Skipping apogee ellipses.")
+            logger.warning("No apogee data found. Skipping apogee ellipses.")
             original_apogee_x = np.array([])
             original_apogee_y = np.array([])
             other_apogee_x = np.array([])
@@ -568,7 +571,7 @@ def ellipses_comparison(
             other_impact_x = np.array(other_monte_carlo.results["x_impact"])
             other_impact_y = np.array(other_monte_carlo.results["y_impact"])
         except KeyError:
-            print("No impact data found. Skipping impact ellipses.")
+            logger.warning("No impact data found. Skipping impact ellipses.")
             original_impact_x = np.array([])
             original_impact_y = np.array([])
             other_impact_x = np.array([])
diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py
index e208c775f..8e2b35558 100644
--- a/rocketpy/plots/rocket_plots.py
+++ b/rocketpy/plots/rocket_plots.py
@@ -1,8 +1,9 @@
 import matplotlib.pyplot as plt
 import numpy as np
 
-from rocketpy.motors import EmptyMotor, HybridMotor, LiquidMotor, SolidMotor
-from rocketpy.rocket.aero_surface import Fins, NoseCone, Tail
+from rocketpy.mathutils.vector_matrix import Vector
+from rocketpy.motors import HybridMotor, LiquidMotor, SolidMotor
+from rocketpy.rocket.aero_surface import Fin, Fins, NoseCone, Tail
 from rocketpy.rocket.aero_surface.generic_surface import GenericSurface
 
 from .plot_helpers import show_or_save_plot
@@ -183,7 +184,7 @@ def draw(self, vis_args=None, plane="xz", *, filename=None):
             and webp (these are the formats supported by matplotlib).
         """
 
-        self.__validate_aerodynamic_surfaces()
+        self.__validate_aerodynamic_surfaces(plane)
 
         if vis_args is None:
             vis_args = {
@@ -203,9 +204,9 @@ def draw(self, vis_args=None, plane="xz", *, filename=None):
 
         csys = self.rocket._csys
         reverse = csys == 1
-        self.rocket.aerodynamic_surfaces.sort_by_position(reverse=reverse)
+        surfaces = self.rocket.aerodynamic_surfaces.sort_by_position(reverse=reverse)
 
-        drawn_surfaces = self._draw_aerodynamic_surfaces(ax, vis_args, plane)
+        drawn_surfaces = self._draw_aerodynamic_surfaces(ax, vis_args, plane, surfaces)
         last_radius, last_x = self._draw_tubes(ax, drawn_surfaces, vis_args)
         self._draw_motor(last_radius, last_x, ax, vis_args)
         self._draw_rail_buttons(ax, vis_args)
@@ -221,13 +222,15 @@ def draw(self, vis_args=None, plane="xz", *, filename=None):
         plt.tight_layout()
         show_or_save_plot(filename)
 
-    def __validate_aerodynamic_surfaces(self):
+    def __validate_aerodynamic_surfaces(self, plane):
         if not self.rocket.aerodynamic_surfaces:
             raise ValueError(
                 "The rocket must have at least one aerodynamic surface to be drawn."
             )
+        if plane not in ("xz", "yz"):
+            raise ValueError("The plane must be 'xz' or 'yz'. The default is 'xz'.")
 
-    def _draw_aerodynamic_surfaces(self, ax, vis_args, plane):
+    def _draw_aerodynamic_surfaces(self, ax, vis_args, plane, surfaces):
         """Draws the aerodynamic surfaces and saves the position of the points
         of interest for the tubes."""
         # List of drawn surfaces with the position of points of interest
@@ -240,13 +243,17 @@ def _draw_aerodynamic_surfaces(self, ax, vis_args, plane):
         # diameter changes. The final point of the last surface is the final
         # point of the last tube
 
-        for surface, position in self.rocket.aerodynamic_surfaces:
+        for surface, position in surfaces:
             if isinstance(surface, NoseCone):
                 self._draw_nose_cone(ax, surface, position.z, drawn_surfaces, vis_args)
             elif isinstance(surface, Tail):
                 self._draw_tail(ax, surface, position.z, drawn_surfaces, vis_args)
             elif isinstance(surface, Fins):
-                self._draw_fins(ax, surface, position.z, drawn_surfaces, vis_args)
+                self._draw_fins(
+                    ax, surface, position.z, drawn_surfaces, vis_args, plane
+                )
+            elif isinstance(surface, Fin):
+                self._draw_fin(ax, surface, position, drawn_surfaces, vis_args, plane)
             elif isinstance(surface, GenericSurface):
                 self._draw_generic_surface(
                     ax, surface, position, drawn_surfaces, vis_args, plane
@@ -309,13 +316,15 @@ def _draw_tail(self, ax, surface, position, drawn_surfaces, vis_args):
         # Add the tail to the list of drawn surfaces
         drawn_surfaces.append((surface, position, surface.bottom_radius, x_tail[-1]))
 
-    def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args):
+    def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args, plane):
         """Draws the fins and saves the position of the points of interest
         for the tubes."""
         num_fins = surface.n
         x_fin = -self.rocket._csys * surface.shape_vec[0] + position
         y_fin = surface.shape_vec[1] + surface.rocket_radius
-        rotation_angles = [2 * np.pi * i / num_fins for i in range(num_fins)]
+        rotation_angles = np.array([2 * np.pi * i / num_fins for i in range(num_fins)])
+        if plane == "xz":
+            rotation_angles -= np.pi / 2
 
         for angle in rotation_angles:
             # Create a rotation matrix for the current angle around the x-axis
@@ -327,13 +336,6 @@ def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args):
             # Extract x and y coordinates of the rotated points
             x_rotated, y_rotated = rotated_points_2d
 
-            # Project points above the XY plane back into the XY plane (set z-coordinate to 0)
-            x_rotated = np.where(
-                rotated_points_2d[1] > 0, rotated_points_2d[0], x_rotated
-            )
-            y_rotated = np.where(
-                rotated_points_2d[1] > 0, rotated_points_2d[1], y_rotated
-            )
             ax.plot(
                 x_rotated,
                 y_rotated,
@@ -343,6 +345,56 @@ def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args):
 
         drawn_surfaces.append((surface, position, surface.rocket_radius, x_rotated[-1]))
 
+    def _draw_fin(self, ax, surface, position, drawn_surfaces, vis_args, plane):
+        """Draws individual fins."""
+
+        # Get shape vec
+        xs = surface.shape_vec[0]
+        ys = surface.shape_vec[1]
+        zs = np.zeros_like(xs)
+
+        # Define shape in fin coordinate system
+        x_fin = -zs
+        y_fin = ys
+        z_fin = xs
+        points = np.column_stack((x_fin, y_fin, z_fin))
+
+        # Move drawing coordinates to center of fin for cant angle rotation
+        xd = np.array([0, 0, max(xs) / 2])
+        points -= xd
+
+        # Rotate to body coordinate system
+        for i, p in enumerate(points):
+            points[i] = surface._rotation_fin_to_body @ Vector(p)
+
+        rotated_xd = surface._rotation_fin_to_body @ Vector(xd)
+        points += np.array(rotated_xd)
+
+        # Back to the drawing system
+        x_fin_rotated = points[:, 0]
+        y_fin_rotated = points[:, 1]
+        z_fin_rotated = points[:, 2]
+
+        if plane == "xz":
+            x_rotated = self.rocket._csys * z_fin_rotated + position.z
+            y_rotated = x_fin_rotated + position.x
+        elif plane == "yz":
+            x_rotated = self.rocket._csys * z_fin_rotated + position.z
+            y_rotated = y_fin_rotated + position.y
+        else:  # pragma: no cover
+            raise ValueError("Plane must be 'xz' or 'yz'.")
+
+        ax.plot(
+            x_rotated,
+            y_rotated,
+            color=vis_args["fins"],
+            linewidth=vis_args["line_width"],
+        )
+
+        drawn_surfaces.append(
+            (surface, position.z, surface.rocket_radius, x_rotated[-1])
+        )
+
     def _draw_generic_surface(
         self,
         ax,
@@ -420,57 +472,82 @@ def _draw_tubes(self, ax, drawn_surfaces, vis_args):
     def _draw_motor(self, last_radius, last_x, ax, vis_args):
         """Draws the motor from motor patches"""
         total_csys = self.rocket._csys * self.rocket.motor._csys
+        is_cluster = hasattr(self.rocket.motor, "number")
+        base_motor = self.rocket.motor.motor if is_cluster else self.rocket.motor
+
+        if is_cluster:
+            angles = np.linspace(0, 2 * np.pi, self.rocket.motor.number, endpoint=False)
+            y_offsets = self.rocket.motor.radius * np.cos(angles)
+        else:
+            y_offsets = [0]
         nozzle_position = (
-            self.rocket.motor_position + self.rocket.motor.nozzle_position * total_csys
+            self.rocket.motor_position + base_motor.nozzle_position * total_csys
         )
-
         # Get motor patches translated to the correct position
         motor_patches = self._generate_motor_patches(total_csys, ax)
-
         # Draw patches
-        if not isinstance(self.rocket.motor, EmptyMotor):
-            # Add nozzle last so it is in front of the other patches
-            nozzle = self.rocket.motor.plots._generate_nozzle(
-                translate=(nozzle_position, 0), csys=self.rocket._csys
-            )
-            motor_patches += [nozzle]
+        if type(self.rocket.motor).__name__ != "EmptyMotor":
+            for y_off in y_offsets:
+                nozzle = base_motor.plots._generate_nozzle(
+                    translate=(nozzle_position, y_off), csys=self.rocket._csys
+                )
+                if y_off != y_offsets[0]:
+                    nozzle.set_label("_nolegend_")
+                motor_patches.append(nozzle)
 
-            outline = self.rocket.motor.plots._generate_motor_region(
+            outline = base_motor.plots._generate_motor_region(
                 list_of_patches=motor_patches
             )
-            # add outline first so it is behind the other patches
-            ax.add_patch(outline)
+            if not is_cluster:
+                ax.add_patch(outline)
+
             for patch in motor_patches:
+                if is_cluster:
+                    patch.set_alpha(0.6)
                 ax.add_patch(patch)
-
         self._draw_nozzle_tube(last_radius, last_x, nozzle_position, ax, vis_args)
 
-    def _generate_motor_patches(self, total_csys, ax):  # pylint: disable=unused-argument
+    def _generate_motor_patches(self, total_csys, ax):
         """Generates motor patches for drawing"""
         motor_patches = []
 
-        if isinstance(self.rocket.motor, SolidMotor):
+        is_cluster = hasattr(self.rocket.motor, "number")
+        base_motor = self.rocket.motor.motor if is_cluster else self.rocket.motor
+
+        if isinstance(base_motor, SolidMotor):
+            y_offsets = (
+                self.rocket.motor.radius
+                * np.cos(
+                    np.linspace(0, 2 * np.pi, self.rocket.motor.number, endpoint=False)
+                )
+                if is_cluster
+                else [0]
+            )
             grains_cm_position = (
                 self.rocket.motor_position
-                + self.rocket.motor.grains_center_of_mass_position * total_csys
-            )
-            ax.scatter(
-                grains_cm_position,
-                0,
-                color="brown",
-                label="Grains Center of Mass",
-                s=8,
-                zorder=10,
+                + base_motor.grains_center_of_mass_position * total_csys
             )
+            for y_off in y_offsets:
+                ax.scatter(
+                    grains_cm_position,
+                    y_off,
+                    color="brown",
+                    label="Grains Center of Mass" if y_off == y_offsets[0] else "",
+                    s=8,
+                    zorder=10,
+                )
 
-            chamber = self.rocket.motor.plots._generate_combustion_chamber(
-                translate=(grains_cm_position, 0), label=None
-            )
-            grains = self.rocket.motor.plots._generate_grains(
-                translate=(grains_cm_position, 0)
-            )
+                chamber = base_motor.plots._generate_combustion_chamber(
+                    translate=(grains_cm_position, y_off), label=None
+                )
+                grains = base_motor.plots._generate_grains(
+                    translate=(grains_cm_position, y_off)
+                )
+                if y_off != y_offsets[0]:
+                    for grain in grains:
+                        grain.set_label("_nolegend_")
 
-            motor_patches += [chamber, *grains]
+                motor_patches += [chamber, *grains]
 
         elif isinstance(self.rocket.motor, HybridMotor):
             grains_cm_position = (
@@ -654,7 +731,7 @@ def all(self):
 
         # Rocket draw
         if len(self.rocket.aerodynamic_surfaces) > 0:
-            print("\nRocket Draw")
+            print("\nRocket Drawing")
             print("-" * 40)
             self.draw()
 
diff --git a/rocketpy/prints/aero_surface_prints.py b/rocketpy/prints/aero_surface_prints.py
index 4eb42b08d..cc36f1b01 100644
--- a/rocketpy/prints/aero_surface_prints.py
+++ b/rocketpy/prints/aero_surface_prints.py
@@ -1,6 +1,7 @@
 from abc import ABC, abstractmethod
 
 
+# TODO: the rocketpy/prints/aero_surface_prints.py file could be separated into different, smaller files.
 class _AeroSurfacePrints(ABC):
     def __init__(self, aero_surface):
         self.aero_surface = aero_surface
@@ -77,10 +78,8 @@ def geometry(self):
         print("-------------------------------------")
         print(f"Number of fins: {self.aero_surface.n}")
         print(f"Reference rocket radius: {self.aero_surface.rocket_radius:.3f} m")
-        try:
+        if hasattr(self.aero_surface, "tip_chord"):
             print(f"Tip chord: {self.aero_surface.tip_chord:.3f} m")
-        except AttributeError:
-            pass  # it isn't a trapezoidal fin, just don't worry about tip chord
         print(f"Root chord: {self.aero_surface.root_chord:.3f} m")
         print(f"Span: {self.aero_surface.span:.3f} m")
         print(
@@ -156,9 +155,102 @@ def lift(self):
             "Lift Coefficient derivative (single fin) at Mach 0 and AoA 0: "
             f"{self.aero_surface.clalpha_single_fin(0):.3f}"
         )
+
+    def all(self):
+        """Prints all information of the fin set.
+
+        Returns
+        -------
+        None
+        """
+        self.identity()
+        self.geometry()
+        self.airfoil()
+        self.roll()
+        self.lift()
+
+
+class _FinPrints(_AeroSurfacePrints):
+    def geometry(self):
+        print("Geometric information of the fin set:")
+        print("-------------------------------------")
+        print(f"Reference rocket radius: {self.aero_surface.rocket_radius:.3f} m")
+        if hasattr(self.aero_surface, "tip_chord"):
+            print(f"Tip chord: {self.aero_surface.tip_chord:.3f} m")
+        print(f"Root chord: {self.aero_surface.root_chord:.3f} m")
+        print(f"Span: {self.aero_surface.span:.3f} m")
+        print(
+            f"Cant angle: {self.aero_surface.cant_angle:.3f} ° or "
+            f"{self.aero_surface.cant_angle_rad:.3f} rad"
+        )
+        print(f"Longitudinal section area: {self.aero_surface.Af:.3f} m²")
+        print(f"Aspect ratio: {self.aero_surface.AR:.3f} ")
+        print(f"Gamma_c: {self.aero_surface.gamma_c:.3f} m")
+        print(f"Mean aerodynamic chord: {self.aero_surface.Yma:.3f} m\n")
+
+    def airfoil(self):
+        """Prints out airfoil related information of the fin set.
+
+        Returns
+        -------
+        None
+        """
+        if self.aero_surface.airfoil:
+            print("Airfoil information:")
+            print("--------------------")
+            print(
+                "Number of points defining the lift curve: "
+                f"{len(self.aero_surface.airfoil_cl.x_array)}"
+            )
+            print(
+                "Lift coefficient derivative at Mach 0 and AoA 0: "
+                f"{self.aero_surface.clalpha(0):.5f} 1/rad\n"
+            )
+
+    def roll(self):
+        """Prints out information about roll parameters
+        of the fin set.
+
+        Returns
+        -------
+        None
+        """
+        print("Roll information of the fin set:")
+        print("--------------------------------")
+        print(
+            f"Geometric constant: {self.aero_surface.roll_geometrical_constant:.3f} m"
+        )
+        print(
+            "Damping interference factor: "
+            f"{self.aero_surface.roll_damping_interference_factor:.3f} rad"
+        )
+        print(
+            "Forcing interference factor: "
+            f"{self.aero_surface.roll_forcing_interference_factor:.3f} rad\n"
+        )
+
+    def lift(self):
+        """Prints out information about lift parameters
+        of the fin set.
+
+        Returns
+        -------
+        None
+        """
+        print("Lift information of the fin set:")
+        print("--------------------------------")
         print(
-            "Lift Coefficient derivative (fin set) at Mach 0 and AoA 0: "
-            f"{self.aero_surface.clalpha_multiple_fins(0):.3f}"
+            "Lift interference factor: "
+            f"{self.aero_surface.lift_interference_factor:.3f} m"
+        )
+        print(
+            "Center of Pressure position in local coordinates: "
+            f"({self.aero_surface.cpx:.3f}, {self.aero_surface.cpy:.3f}, "
+            f"{self.aero_surface.cpz:.3f})"
+        )
+        print(
+            "Lift Coefficient derivative (single fin) at Mach 0 and AoA 0: "
+            f"{self.aero_surface.clalpha_single_fin(0):.3f}"
         )
 
     def all(self):
@@ -179,14 +271,26 @@ class _TrapezoidalFinsPrints(_FinsPrints):
     """Class that contains all trapezoidal fins prints."""
 
 
+class _TrapezoidalFinPrints(_FinPrints):
+    """Class that contains all trapezoidal fin prints."""
+
+
 class _EllipticalFinsPrints(_FinsPrints):
     """Class that contains all elliptical fins prints."""
 
 
+class _EllipticalFinPrints(_FinPrints):
+    """Class that contains all elliptical fin prints."""
+
+
 class _FreeFormFinsPrints(_FinsPrints):
     """Class that contains all free form fins prints."""
 
 
+class _FreeFormFinPrints(_FinPrints):
+    """Class that contains all free form fins prints."""
+
+
 class _TailPrints(_AeroSurfacePrints):
     """Class that contains all tail prints."""
 
diff --git a/rocketpy/prints/controller_prints.py b/rocketpy/prints/controller_prints.py
index cb19ec00c..e2690c36a 100644
--- a/rocketpy/prints/controller_prints.py
+++ b/rocketpy/prints/controller_prints.py
@@ -41,7 +41,10 @@ def controller_function(self):
             print(
                 "Controller function: " + self.controller.controller_function.__name__
             )
-        print(f"Controller refresh rate: {self.controller.sampling_rate:.3f} Hz")
+        if self.controller.is_continuous:
+            print("Controller refresh rate: continuous (every solver step)")
+        else:
+            print(f"Controller refresh rate: {self.controller.sampling_rate:.3f} Hz")
 
     def interactive_objects(self):
         """Prints interactive objects."""
diff --git a/rocketpy/prints/environment_prints.py b/rocketpy/prints/environment_prints.py
index a770741c5..ba01d7d82 100644
--- a/rocketpy/prints/environment_prints.py
+++ b/rocketpy/prints/environment_prints.py
@@ -34,8 +34,8 @@ def gravity_details(self):
         """
         elevation = self.environment.elevation
         max_expected_height = self.environment.max_expected_height
-        surface_gravity = self.environment.gravity([elevation])
-        ceiling_gravity = self.environment.gravity([max_expected_height])
+        surface_gravity = self.environment.gravity(elevation)
+        ceiling_gravity = self.environment.gravity(max_expected_height)
         print("\nGravity Details\n")
         print(f"Acceleration of gravity at surface level: {surface_gravity:9.4f} m/s²")
         print(
diff --git a/rocketpy/rocket/__init__.py b/rocketpy/rocket/__init__.py
index 463cbe3b3..afb7f0bb6 100644
--- a/rocketpy/rocket/__init__.py
+++ b/rocketpy/rocket/__init__.py
@@ -2,14 +2,18 @@
 from rocketpy.rocket.aero_surface import (
     AeroSurface,
     AirBrakes,
+    EllipticalFin,
     EllipticalFins,
+    Fin,
     Fins,
+    FreeFormFin,
     FreeFormFins,
     GenericSurface,
     LinearGenericSurface,
     NoseCone,
     RailButtons,
     Tail,
+    TrapezoidalFin,
     TrapezoidalFins,
 )
 from rocketpy.rocket.components import Components
diff --git a/rocketpy/rocket/aero_surface/__init__.py b/rocketpy/rocket/aero_surface/__init__.py
index ad784f8d0..7634d3500 100644
--- a/rocketpy/rocket/aero_surface/__init__.py
+++ b/rocketpy/rocket/aero_surface/__init__.py
@@ -1,9 +1,13 @@
 from rocketpy.rocket.aero_surface.aero_surface import AeroSurface
 from rocketpy.rocket.aero_surface.air_brakes import AirBrakes
 from rocketpy.rocket.aero_surface.fins import (
+    EllipticalFin,
     EllipticalFins,
+    Fin,
     Fins,
+    FreeFormFin,
     FreeFormFins,
+    TrapezoidalFin,
     TrapezoidalFins,
 )
 from rocketpy.rocket.aero_surface.generic_surface import GenericSurface
diff --git a/rocketpy/rocket/aero_surface/aero_surface.py b/rocketpy/rocket/aero_surface/aero_surface.py
index bf6a72176..e7ea2bf35 100644
--- a/rocketpy/rocket/aero_surface/aero_surface.py
+++ b/rocketpy/rocket/aero_surface/aero_surface.py
@@ -2,6 +2,8 @@
 
 import numpy as np
 
+from rocketpy.mathutils.vector_matrix import Matrix
+
 
 class AeroSurface(ABC):
     """Abstract class used to define aerodynamic surfaces."""
@@ -15,6 +17,14 @@ def __init__(self, name, reference_area, reference_length):
         self.cpy = 0
         self.cpz = 0
 
+        self._rotation_surface_to_body = Matrix(
+            [
+                [-1, 0, 0],
+                [0, 1, 0],
+                [0, 0, -1],
+            ]
+        )
+
     @staticmethod
     def _beta(mach):
         """Defines a parameter that is often used in aerodynamic
@@ -130,7 +140,7 @@ def compute_forces_and_moments(
         R1, R2, R3, M1, M2, M3 = 0, 0, 0, 0, 0, 0
         cpz = cp[2]
         stream_vx, stream_vy, stream_vz = stream_velocity
-        if stream_vx**2 + stream_vy**2 != 0:  # TODO: maybe try/except
+        if stream_vx**2 + stream_vy**2 != 0:
             # Normalize component stream velocity in body frame; clip to [-1, 1]
             # to guard arccos against floating-point values just outside the domain.
             stream_vzn = np.clip(stream_vz / stream_speed, -1.0, 1.0)
diff --git a/rocketpy/rocket/aero_surface/fins/__init__.py b/rocketpy/rocket/aero_surface/fins/__init__.py
index 941aa5465..dd678c625 100644
--- a/rocketpy/rocket/aero_surface/fins/__init__.py
+++ b/rocketpy/rocket/aero_surface/fins/__init__.py
@@ -1,4 +1,8 @@
+from rocketpy.rocket.aero_surface.fins.elliptical_fin import EllipticalFin
 from rocketpy.rocket.aero_surface.fins.elliptical_fins import EllipticalFins
+from rocketpy.rocket.aero_surface.fins.fin import Fin
 from rocketpy.rocket.aero_surface.fins.fins import Fins
+from rocketpy.rocket.aero_surface.fins.free_form_fin import FreeFormFin
 from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins
+from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin
 from rocketpy.rocket.aero_surface.fins.trapezoidal_fins import TrapezoidalFins
diff --git a/rocketpy/rocket/aero_surface/fins/_base_fin.py b/rocketpy/rocket/aero_surface/fins/_base_fin.py
new file mode 100644
index 000000000..f6b09f797
--- /dev/null
+++ b/rocketpy/rocket/aero_surface/fins/_base_fin.py
@@ -0,0 +1,344 @@
+import math
+from abc import abstractmethod
+
+import numpy as np
+
+from rocketpy.mathutils.function import Function
+
+from ..aero_surface import AeroSurface
+
+
+class _BaseFin(AeroSurface):
+    """
+    Base class for fins, shared by both Fin and Fins classes.
+    Inherits from AeroSurface.
+
+    Handles shared initialization logic and common properties.
+    """
+
+    def __init__(
+        self, name, rocket_radius, root_chord, span, airfoil=None, cant_angle=0
+    ):
+        """
+        Initialize the base fin class.
+
+        Parameters
+        ----------
+        name : str
+            Name of the fin or fin set.
+        rocket_radius : float
+            Rocket radius in meters.
+        root_chord : float
+            Root chord of the fin in meters.
+        span : float
+            Span of the fin in meters.
+        airfoil : tuple, optional
+            Tuple containing airfoil data and unit ('degrees' or 'radians').
+        cant_angle : float, optional
+            Cant angle in degrees.
+        """
+        self.name = name
+        self._rocket_radius = rocket_radius
+        self._root_chord = root_chord
+        self._span = span
+        self._airfoil = airfoil
+        self._cant_angle = cant_angle
+        self._cant_angle_rad = math.radians(cant_angle)
+        self.geometry = None
+
+        self.reference_area = np.pi * rocket_radius**2
+
+        super().__init__(name, self.reference_area, self.rocket_diameter)
+
+    def _update_reference_quantities(self):
+        """Update quantities that depend on rocket radius."""
+        self.reference_area = np.pi * self._rocket_radius**2
+        self.reference_length = self.rocket_diameter
+
+    def _update_geometry_chain(self):
+        """Update geometry-dependent quantities in dependency order."""
+        self.evaluate_geometrical_parameters()
+        self.evaluate_center_of_pressure()
+        self.evaluate_lift_coefficient()
+        self.evaluate_roll_parameters()
+
+    @property
+    def rocket_radius(self):
+        """Rocket radius in meters.
+
+        Returns
+        -------
+        float
+            Rocket radius in meters.
+        """
+        return self._rocket_radius
+
+    @rocket_radius.setter
+    def rocket_radius(self, value):
+        """Set rocket radius and update dependent properties.
+
+        Parameters
+        ----------
+        value : float
+            Rocket radius in meters.
+        """
+        self._rocket_radius = value
+        self._update_reference_quantities()
+        self._update_geometry_chain()
+
+    @property
+    def rocket_diameter(self):
+        """Reference rocket diameter in meters."""
+        return 2 * self._rocket_radius
+
+    @rocket_diameter.setter
+    def rocket_diameter(self, value):
+        """Set reference rocket diameter in meters."""
+        self.rocket_radius = value / 2
+
+    @property
+    def diameter(self):
+        """Backward-compatible alias for :attr:`rocket_diameter`."""
+        return self.rocket_diameter
+
+    @diameter.setter
+    def diameter(self, value):
+        """Backward-compatible alias setter for :attr:`rocket_diameter`."""
+        self.rocket_diameter = value
+
+    @property
+    def d(self):
+        """Backward-compatible alias for :attr:`rocket_diameter`."""
+        return self.rocket_diameter
+
+    @d.setter
+    def d(self, value):
+        """Backward-compatible alias setter for :attr:`rocket_diameter`."""
+        self.rocket_diameter = value
+
+    @property
+    def ref_area(self):
+        """Backward-compatible alias for :attr:`reference_area`."""
+        return self.reference_area
+
+    @ref_area.setter
+    def ref_area(self, value):
+        """Backward-compatible alias setter for :attr:`reference_area`."""
+        self.reference_area = value
+
+    @property
+    def root_chord(self):
+        """Root chord length in meters.
+
+        Returns
+        -------
+        float
+            Root chord length in meters.
+        """
+        return self._root_chord
+
+    @root_chord.setter
+    def root_chord(self, value):
+        """Set root chord and update dependent properties.
+
+        Parameters
+        ----------
+        value : float
+            Root chord length in meters.
+        """
+        self._root_chord = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
+
+    @property
+    def span(self):
+        """Fin span in meters.
+
+        Returns
+        -------
+        float
+            Fin span in meters.
+        """
+        return self._span
+
+    @span.setter
+    def span(self, value):
+        """Set fin span and update dependent properties.
+
+        Parameters
+        ----------
+        value : float
+            Fin span in meters.
+        """
+        self._span = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
+
+    @property
+    def cant_angle(self):
+        """Cant angle in degrees.
+
+        Returns
+        -------
+        float
+            Cant angle in degrees.
+        """
+        return self._cant_angle
+
+    @cant_angle.setter
+    def cant_angle(self, value):
+        """Set cant angle and update radian representation.
+
+        Parameters
+        ----------
+        value : float
+            Cant angle in degrees.
+        """
+        self._cant_angle = value
+        self.cant_angle_rad = math.radians(value)
+
+    @property
+    def cant_angle_rad(self):
+        """Cant angle in radians.
+
+        Returns
+        -------
+        float
+            Cant angle in radians.
+        """
+        return self._cant_angle_rad
+
+    @cant_angle_rad.setter
+    def cant_angle_rad(self, value):
+        """Set cant angle in radians and update dependent properties.
+
+        Parameters
+        ----------
+        value : float
+            Cant angle in radians.
+        """
+        self._cant_angle_rad = value
+        self._update_geometry_chain()
+
+    @property
+    def airfoil(self):
+        """Airfoil data for the fin.
+
+        Returns
+        -------
+        tuple or None
+            Tuple containing airfoil data and unit ('degrees' or 'radians'),
+            or None if using planar fin.
+        """
+        return self._airfoil
+
+    @airfoil.setter
+    def airfoil(self, value):
+        """Set airfoil data and update dependent properties.
+
+        Parameters
+        ----------
+        value : tuple or None
+            Tuple containing airfoil data and unit ('degrees' or 'radians'),
+            or None for planar fin.
+        """
+        self._airfoil = value
+        self._update_geometry_chain()
+
+    def info(self):
+        """Print fin geometry and lift information."""
+        self.prints.geometry()
+        self.prints.lift()
+
+    def all_info(self):
+        """Print all available fin information and show all fin plots."""
+        self.prints.all()
+        self.plots.all()
+
+    def evaluate_single_fin_lift_coefficient(self):
+        """Evaluate the lift coefficient derivative for a single fin.
+
+        Computes the lift coefficient derivative (clalpha) considering the
+        fin's geometry, airfoil characteristics (if provided), and Mach number
+        effects using Prandtl-Glauert compressibility correction and
+        Diederich's planform correlation.
+
+        Sets the `clalpha_single_fin` attribute as a Function of Mach number.
+        """
+        if not self.airfoil:
+            # Defines clalpha2D as 2*pi for planar fins
+            clalpha2D_incompressible = 2 * np.pi
+        else:
+            # Defines clalpha2D as the derivative of the lift coefficient curve
+            # for the specific airfoil
+            self.airfoil_cl = Function(
+                self.airfoil[0],
+                title="Airfoil lift coefficient",
+                interpolation="linear",
+            )
+
+            # Differentiating at alpha = 0 to get cl_alpha
+            clalpha2D_incompressible = self.airfoil_cl.differentiate(x=1e-3, dx=1e-3)
+
+            # Convert to radians if needed
+            if self.airfoil[1] == "degrees":
+                clalpha2D_incompressible *= 180 / np.pi
+
+        # Correcting for compressible flow (apply Prandtl-Glauert correction)
+        clalpha2D = Function(lambda mach: clalpha2D_incompressible / self._beta(mach))
+
+        # Diederich's Planform Correlation Parameter
+        planform_correlation_parameter = (
+            2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c))
+        )
+
+        # Lift coefficient derivative for a single fin
+        def lift_source(mach):
+            return (
+                clalpha2D(mach)
+                * planform_correlation_parameter(mach)
+                * (self.Af / self.reference_area)
+                * np.cos(self.gamma_c)
+            ) / (
+                2
+                + planform_correlation_parameter(mach)
+                * np.sqrt(1 + (2 / planform_correlation_parameter(mach)) ** 2)
+            )
+
+        self.clalpha_single_fin = Function(
+            lift_source,
+            "Mach",
+            "Lift coefficient derivative for a single fin",
+        )
+
+    @abstractmethod
+    def evaluate_lift_coefficient(self):
+        """Evaluate the lift coefficient for the fin."""
+
+    @abstractmethod
+    def evaluate_roll_parameters(self):
+        """Evaluate roll-related parameters for the fin."""
+
+    @abstractmethod
+    def evaluate_center_of_pressure(self):
+        """Evaluate the center of pressure for the fin."""
+
+    def evaluate_geometrical_parameters(self):
+        """Evaluate geometric parameters of the fin.
+
+        This method delegates to the configured geometry strategy.
+        """
+        geometry_data = self.geometry.evaluate_geometrical_parameters()
+        for key, value in geometry_data.items():
+            setattr(self, key, value)
+
+    def evaluate_shape(self):
+        """Evaluate the shape representation of the fin.
+
+        This method delegates to the configured geometry strategy.
+        """
+        self.shape_vec = self.geometry.evaluate_shape()
+
+    @abstractmethod
+    def draw(self):
+        """Draw or render the fin."""
diff --git a/rocketpy/rocket/aero_surface/fins/_geometry.py b/rocketpy/rocket/aero_surface/fins/_geometry.py
new file mode 100644
index 000000000..bd9a0465a
--- /dev/null
+++ b/rocketpy/rocket/aero_surface/fins/_geometry.py
@@ -0,0 +1,564 @@
+"""Geometry strategy classes for fin aerodynamic surfaces."""
+
+import warnings
+from abc import ABC, abstractmethod
+
+import numpy as np
+
+
+class _FinGeometry(ABC):
+    """Base geometry strategy for fin shapes."""
+
+    def __init__(self, owner):
+        self.owner = owner
+
+    @abstractmethod
+    def evaluate_geometrical_parameters(self):
+        """Evaluate and return geometry-dependent aerodynamic parameters."""
+
+    @abstractmethod
+    def evaluate_shape(self):
+        """Evaluate and return the shape vector used by plotting and outputs."""
+
+    def get_data(self, include_outputs=False):
+        """Return geometry-specific serialization data."""
+        _ = include_outputs
+        return {}
+
+
+class _TrapezoidalGeometry(_FinGeometry):
+    """Geometry strategy for trapezoidal fins."""
+
+    def __init__(
+        self,
+        owner,
+        tip_chord,
+        sweep_length=None,
+        sweep_angle=None,
+    ):
+        super().__init__(owner)
+        if sweep_length is not None and sweep_angle is not None:
+            raise ValueError("Cannot use sweep_length and sweep_angle together")
+
+        if sweep_angle is not None:
+            sweep_length = np.tan(np.radians(sweep_angle)) * owner.span
+        elif sweep_length is None:
+            sweep_length = owner.root_chord - tip_chord
+
+        self._tip_chord = tip_chord
+        self._sweep_length = sweep_length
+        self._sweep_angle = sweep_angle
+
+    @property
+    def tip_chord(self):
+        return self._tip_chord
+
+    @tip_chord.setter
+    def tip_chord(self, value):
+        self._tip_chord = value
+
+    @property
+    def sweep_length(self):
+        return self._sweep_length
+
+    @sweep_length.setter
+    def sweep_length(self, value):
+        self._sweep_length = value
+
+    @property
+    def sweep_angle(self):
+        return self._sweep_angle
+
+    @sweep_angle.setter
+    def sweep_angle(self, value):
+        self._sweep_angle = value
+        self._sweep_length = np.tan(np.radians(value)) * self.owner.span
+
+    def evaluate_geometrical_parameters(self):
+        """Calculate trapezoidal fin geometric parameters."""
+        # pylint: disable=invalid-name
+        owner = self.owner
+        Yr = owner.root_chord + self.tip_chord
+        Af = Yr * owner.span / 2
+        AR = 2 * owner.span**2 / Af
+        gamma_c = np.arctan(
+            (self.sweep_length + 0.5 * self.tip_chord - 0.5 * owner.root_chord)
+            / owner.span
+        )
+        Yma = (owner.span / 3) * (owner.root_chord + 2 * self.tip_chord) / Yr
+
+        tau = (owner.span + owner.rocket_radius) / owner.rocket_radius
+        lift_interference_factor = 1 + 1 / tau
+        lambda_ = self.tip_chord / owner.root_chord
+
+        roll_geometrical_constant = (
+            (owner.root_chord + 3 * self.tip_chord) * owner.span**3
+            + 4
+            * (owner.root_chord + 2 * self.tip_chord)
+            * owner.rocket_radius
+            * owner.span**2
+            + 6
+            * (owner.root_chord + self.tip_chord)
+            * owner.span
+            * owner.rocket_radius**2
+        ) / 12
+        roll_damping_interference_factor = 1 + (
+            ((tau - lambda_) / tau) - ((1 - lambda_) / (tau - 1)) * np.log(tau)
+        ) / (
+            ((tau + 1) * (tau - lambda_)) / 2
+            - ((1 - lambda_) * (tau**3 - 1)) / (3 * (tau - 1))
+        )
+        roll_forcing_interference_factor = (1 / np.pi**2) * (
+            (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2)
+            + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+            - (2 * np.pi * (tau + 1)) / (tau * (tau - 1))
+            + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+            * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2
+            - (4 * (tau + 1))
+            / (tau * (tau - 1))
+            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+            + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau))
+        )
+
+        self.Yr = Yr
+        self.Af = Af
+        self.AR = AR
+        self.gamma_c = gamma_c
+        self.Yma = Yma
+        self.roll_geometrical_constant = roll_geometrical_constant
+        self.tau = tau
+        self.lift_interference_factor = lift_interference_factor
+        self.λ = lambda_  # pylint: disable=non-ascii-name
+        self.roll_damping_interference_factor = roll_damping_interference_factor
+        self.roll_forcing_interference_factor = roll_forcing_interference_factor
+
+        return {
+            "Yr": Yr,
+            "Af": Af,
+            "AR": AR,
+            "gamma_c": gamma_c,
+            "Yma": Yma,
+            "roll_geometrical_constant": roll_geometrical_constant,
+            "tau": tau,
+            "lift_interference_factor": lift_interference_factor,
+            "λ": lambda_,
+            "roll_damping_interference_factor": roll_damping_interference_factor,
+            "roll_forcing_interference_factor": roll_forcing_interference_factor,
+        }
+
+    def evaluate_shape(self):
+        owner = self.owner
+        if self.sweep_length:
+            points = [
+                (0, 0),
+                (self.sweep_length, owner.span),
+                (self.sweep_length + self.tip_chord, owner.span),
+                (owner.root_chord, 0),
+            ]
+        else:
+            points = [
+                (0, 0),
+                (owner.root_chord - self.tip_chord, owner.span),
+                (owner.root_chord, owner.span),
+                (owner.root_chord, 0),
+            ]
+
+        x_array, y_array = zip(*points)
+        shape_vec = [np.array(x_array), np.array(y_array)]
+        self.shape_vec = shape_vec
+        return shape_vec
+
+    def get_data(self, include_outputs=False):
+        data = {
+            "tip_chord": self.tip_chord,
+            "sweep_length": self.sweep_length,
+            "sweep_angle": self.sweep_angle,
+        }
+        if include_outputs:
+            data.update(
+                {
+                    "shape_vec": getattr(self, "shape_vec", None),
+                    "Af": getattr(self, "Af", None),
+                    "AR": getattr(self, "AR", None),
+                    "gamma_c": getattr(self, "gamma_c", None),
+                    "Yma": getattr(self, "Yma", None),
+                    "roll_geometrical_constant": getattr(
+                        self, "roll_geometrical_constant", None
+                    ),
+                    "tau": getattr(self, "tau", None),
+                    "lift_interference_factor": getattr(
+                        self, "lift_interference_factor", None
+                    ),
+                    "roll_damping_interference_factor": getattr(
+                        self, "roll_damping_interference_factor", None
+                    ),
+                    "roll_forcing_interference_factor": getattr(
+                        self, "roll_forcing_interference_factor", None
+                    ),
+                }
+            )
+        return data
+
+
+class _EllipticalGeometry(_FinGeometry):
+    """Geometry strategy for elliptical fins."""
+
+    def evaluate_geometrical_parameters(self):
+        """Calculate elliptical fin geometric parameters."""
+        owner = self.owner
+
+        # pylint: disable=invalid-name
+        Af = (np.pi * owner.root_chord / 2 * owner.span) / 2
+        gamma_c = 0
+        AR = 2 * owner.span**2 / Af
+        Yma = owner.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64)
+        roll_geometrical_constant = (
+            owner.root_chord
+            * owner.span
+            * (
+                3 * np.pi * owner.span**2
+                + 32 * owner.rocket_radius * owner.span
+                + 12 * np.pi * owner.rocket_radius**2
+            )
+            / 48
+        )
+
+        tau = (owner.span + owner.rocket_radius) / owner.rocket_radius
+        lift_interference_factor = 1 + 1 / tau
+        if owner.span > owner.rocket_radius:
+            roll_damping_interference_factor = 1 + (
+                owner.rocket_radius**2
+                * (
+                    2
+                    * owner.rocket_radius**2
+                    * np.sqrt(owner.span**2 - owner.rocket_radius**2)
+                    * np.log(
+                        (
+                            2
+                            * owner.span
+                            * np.sqrt(owner.span**2 - owner.rocket_radius**2)
+                            + 2 * owner.span**2
+                        )
+                        / owner.rocket_radius
+                    )
+                    - 2
+                    * owner.rocket_radius**2
+                    * np.sqrt(owner.span**2 - owner.rocket_radius**2)
+                    * np.log(2 * owner.span)
+                    + 2 * owner.span**3
+                    - np.pi * owner.rocket_radius * owner.span**2
+                    - 2 * owner.rocket_radius**2 * owner.span
+                    + np.pi * owner.rocket_radius**3
+                )
+            ) / (
+                2
+                * owner.span**2
+                * (owner.span / 3 + np.pi * owner.rocket_radius / 4)
+                * (owner.span**2 - owner.rocket_radius**2)
+            )
+        elif owner.span < owner.rocket_radius:
+            roll_damping_interference_factor = 1 - (
+                owner.rocket_radius**2
+                * (
+                    2 * owner.span**3
+                    - np.pi * owner.span**2 * owner.rocket_radius
+                    - 2 * owner.span * owner.rocket_radius**2
+                    + np.pi * owner.rocket_radius**3
+                    + 2
+                    * owner.rocket_radius**2
+                    * np.sqrt(-(owner.span**2) + owner.rocket_radius**2)
+                    * np.arctan(
+                        owner.span / np.sqrt(-(owner.span**2) + owner.rocket_radius**2)
+                    )
+                    - np.pi
+                    * owner.rocket_radius**2
+                    * np.sqrt(-(owner.span**2) + owner.rocket_radius**2)
+                )
+            ) / (
+                2
+                * owner.span
+                * (-(owner.span**2) + owner.rocket_radius**2)
+                * (owner.span**2 / 3 + np.pi * owner.span * owner.rocket_radius / 4)
+            )
+        else:
+            roll_damping_interference_factor = (28 - 3 * np.pi) / (4 + 3 * np.pi)
+
+        roll_forcing_interference_factor = (1 / np.pi**2) * (
+            (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2)
+            + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+            - (2 * np.pi * (tau + 1)) / (tau * (tau - 1))
+            + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+            * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2
+            - (4 * (tau + 1))
+            / (tau * (tau - 1))
+            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+            + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau))
+        )
+
+        self.Af = Af
+        self.AR = AR
+        self.gamma_c = gamma_c
+        self.Yma = Yma
+        self.roll_geometrical_constant = roll_geometrical_constant
+        self.tau = tau
+        self.lift_interference_factor = lift_interference_factor
+        self.roll_damping_interference_factor = roll_damping_interference_factor
+        self.roll_forcing_interference_factor = roll_forcing_interference_factor
+
+        return {
+            "Af": Af,
+            "AR": AR,
+            "gamma_c": gamma_c,
+            "Yma": Yma,
+            "roll_geometrical_constant": roll_geometrical_constant,
+            "tau": tau,
+            "lift_interference_factor": lift_interference_factor,
+            "roll_damping_interference_factor": roll_damping_interference_factor,
+            "roll_forcing_interference_factor": roll_forcing_interference_factor,
+        }
+
+    def evaluate_shape(self):
+        owner = self.owner
+        angles = np.arange(0, 180, 5)
+        x_array = owner.root_chord / 2 + owner.root_chord / 2 * np.cos(
+            np.radians(angles)
+        )
+        y_array = owner.span * np.sin(np.radians(angles))
+        shape_vec = [x_array, y_array]
+        self.shape_vec = shape_vec
+        return shape_vec
+
+    def get_data(self, include_outputs=False):
+        if not include_outputs:
+            return {}
+        return {
+            "Af": getattr(self, "Af", None),
+            "AR": getattr(self, "AR", None),
+            "gamma_c": getattr(self, "gamma_c", None),
+            "Yma": getattr(self, "Yma", None),
+            "roll_geometrical_constant": getattr(
+                self, "roll_geometrical_constant", None
+            ),
+            "tau": getattr(self, "tau", None),
+            "lift_interference_factor": getattr(self, "lift_interference_factor", None),
+            "roll_damping_interference_factor": getattr(
+                self, "roll_damping_interference_factor", None
+            ),
+            "roll_forcing_interference_factor": getattr(
+                self, "roll_forcing_interference_factor", None
+            ),
+        }
+
+
+class _FreeFormGeometry(_FinGeometry):
+    """Geometry strategy for free-form fins."""
+
+    def __init__(self, owner, shape_points):
+        super().__init__(owner)
+        self.shape_points = shape_points
+
+    @staticmethod
+    def infer_dimensions(shape_points):
+        """Infer root chord and span from free-form points."""
+        down = False
+        for i in range(1, len(shape_points)):
+            if shape_points[i][1] > shape_points[i - 1][1] and down:
+                warnings.warn(
+                    "Jagged fin shape detected. This may cause small "
+                    "inaccuracies center of pressure and pitch moment "
+                    "calculations."
+                )
+                break
+            if shape_points[i][1] < shape_points[i - 1][1]:
+                down = True
+
+        root_chord = abs(shape_points[0][0] - shape_points[-1][0])
+        ys = [point[1] for point in shape_points]
+        span = max(ys) - min(ys)
+        return root_chord, span
+
+    def evaluate_geometrical_parameters(
+        self,
+    ):  # pylint: disable=too-many-statements,too-many-locals,invalid-name
+        """Calculate free-form fin geometric parameters."""
+        owner = self.owner
+
+        Af = 0
+        for i in range(len(self.shape_points) - 1):
+            x1, y1 = self.shape_points[i]
+            x2, y2 = self.shape_points[i + 1]
+            Af += (y1 + y2) * (x1 - x2)
+        Af = abs(Af) / 2
+        if Af < 1e-6:
+            raise ValueError("Fin area is too small. Check the shape_points.")
+
+        AR = 2 * owner.span**2 / Af
+        tau = (owner.span + owner.rocket_radius) / owner.rocket_radius
+        lift_interference_factor = 1 + 1 / tau
+
+        roll_forcing_interference_factor = (1 / np.pi**2) * (
+            (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2)
+            + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+            - (2 * np.pi * (tau + 1)) / (tau * (tau - 1))
+            + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+            * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2
+            - (4 * (tau + 1))
+            / (tau * (tau - 1))
+            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+            + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau))
+        )
+
+        points_per_line = 40
+        chord_lead = np.ones(points_per_line) * np.inf
+        chord_trail = np.ones(points_per_line) * -np.inf
+        chord_length = np.zeros(points_per_line)
+
+        for p in range(1, len(self.shape_points)):
+            x1, y1 = self.shape_points[p - 1]
+            x2, y2 = self.shape_points[p]
+
+            prev_idx = int(y1 / owner.span * (points_per_line - 1))
+            curr_idx = int(y2 / owner.span * (points_per_line - 1))
+            prev_idx = np.clip(prev_idx, 0, points_per_line - 1)
+            curr_idx = np.clip(curr_idx, 0, points_per_line - 1)
+
+            if prev_idx > curr_idx:
+                prev_idx, curr_idx = curr_idx, prev_idx
+
+            for i in range(prev_idx, curr_idx + 1):
+                y = i * owner.span / (points_per_line - 1)
+                if y1 != y2:
+                    x = np.clip(
+                        (y - y2) / (y1 - y2) * x1 + (y1 - y) / (y1 - y2) * x2,
+                        min(x1, x2),
+                        max(x1, x2),
+                    )
+                else:
+                    x = x1
+
+                chord_lead[i] = min(chord_lead[i], x)
+                chord_trail[i] = max(chord_trail[i], x)
+
+                if y1 < y2:
+                    chord_length[i] -= x
+                else:
+                    chord_length[i] += x
+
+        invalid_lead = np.isnan(chord_lead) | np.isinf(chord_lead)
+        invalid_trail = np.isnan(chord_trail) | np.isinf(chord_trail)
+        chord_lead[invalid_lead | invalid_trail] = 0
+        chord_trail[invalid_lead | invalid_trail] = 0
+
+        chord_length[chord_length < 0] = 0
+        chord_length[np.isnan(chord_length)] = 0
+        max_chord = chord_trail - chord_lead
+        chord_length = np.minimum(chord_length, max_chord)
+
+        radius = owner.rocket_radius
+        total_area = 0
+        mac_length = 0
+        mac_lead = 0
+        mac_span = 0
+        cos_gamma_sum = 0
+        roll_geometrical_constant = 0
+        roll_damping_numerator = 0
+        roll_damping_denominator = 0
+
+        dy = owner.span / (points_per_line - 1)
+        for i in range(points_per_line):
+            chord = chord_trail[i] - chord_lead[i]
+            y = i * dy
+
+            mac_length += chord * chord
+            mac_span += y * chord
+            mac_lead += chord_lead[i] * chord
+            total_area += chord
+            roll_geometrical_constant += chord_length[i] * (radius + y) ** 2
+            roll_damping_numerator += radius**3 * chord / (radius + y) ** 2
+            roll_damping_denominator += (radius + y) * chord
+
+            if i > 0:
+                dx = (chord_trail[i] + chord_lead[i]) / 2 - (
+                    chord_trail[i - 1] + chord_lead[i - 1]
+                ) / 2
+                cos_gamma_sum += dy / np.hypot(dx, dy)
+
+        mac_length *= dy
+        mac_span *= dy
+        mac_lead *= dy
+        total_area *= dy
+        roll_geometrical_constant *= dy
+        roll_damping_numerator *= dy
+        roll_damping_denominator *= dy
+
+        mac_length /= total_area
+        mac_span /= total_area
+        mac_lead /= total_area
+        cos_gamma = cos_gamma_sum / (points_per_line - 1)
+
+        gamma_c = np.arccos(cos_gamma)
+
+        self.Af = Af
+        self.AR = AR
+        self.gamma_c = gamma_c
+        self.Yma = mac_span
+        self.mac_length = mac_length
+        self.mac_lead = mac_lead
+        self.tau = tau
+        self.roll_geometrical_constant = roll_geometrical_constant
+        self.lift_interference_factor = lift_interference_factor
+        self.roll_forcing_interference_factor = roll_forcing_interference_factor
+        self.roll_damping_interference_factor = 1 + (
+            roll_damping_numerator / roll_damping_denominator
+        )
+
+        return {
+            "Af": Af,
+            "AR": AR,
+            "gamma_c": gamma_c,
+            "Yma": mac_span,
+            "mac_length": mac_length,
+            "mac_lead": mac_lead,
+            "tau": tau,
+            "roll_geometrical_constant": roll_geometrical_constant,
+            "lift_interference_factor": lift_interference_factor,
+            "roll_forcing_interference_factor": roll_forcing_interference_factor,
+            "roll_damping_interference_factor": self.roll_damping_interference_factor,
+        }
+
+    def evaluate_shape(self):
+        x_array, y_array = zip(*self.shape_points)
+        shape_vec = [np.array(x_array), np.array(y_array)]
+        self.shape_vec = shape_vec
+        return shape_vec
+
+    def get_data(self, include_outputs=False):
+        data = {"shape_points": self.shape_points}
+        if include_outputs:
+            data.update(
+                {
+                    "Af": getattr(self, "Af", None),
+                    "AR": getattr(self, "AR", None),
+                    "gamma_c": getattr(self, "gamma_c", None),
+                    "Yma": getattr(self, "Yma", None),
+                    "mac_length": getattr(self, "mac_length", None),
+                    "mac_lead": getattr(self, "mac_lead", None),
+                    "roll_geometrical_constant": getattr(
+                        self, "roll_geometrical_constant", None
+                    ),
+                    "tau": getattr(self, "tau", None),
+                    "lift_interference_factor": getattr(
+                        self, "lift_interference_factor", None
+                    ),
+                    "roll_forcing_interference_factor": getattr(
+                        self, "roll_forcing_interference_factor", None
+                    ),
+                    "roll_damping_interference_factor": getattr(
+                        self, "roll_damping_interference_factor", None
+                    ),
+                }
+            )
+        return data
diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py
new file mode 100644
index 000000000..66345f2cb
--- /dev/null
+++ b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py
@@ -0,0 +1,196 @@
+from rocketpy.plots.aero_surface_plots import _EllipticalFinPlots
+from rocketpy.prints.aero_surface_prints import _EllipticalFinPrints
+from rocketpy.rocket.aero_surface.fins._geometry import _EllipticalGeometry
+from rocketpy.rocket.aero_surface.fins.fin import Fin
+
+
+class EllipticalFin(Fin):
+    """Class that defines and holds information for an elliptical fin set.
+
+    This class inherits from the Fin class.
+
+    Note
+    ----
+    Local coordinate system:
+        - Origin located at the top of the root chord.
+        - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom).
+        - Y axis perpendicular to the Z axis, in the span direction, positive upwards.
+        - X axis completes the right-handed coordinate system.
+
+    See Also
+    --------
+    Fin
+
+    Attributes
+    ----------
+    EllipticalFin.rocket_radius : float
+        The reference rocket radius used for lift coefficient normalization, in
+        meters.
+    EllipticalFin.airfoil : tuple
+        Tuple of two items. First is the airfoil lift curve.
+        Second is the unit of the curve (radians or degrees)
+    EllipticalFin.cant_angle : float
+        Fins cant angle with respect to the rocket centerline, in degrees.
+    EllipticalFin.cant_angle_rad : float
+        Fins cant angle with respect to the rocket centerline, in radians.
+    EllipticalFin.root_chord : float
+        Fin root chord in meters.
+    EllipticalFin.span : float
+        Fin span in meters.
+    EllipticalFin.name : string
+        Name of fin set.
+    EllipticalFin.sweep_length : float
+        Fins sweep length in meters. By sweep length, understand the axial
+        distance between the fin root leading edge and the fin tip leading edge
+        measured parallel to the rocket centerline.
+    EllipticalFin.sweep_angle : float
+        Fins sweep angle with respect to the rocket centerline. Must
+        be given in degrees.
+    EllipticalFin.rocket_diameter : float
+        Reference diameter of the rocket, in meters.
+    EllipticalFin.reference_area : float
+        Reference area of the rocket.
+    EllipticalFin.Af : float
+        Area of the longitudinal section of each fin in the set.
+    EllipticalFin.AR : float
+        Aspect ratio of the fin.
+    EllipticalFin.gamma_c : float
+        Fin mid-chord sweep angle.
+    EllipticalFin.Yma : float
+        Span wise position of the mean aerodynamic chord.
+    EllipticalFin.roll_geometrical_constant : float
+        Geometrical constant used in roll calculations.
+    EllipticalFin.tau : float
+        Geometrical relation used to simplify lift and roll calculations.
+    EllipticalFin.lift_interference_factor : float
+        Factor of Fin-Body interference in the lift coefficient.
+    EllipticalFin.cp : tuple
+        Tuple with the x, y and z local coordinates of the fin set center of
+        pressure. Has units of length and is given in meters.
+    EllipticalFin.cpx : float
+        Fin set local center of pressure x coordinate. Has units of length and
+        is given in meters.
+    EllipticalFin.cpy : float
+        Fin set local center of pressure y coordinate. Has units of length and
+        is given in meters.
+    EllipticalFin.cpz : float
+        Fin set local center of pressure z coordinate. Has units of length and
+        is given in meters.
+    EllipticalFin.cl : Function
+        Function which defines the lift coefficient as a function of the angle
+        of attack and the Mach number. Takes as input the angle of attack in
+        radians and the Mach number. Returns the lift coefficient.
+    EllipticalFin.clalpha : float
+        Lift coefficient slope. Has units of 1/rad.
+    """
+
+    def __init__(
+        self,
+        angular_position,
+        root_chord,
+        span,
+        rocket_radius,
+        cant_angle=0,
+        airfoil=None,
+        name="Elliptical Fin",
+    ):
+        """Initialize EllipticalFin class.
+
+        Parameters
+        ----------
+        angular_position : float
+            Angular position of the fin in degrees measured as the rotation
+            around the symmetry axis of the rocket relative to one of the other
+            principal axis. See :ref:`Angular Position Inputs `
+        root_chord : int, float
+            Fin root chord in meters.
+        span : int, float
+            Fin span in meters.
+        rocket_radius : int, float
+            Reference radius to calculate lift coefficient, in meters.
+        cant_angle : int, float, optional
+            Fins cant angle with respect to the rocket centerline. Must
+            be given in degrees.
+        sweep_length : int, float, optional
+            Fins sweep length in meters. By sweep length, understand the axial
+            distance between the fin root leading edge and the fin tip leading
+            edge measured parallel to the rocket centerline. If not given, the
+            sweep length is assumed to be equal the root chord minus the tip
+            chord, in which case the fin is a right trapezoid with its base
+            perpendicular to the rocket's axis. Cannot be used in conjunction
+            with sweep_angle.
+        sweep_angle : int, float, optional
+            Fins sweep angle with respect to the rocket centerline. Must
+            be given in degrees. If not given, the sweep angle is automatically
+            calculated, in which case the fin is assumed to be a right trapezoid
+            with its base perpendicular to the rocket's axis.
+            Cannot be used in conjunction with sweep_length.
+        airfoil : tuple, optional
+            Default is null, in which case fins will be treated as flat plates.
+            Otherwise, if tuple, fins will be considered as airfoils. The
+            tuple's first item specifies the airfoil's lift coefficient
+            by angle of attack and must be either a .csv, .txt, ndarray
+            or callable. The .csv and .txt files can contain a single line
+            header and the first column must specify the angle of attack, while
+            the second column must specify the lift coefficient. The
+            ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...]
+            where x0 is the angle of attack and y0 is the lift coefficient.
+            If callable, it should take an angle of attack as input and
+            return the lift coefficient at that angle of attack.
+            The tuple's second item is the unit of the angle of attack,
+            accepting either "radians" or "degrees".
+        name : str
+            Name of elliptical fin.
+
+        Returns
+        -------
+        None
+        """
+
+        super().__init__(
+            angular_position,
+            root_chord,
+            span,
+            rocket_radius,
+            cant_angle,
+            airfoil,
+            name,
+        )
+
+        self.geometry = _EllipticalGeometry(self)
+        self._update_geometry_chain()
+        self.evaluate_shape()
+        self.evaluate_rotation_matrix()
+
+        self.prints = _EllipticalFinPrints(self)
+        self.plots = _EllipticalFinPlots(self)
+
+    def evaluate_center_of_pressure(self):
+        """Calculates and returns the center of pressure of the fin in local
+        coordinates. The center of pressure position is saved and stored as a
+        tuple."""
+        # Barrowman elliptical-fin center of pressure location.
+        cpz = 0.288 * self.root_chord
+        self.cpx = 0
+        self.cpy = self.Yma
+        self.cpz = cpz
+        self.cp = (self.cpx, self.cpy, self.cpz)
+
+    def to_dict(self, **kwargs):
+        data = super().to_dict(**kwargs)
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
+        return data
+
+    @classmethod
+    def from_dict(cls, data):
+        return cls(
+            angular_position=data["angular_position"],
+            root_chord=data["root_chord"],
+            span=data["span"],
+            rocket_radius=data["rocket_radius"],
+            cant_angle=data["cant_angle"],
+            airfoil=data["airfoil"],
+            name=data["name"],
+        )
diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py
index 61d98bc0d..4576bd1f3 100644
--- a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py
+++ b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py
@@ -1,7 +1,6 @@
-import numpy as np
-
 from rocketpy.plots.aero_surface_plots import _EllipticalFinsPlots
 from rocketpy.prints.aero_surface_prints import _EllipticalFinsPrints
+from rocketpy.rocket.aero_surface.fins._geometry import _EllipticalGeometry
 
 from .fins import Fins
 
@@ -35,9 +34,6 @@ class EllipticalFins(Fins):
         Second is the unit of the curve (radians or degrees)
     EllipticalFins.cant_angle : float
         Fins cant angle with respect to the rocket centerline, in degrees.
-    EllipticalFins.changing_attribute_dict : dict
-        Dictionary that stores the name and the values of the attributes that
-        may be changed during a simulation. Useful for control systems.
     EllipticalFins.cant_angle_rad : float
         Fins cant angle with respect to the rocket centerline, in radians.
     EllipticalFins.root_chord : float
@@ -53,9 +49,9 @@ class EllipticalFins(Fins):
     EllipticalFins.sweep_angle : float
         Fins sweep angle with respect to the rocket centerline. Must
         be given in degrees.
-    EllipticalFins.d : float
+    EllipticalFins.rocket_diameter : float
         Reference diameter of the rocket, in meters.
-    EllipticalFins.ref_area : float
+    EllipticalFins.reference_area : float
         Reference area of the rocket.
     EllipticalFins.Af : float
         Area of the longitudinal section of each fin in the set.
@@ -162,10 +158,9 @@ def __init__(
             name,
         )
 
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
+        self.geometry = _EllipticalGeometry(self)
+        self._update_geometry_chain()
+        self.evaluate_shape()
 
         self.prints = _EllipticalFinsPrints(self)
         self.plots = _EllipticalFinsPlots(self)
@@ -179,160 +174,18 @@ def evaluate_center_of_pressure(self):
         -------
         None
         """
-        # Center of pressure position in local coordinates
+        # Barrowman elliptical-fin center of pressure location.
         cpz = 0.288 * self.root_chord
         self.cpx = 0
         self.cpy = 0
         self.cpz = cpz
         self.cp = (self.cpx, self.cpy, self.cpz)
 
-    def evaluate_geometrical_parameters(self):  # pylint: disable=too-many-statements
-        """Calculates and saves fin set's geometrical parameters such as the
-        fins' area, aspect ratio and parameters for roll movement.
-
-        Returns
-        -------
-        None
-        """
-
-        # Compute auxiliary geometrical parameters
-        # pylint: disable=invalid-name
-        Af = (np.pi * self.root_chord / 2 * self.span) / 2  # Fin area
-        gamma_c = 0  # Zero for elliptical fins
-        AR = 2 * self.span**2 / Af  # Fin aspect ratio
-        Yma = (
-            self.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64)
-        )  # Span wise coord of mean aero chord
-        roll_geometrical_constant = (
-            self.root_chord
-            * self.span
-            * (
-                3 * np.pi * self.span**2
-                + 32 * self.rocket_radius * self.span
-                + 12 * np.pi * self.rocket_radius**2
-            )
-            / 48
-        )
-
-        # Fin–body interference correction parameters
-        tau = (self.span + self.rocket_radius) / self.rocket_radius
-        lift_interference_factor = 1 + 1 / tau
-        if self.span > self.rocket_radius:
-            roll_damping_interference_factor = 1 + (
-                (self.rocket_radius**2)
-                * (
-                    2
-                    * (self.rocket_radius**2)
-                    * np.sqrt(self.span**2 - self.rocket_radius**2)
-                    * np.log(
-                        (
-                            2
-                            * self.span
-                            * np.sqrt(self.span**2 - self.rocket_radius**2)
-                            + 2 * self.span**2
-                        )
-                        / self.rocket_radius
-                    )
-                    - 2
-                    * (self.rocket_radius**2)
-                    * np.sqrt(self.span**2 - self.rocket_radius**2)
-                    * np.log(2 * self.span)
-                    + 2 * self.span**3
-                    - np.pi * self.rocket_radius * self.span**2
-                    - 2 * (self.rocket_radius**2) * self.span
-                    + np.pi * self.rocket_radius**3
-                )
-            ) / (
-                2
-                * (self.span**2)
-                * (self.span / 3 + np.pi * self.rocket_radius / 4)
-                * (self.span**2 - self.rocket_radius**2)
-            )
-        elif self.span < self.rocket_radius:
-            roll_damping_interference_factor = 1 - (
-                self.rocket_radius**2
-                * (
-                    2 * self.span**3
-                    - np.pi * self.span**2 * self.rocket_radius
-                    - 2 * self.span * self.rocket_radius**2
-                    + np.pi * self.rocket_radius**3
-                    + 2
-                    * self.rocket_radius**2
-                    * np.sqrt(-(self.span**2) + self.rocket_radius**2)
-                    * np.arctan(
-                        (self.span) / (np.sqrt(-(self.span**2) + self.rocket_radius**2))
-                    )
-                    - np.pi
-                    * self.rocket_radius**2
-                    * np.sqrt(-(self.span**2) + self.rocket_radius**2)
-                )
-            ) / (
-                2
-                * self.span
-                * (-(self.span**2) + self.rocket_radius**2)
-                * (self.span**2 / 3 + np.pi * self.span * self.rocket_radius / 4)
-            )
-        else:
-            roll_damping_interference_factor = (28 - 3 * np.pi) / (4 + 3 * np.pi)
-
-        roll_forcing_interference_factor = (1 / np.pi**2) * (
-            (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2)
-            + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2))
-            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
-            - (2 * np.pi * (tau + 1)) / (tau * (tau - 1))
-            + ((tau**2 + 1) ** 2)
-            / (tau**2 * (tau - 1) ** 2)
-            * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2
-            - (4 * (tau + 1))
-            / (tau * (tau - 1))
-            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
-            + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau))
-        )
-
-        # Store values
-        # pylint: disable=invalid-name
-        self.Af = Af  # Fin area
-        self.AR = AR  # Fin aspect ratio
-        self.gamma_c = gamma_c  # Mid chord angle
-        self.Yma = Yma  # Span wise coord of mean aero chord
-        self.roll_geometrical_constant = roll_geometrical_constant
-        self.tau = tau
-        self.lift_interference_factor = lift_interference_factor
-        self.roll_damping_interference_factor = roll_damping_interference_factor
-        self.roll_forcing_interference_factor = roll_forcing_interference_factor
-
-        self.evaluate_shape()
-
-    def evaluate_shape(self):
-        angles = np.arange(0, 180, 5)
-        x_array = self.root_chord / 2 + self.root_chord / 2 * np.cos(np.radians(angles))
-        y_array = self.span * np.sin(np.radians(angles))
-        self.shape_vec = [x_array, y_array]
-
-    def info(self):
-        self.prints.geometry()
-        self.prints.lift()
-
-    def all_info(self):
-        self.prints.all()
-        self.plots.all()
-
     def to_dict(self, **kwargs):
         data = super().to_dict(**kwargs)
-        if kwargs.get("include_outputs", False):
-            data.update(
-                {
-                    "Af": self.Af,
-                    "AR": self.AR,
-                    "gamma_c": self.gamma_c,
-                    "Yma": self.Yma,
-                    "roll_geometrical_constant": self.roll_geometrical_constant,
-                    "tau": self.tau,
-                    "lift_interference_factor": self.lift_interference_factor,
-                    "roll_damping_interference_factor": self.roll_damping_interference_factor,
-                    "roll_forcing_interference_factor": self.roll_forcing_interference_factor,
-                }
-            )
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
         return data
 
     @classmethod
diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py
new file mode 100644
index 000000000..bb41c89d2
--- /dev/null
+++ b/rocketpy/rocket/aero_surface/fins/fin.py
@@ -0,0 +1,483 @@
+import math
+
+import numpy as np
+
+from rocketpy.mathutils.function import Function
+from rocketpy.mathutils.vector_matrix import Matrix, Vector
+from rocketpy.rocket.aero_surface.fins._base_fin import _BaseFin
+
+
+class Fin(_BaseFin):
+    """Abstract class that holds common methods for the individual fin classes.
+    Cannot be instantiated.
+
+    Note
+    ----
+    Local coordinate system:
+        - Origin located at the top of the root chord.
+        - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom).
+        - Y axis perpendicular to the Z axis, in the span direction, positive upwards.
+        - X axis completes the right-handed coordinate system.
+
+    Attributes
+    ----------
+    Fin.rocket_radius : float
+        The reference rocket radius used for lift coefficient normalization,
+        in meters.
+    Fin.airfoil : tuple
+        Tuple of two items. First is the airfoil lift curve.
+        Second is the unit of the curve (radians or degrees).
+    Fin.cant_angle : float
+        Fin cant angle with respect to the rocket centerline, in degrees.
+    Fin.changing_attribute_dict : dict
+        Dictionary that stores the name and the values of the attributes that
+        may be changed during a simulation. Useful for control systems.
+    Fin.cant_angle_rad : float
+        Fin cant angle with respect to the rocket centerline, in radians.
+    Fin.root_chord : float
+        Fin root chord in meters.
+    Fin.tip_chord : float
+        Fin tip chord in meters.
+    Fin.span : float
+        Fin span in meters.
+    Fin.name : string
+        Name of fin set.
+    Fin.sweep_length : float
+        Fin sweep length in meters. By sweep length, understand the axial
+        distance between the fin root leading edge and the fin tip leading edge
+        measured parallel to the rocket centerline.
+    Fin.sweep_angle : float
+        Fin sweep angle with respect to the rocket centerline. Must
+        be given in degrees.
+    Fin.rocket_diameter : float
+        Reference diameter of the rocket. Has units of length and is given
+        in meters.
+    Fin.reference_area : float
+        Reference area of the rocket.
+    Fin.Af : float
+        Area of the longitudinal section of each fin in the set.
+    Fin.AR : float
+        Aspect ratio of each fin in the set.
+    Fin.gamma_c : float
+        Fin mid-chord sweep angle.
+    Fin.Yma : float
+        Span wise position of the mean aerodynamic chord.
+    Fin.roll_geometrical_constant : float
+        Geometrical constant used in roll calculations.
+    Fin.tau : float
+        Geometrical relation used to simplify lift and roll calculations.
+    Fin.lift_interference_factor : float
+        Factor of Fin-Body interference in the lift coefficient.
+    Fin.cp : tuple
+        Tuple with the x, y and z local coordinates of the fin set center of
+        pressure. Has units of length and is given in meters.
+    Fin.cpx : float
+        Fin set local center of pressure x coordinate. Has units of length and
+        is given in meters.
+    Fin.cpy : float
+        Fin set local center of pressure y coordinate. Has units of length and
+        is given in meters.
+    Fin.cpz : float
+        Fin set local center of pressure z coordinate. Has units of length and
+        is given in meters.
+    Fin.cl : Function
+        Function which defines the lift coefficient as a function of the angle
+        of attack and the Mach number. Takes as input the angle of attack in
+        radians and the Mach number. Returns the lift coefficient.
+    Fin.clalpha : float
+        Lift coefficient slope. Has units of 1/rad.
+    Fin.roll_parameters : list
+        List containing the roll moment lift coefficient, the roll moment
+        damping coefficient and the cant angle in radians.
+    """
+
+    def __init__(
+        self,
+        angular_position,
+        root_chord,
+        span,
+        rocket_radius,
+        cant_angle=0,
+        airfoil=None,
+        name="Fin",
+    ):
+        """Initialize Fin class.
+
+        Parameters
+        ----------
+        angular_position : float
+            Angular position of the fin in degrees measured as the rotation
+            around the symmetry axis of the rocket relative to one of the other
+            principal axis. See :ref:`Angular Position Inputs `
+        root_chord : int, float
+            Fin root chord in meters.
+        span : int, float
+            Fin span in meters.
+        rocket_radius : int, float
+            Reference rocket radius used for lift coefficient normalization.
+        cant_angle : int, float, optional
+            Fin cant angle with respect to the rocket centerline. Must
+            be given in degrees.
+        airfoil : tuple, optional
+            Default is null, in which case fins will be treated as flat plates.
+            Otherwise, if tuple, fins will be considered as airfoils. The
+            tuple's first item specifies the airfoil's lift coefficient
+            by angle of attack and must be either a .csv, .txt, ndarray
+            or callable. The .csv and .txt files can contain a single line
+            header and the first column must specify the angle of attack, while
+            the second column must specify the lift coefficient. The
+            ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...]
+            where x0 is the angle of attack and y0 is the lift coefficient.
+            If callable, it should take an angle of attack as input and
+            return the lift coefficient at that angle of attack.
+            The tuple's second item is the unit of the angle of attack,
+            accepting either "radians" or "degrees".
+        name : str
+            Name of fin.
+        """
+        super().__init__(
+            name=name,
+            rocket_radius=rocket_radius,
+            root_chord=root_chord,
+            span=span,
+            airfoil=airfoil,
+            cant_angle=cant_angle,
+        )
+
+        # Store values
+        self._angular_position = angular_position
+        self._angular_position_rad = math.radians(angular_position)
+
+    @property
+    def cant_angle(self):
+        return self._cant_angle
+
+    @cant_angle.setter
+    def cant_angle(self, value):
+        self._cant_angle = value
+        self.cant_angle_rad = math.radians(value)
+
+    @property
+    def cant_angle_rad(self):
+        return self._cant_angle_rad
+
+    @cant_angle_rad.setter
+    def cant_angle_rad(self, value):
+        self._cant_angle_rad = value
+        self.evaluate_geometrical_parameters()
+        self.evaluate_center_of_pressure()
+        self.evaluate_lift_coefficient()
+        self.evaluate_roll_parameters()
+        self.evaluate_rotation_matrix()
+
+    @property
+    def angular_position(self):
+        return self._angular_position
+
+    @angular_position.setter
+    def angular_position(self, value):
+        self._angular_position = value
+        self.angular_position_rad = math.radians(value)
+
+    @property
+    def angular_position_rad(self):
+        return self._angular_position_rad
+
+    @angular_position_rad.setter
+    def angular_position_rad(self, value):
+        self._angular_position_rad = value
+        self.evaluate_rotation_matrix()
+
+    def evaluate_lift_coefficient(self):
+        """Calculates and returns the individual fin's lift coefficient.
+
+        Note
+        ----
+        An individual :class:`Fin` models a single physical fin, so its lift
+        curve slope is the single-fin value corrected only for fin-body
+        interference. It intentionally does NOT include the empirical
+        multiple-fin interference correction (``fin_num_correction``) applied by
+        the symmetric :class:`Fins` set, because that correction is defined for a
+        set of ``n`` identical, evenly spaced fins and has no meaning for an
+        individually positioned fin. Consequently, a set of ``n`` identical
+        ``Fin`` objects will not reproduce the exact aggregate lift slope of a
+        ``Fins(n=...)`` set (they differ by ``fin_num_correction(n) / n``). For
+        standard symmetric fin sets, prefer the plural ``*Fins`` classes.
+
+        Returns
+        -------
+        None
+        """
+        self.evaluate_single_fin_lift_coefficient()
+
+        self.clalpha = self.clalpha_single_fin * self.lift_interference_factor
+
+        # Cl = clalpha * alpha
+        self.cl = Function(
+            lambda alpha, mach: alpha * self.clalpha(mach),
+            ["Alpha (rad)", "Mach"],
+            "Lift coefficient",
+        )
+
+        return self.cl
+
+    def evaluate_roll_parameters(self):
+        """Calculates and returns the fin set's roll coefficients.
+        The roll coefficients are saved in a list.
+
+        Returns
+        -------
+        self.roll_parameters : list
+            List containing the roll moment lift coefficient, the
+            roll moment damping coefficient and the cant angle in
+            radians
+        """
+        clf_delta = (
+            self.roll_forcing_interference_factor
+            * (self.Yma + self.rocket_radius)
+            * self.clalpha_single_fin
+            / self.reference_length
+        )  # Function of mach number
+        clf_delta.set_inputs("Mach")
+        clf_delta.set_outputs("Roll moment forcing coefficient derivative")
+        clf_delta.set_title(
+            "Roll moment forcing coefficient derivative vs. Mach number"
+        )
+        cld_omega = -(
+            2
+            * self.roll_damping_interference_factor
+            * self.clalpha_single_fin
+            * np.cos(self.cant_angle_rad)
+            * self.roll_geometrical_constant
+            / (self.reference_area * self.reference_length**2)
+        )  # Function of mach number
+        cld_omega.set_inputs("Mach")
+        cld_omega.set_outputs("Roll moment damping coefficient derivative")
+        cld_omega.set_title(
+            "Roll moment damping coefficient derivative vs. Mach number"
+        )
+        self.roll_parameters = [clf_delta, cld_omega, self.cant_angle_rad]
+        return self.roll_parameters
+
+    def evaluate_rotation_matrix(self):
+        """Calculates and returns the rotation matrix from the rocket body frame
+        to the fin frame.
+
+        Note
+        ----
+        Local coordinate system:
+
+        - Origin located at the leading edge of the root chord.
+        - Z axis along the longitudinal axis of the fin, positive
+          downwards (leading edge -> trailing edge).
+        - Y axis perpendicular to the Z axis, in the span direction,
+          positive upwards (root chord -> tip chord).
+        - X axis completes the right-handed coordinate system.
+
+
+        Returns
+        -------
+        None
+
+        References
+        ----------
+        :ref:`Individual Fin Model `
+        """
+        phi = self.angular_position_rad
+        delta = self.cant_angle_rad
+        sin_phi = math.sin(phi)
+        cos_phi = math.cos(phi)
+        sin_delta = math.sin(delta)
+        cos_delta = math.cos(delta)
+
+        # Rotation about body Z by angular position
+        R_phi = Matrix(
+            [
+                [cos_phi, -sin_phi, 0],
+                [sin_phi, cos_phi, 0],
+                [0, 0, 1],
+            ]
+        )
+
+        # Cant rotation about body Y
+        R_delta = Matrix(
+            [
+                [cos_delta, 0, -sin_delta],
+                [0, 1, 0],
+                [sin_delta, 0, cos_delta],
+            ]
+        )
+
+        # 180 flip about Y to align fin leading/trailing edge
+        R_pi = Matrix(
+            [
+                [-1, 0, 0],
+                [0, 1, 0],
+                [0, 0, -1],
+            ]
+        )
+
+        # Uncanted body to fin, then apply cant
+        R_uncanted = R_phi @ R_pi
+        R_body_to_fin = R_delta @ R_uncanted
+
+        # Store for downstream transforms
+        self._rotation_fin_to_body_uncanted = R_uncanted.transpose
+        self._rotation_body_to_fin = R_body_to_fin
+        self._rotation_fin_to_body = R_body_to_fin.transpose
+        self._rotation_surface_to_body = self._rotation_fin_to_body
+
+    def compute_forces_and_moments(
+        self,
+        stream_velocity,
+        stream_speed,
+        stream_mach,
+        rho,
+        cp,
+        omega,
+        *args,
+    ):  # pylint: disable=arguments-differ,unused-argument
+        """Computes the forces and moments acting on the aerodynamic surface.
+
+        Parameters
+        ----------
+        stream_velocity : tuple of float
+            The velocity of the airflow relative to the surface.
+        stream_speed : float
+            The magnitude of the airflow speed.
+        stream_mach : float
+            The Mach number of the airflow.
+        rho : float
+            Air density.
+        cp : Vector
+            Center of pressure coordinates in the body frame.
+        omega: tuple[float, float, float]
+            Tuple containing angular velocities around the x, y, z axes.
+
+        Returns
+        -------
+        tuple of float
+            The aerodynamic forces (lift, side_force, drag) and moments
+            (pitch, yaw, roll) in the body frame.
+        """
+        R1, R2, R3, M1, M2, M3 = 0, 0, 0, 0, 0, 0
+
+        # stream velocity in fin frame
+        stream_velocity_f = self._rotation_body_to_fin @ stream_velocity
+
+        attack_angle = np.arctan2(stream_velocity_f[0], stream_velocity_f[2])
+        # Force in the X direction of the fin
+        X = (
+            0.5
+            * rho
+            * stream_speed**2
+            * self.reference_area
+            * self.cl.get_value_opt(attack_angle, stream_mach)
+        )
+        # Force in body frame
+        R1, R2, R3 = self._rotation_fin_to_body @ Vector([X, 0, 0])
+        # Moments
+        M1, M2, M3 = cp ^ Vector([R1, R2, R3])
+        # Apply roll interference factor, disregarding lift interference factor
+        M3 *= self.roll_forcing_interference_factor / self.lift_interference_factor
+
+        # NOTE: roll damping is intentionally NOT added as a separate term here.
+        # Unlike the symmetric fin set (whose center of pressure lies on the
+        # roll axis), an individual fin has an off-axis center of pressure, so
+        # the roll-rate contribution ``w ^ cp`` is already injected by the Flight
+        # loop into the local stream velocity fed to this surface (see
+        # ``Flight`` ``comp_vb``). That contribution changes ``attack_angle`` and
+        # therefore already produces the roll-damping moment through the
+        # ``cp ^ R`` term above. Adding an explicit ``cld_omega`` damping term
+        # would double-count it.
+        return R1, R2, R3, M1, M2, M3
+
+    def _compute_leading_edge_position(self, position, _csys):
+        """Computes the position of the fin leading edge in a rocket's user,
+        given its position in a rocket."""
+        # Point from deflection from cant angle in the plane perpendicular to
+        # the fuselage where the fin is located in the fin frame
+        p = Vector(
+            [
+                -self.root_chord / 2 * np.sin(self.cant_angle_rad),
+                0,
+                self.root_chord / 2 * (1 - np.cos(self.cant_angle_rad)),
+            ]
+        )
+        # Rotate the point to the body frame orientation
+        p = self._rotation_fin_to_body_uncanted @ p
+
+        # Rotate the point to the user-defined coordinate system
+        p = Vector([p.x * _csys, p.y, p.z * _csys])
+
+        # Build the leading-edge position in the user frame as if no cant
+        # angle was applied. Scalars are interpreted as z coordinates only,
+        # while vectors/tuples/lists are interpreted as full (x, y, z)
+        # coordinates.
+        if isinstance(position, (Vector, tuple, list)):
+            position = Vector(position)
+        else:
+            position = Vector(
+                [
+                    -self.rocket_radius * math.sin(self.angular_position_rad) * _csys,
+                    self.rocket_radius * math.cos(self.angular_position_rad),
+                    position,
+                ]
+            )
+
+        # Translate the position of the fin leading edge to the position of the
+        # fin leading edge with cant angle
+        position += p
+        return position
+
+    def to_dict(self, **kwargs):
+        if self.airfoil:
+            if kwargs.get("discretize", False):
+                lower = -np.pi / 6 if self.airfoil[1] == "radians" else -30
+                upper = np.pi / 6 if self.airfoil[1] == "radians" else 30
+                airfoil = (
+                    self.airfoil_cl.set_discrete(lower, upper, 50, mutate_self=False),
+                    self.airfoil[1],
+                )
+            else:
+                airfoil = (self.airfoil_cl, self.airfoil[1])
+        else:
+            airfoil = None
+        data = {
+            "angular_position": self.angular_position,
+            "root_chord": self.root_chord,
+            "span": self.span,
+            "rocket_radius": self.rocket_radius,
+            "cant_angle": self.cant_angle,
+            "airfoil": airfoil,
+            "name": self.name,
+        }
+
+        if kwargs.get("include_outputs", False):
+            data.update(
+                {
+                    "cp": self.cp,
+                    "cl": self.cl,
+                    "roll_parameters": self.roll_parameters,
+                    "rocket_diameter": self.rocket_diameter,
+                    "diameter": self.rocket_diameter,
+                    "d": self.rocket_diameter,
+                    "reference_area": self.reference_area,
+                    "ref_area": self.reference_area,
+                }
+            )
+        return data
+
+    def draw(self, *, filename=None):
+        """Draw the fin shape along with some important information, including
+        the center line, the quarter line and the center of pressure position.
+
+        Parameters
+        ----------
+        filename : str | None, optional
+            The path the plot should be saved to. By default None, in which case
+            the plot will be shown instead of saved. Supported file endings are:
+            eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff
+            and webp (these are the formats supported by matplotlib).
+        """
+        self.plots.draw(filename=filename)
diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py
index fe24a84f4..912212979 100644
--- a/rocketpy/rocket/aero_surface/fins/fins.py
+++ b/rocketpy/rocket/aero_surface/fins/fins.py
@@ -1,11 +1,10 @@
 import numpy as np
 
 from rocketpy.mathutils.function import Function
+from rocketpy.rocket.aero_surface.fins._base_fin import _BaseFin
 
-from ..aero_surface import AeroSurface
 
-
-class Fins(AeroSurface):
+class Fins(_BaseFin):
     """Abstract class that holds common methods for the fin classes.
     Cannot be instantiated.
 
@@ -49,10 +48,10 @@ class Fins(AeroSurface):
     Fins.sweep_angle : float
         Fins sweep angle with respect to the rocket centerline. Must
         be given in degrees.
-    Fins.d : float
+    Fins.rocket_diameter : float
         Reference diameter of the rocket. Has units of length and is given
         in meters.
-    Fins.ref_area : float
+    Fins.reference_area : float
         Reference area of the rocket.
     Fins.Af : float
         Area of the longitudinal section of each fin in the set.
@@ -132,27 +131,18 @@ def __init__(
             accepting either "radians" or "degrees".
         name : str
             Name of fin set.
-
-        Returns
-        -------
-        None
         """
-        # Compute auxiliary geometrical parameters
-        d = 2 * rocket_radius
-        ref_area = np.pi * rocket_radius**2  # Reference area
-
-        super().__init__(name, ref_area, d)
+        super().__init__(
+            name=name,
+            rocket_radius=rocket_radius,
+            root_chord=root_chord,
+            span=span,
+            airfoil=airfoil,
+            cant_angle=cant_angle,
+        )
 
         # Store values
         self._n = n
-        self._rocket_radius = rocket_radius
-        self._airfoil = airfoil
-        self._cant_angle = cant_angle
-        self._root_chord = root_chord
-        self._span = span
-        self.name = name
-        self.d = d
-        self.ref_area = ref_area  # Reference area
 
     @property
     def n(self):
@@ -166,134 +156,26 @@ def n(self, value):
         self.evaluate_lift_coefficient()
         self.evaluate_roll_parameters()
 
-    @property
-    def root_chord(self):
-        return self._root_chord
-
-    @root_chord.setter
-    def root_chord(self, value):
-        self._root_chord = value
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
-
-    @property
-    def span(self):
-        return self._span
-
-    @span.setter
-    def span(self, value):
-        self._span = value
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
-
-    @property
-    def rocket_radius(self):
-        return self._rocket_radius
-
-    @rocket_radius.setter
-    def rocket_radius(self, value):
-        self._rocket_radius = value
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
-
-    @property
-    def cant_angle(self):
-        return self._cant_angle
-
-    @cant_angle.setter
-    def cant_angle(self, value):
-        self._cant_angle = value
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
-
-    @property
-    def airfoil(self):
-        return self._airfoil
-
-    @airfoil.setter
-    def airfoil(self, value):
-        self._airfoil = value
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
-
     def evaluate_lift_coefficient(self):
         """Calculates and returns the fin set's lift coefficient.
         The lift coefficient is saved and returned. This function
         also calculates and saves the lift coefficient derivative
         for a single fin and the lift coefficient derivative for
         a number of n fins corrected for Fin-Body interference.
-
-        Returns
-        -------
-        None
         """
-        if not self.airfoil:
-            # Defines clalpha2D as 2*pi for planar fins
-            clalpha2D_incompressible = 2 * np.pi
-        else:
-            # Defines clalpha2D as the derivative of the lift coefficient curve
-            # for the specific airfoil
-            self.airfoil_cl = Function(
-                self.airfoil[0],
-                interpolation="linear",
-            )
-
-            # Differentiating at alpha = 0 to get cl_alpha
-            clalpha2D_incompressible = self.airfoil_cl.differentiate_complex_step(
-                x=1e-3, dx=1e-3
-            )
-
-            # Convert to radians if needed
-            if self.airfoil[1] == "degrees":
-                clalpha2D_incompressible *= 180 / np.pi
-
-        # Correcting for compressible flow (apply Prandtl-Glauert correction)
-        clalpha2D = Function(lambda mach: clalpha2D_incompressible / self._beta(mach))
-
-        # Diederich's Planform Correlation Parameter
-        planform_correlation_parameter = (
-            2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c))
-        )
-
-        # Lift coefficient derivative for a single fin
-        def lift_source(mach):
-            return (
-                clalpha2D(mach)
-                * planform_correlation_parameter(mach)
-                * (self.Af / self.ref_area)
-                * np.cos(self.gamma_c)
-            ) / (
-                2
-                + planform_correlation_parameter(mach)
-                * np.sqrt(1 + (2 / planform_correlation_parameter(mach)) ** 2)
-            )
-
-        self.clalpha_single_fin = Function(
-            lift_source,
-            "Mach",
-            "Lift coefficient derivative for a single fin",
-        )
+        self.evaluate_single_fin_lift_coefficient()
 
         # Lift coefficient derivative for n fins corrected with Fin-Body interference
         self.clalpha_multiple_fins = (
-            self.lift_interference_factor
-            * self.fin_num_correction(self.n)
+            self.fin_num_correction(self.n)
+            * self.lift_interference_factor
             * self.clalpha_single_fin
         )  # Function of mach number
         self.clalpha_multiple_fins.set_inputs("Mach")
         self.clalpha_multiple_fins.set_outputs(
             f"Lift coefficient derivative for {self.n:.0f} fins"
         )
+
         self.clalpha = self.clalpha_multiple_fins
 
         # Cl = clalpha * alpha
@@ -316,19 +198,18 @@ def evaluate_roll_parameters(self):
             roll moment damping coefficient and the cant angle in
             radians
         """
-
-        self.cant_angle_rad = np.radians(self.cant_angle)
-
         clf_delta = (
             self.roll_forcing_interference_factor
             * self.n
             * (self.Yma + self.rocket_radius)
             * self.clalpha_single_fin
-            / self.d
+            / self.reference_length
         )  # Function of mach number
         clf_delta.set_inputs("Mach")
         clf_delta.set_outputs("Roll moment forcing coefficient derivative")
-        clf_delta.set_title(None)
+        clf_delta.set_title(
+            "Roll moment forcing coefficient derivative vs. Mach number"
+        )
         cld_omega = (
             2
             * self.roll_damping_interference_factor
@@ -336,11 +217,13 @@ def evaluate_roll_parameters(self):
             * self.clalpha_single_fin
             * np.cos(self.cant_angle_rad)
             * self.roll_geometrical_constant
-            / (self.ref_area * self.d**2)
+            / (self.reference_area * self.reference_length**2)
         )  # Function of mach number
         cld_omega.set_inputs("Mach")
         cld_omega.set_outputs("Roll moment damping coefficient derivative")
-        cld_omega.set_title(None)
+        cld_omega.set_title(
+            "Roll moment damping coefficient derivative vs. Mach number"
+        )
         self.roll_parameters = [clf_delta, cld_omega, self.cant_angle_rad]
         return self.roll_parameters
 
@@ -463,8 +346,11 @@ def to_dict(self, **kwargs):
                     "cp": self.cp,
                     "cl": cl,
                     "roll_parameters": self.roll_parameters,
-                    "d": self.d,
-                    "ref_area": self.ref_area,
+                    "rocket_diameter": self.rocket_diameter,
+                    "diameter": self.rocket_diameter,
+                    "d": self.rocket_diameter,
+                    "reference_area": self.reference_area,
+                    "ref_area": self.reference_area,
                 }
             )
 
@@ -481,9 +367,5 @@ def draw(self, *, filename=None):
             the plot will be shown instead of saved. Supported file endings are:
             eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff
             and webp (these are the formats supported by matplotlib).
-
-        Returns
-        -------
-        None
         """
         self.plots.draw(filename=filename)
diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fin.py b/rocketpy/rocket/aero_surface/fins/free_form_fin.py
new file mode 100644
index 000000000..767b1bd89
--- /dev/null
+++ b/rocketpy/rocket/aero_surface/fins/free_form_fin.py
@@ -0,0 +1,192 @@
+from rocketpy.plots.aero_surface_plots import _FreeFormFinPlots
+from rocketpy.prints.aero_surface_prints import _FreeFormFinPrints
+from rocketpy.rocket.aero_surface.fins._geometry import _FreeFormGeometry
+from rocketpy.rocket.aero_surface.fins.fin import Fin
+
+
+class FreeFormFin(Fin):
+    """Class that defines and holds information for a free form fin set.
+
+    This class inherits from the Fin class.
+
+    Note
+    ----
+    Local coordinate system:
+        - Origin located at the top of the root chord.
+        - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom).
+        - Y axis perpendicular to the Z axis, in the span direction, positive upwards.
+        - X axis completes the right-handed coordinate system.
+
+    See Also
+    --------
+    Fin
+
+    Attributes
+    ----------
+    FreeFormFin.n : int
+        Number of fins in fin set.
+    FreeFormFin.rocket_radius : float
+        The reference rocket radius used for lift coefficient normalization, in
+        meters.
+    FreeFormFin.airfoil : tuple
+        Tuple of two items. First is the airfoil lift curve.
+        Second is the unit of the curve (radians or degrees).
+    FreeFormFin.cant_angle : float
+        Fins cant angle with respect to the rocket centerline, in degrees.
+    FreeFormFin.cant_angle_rad : float
+        Fins cant angle with respect to the rocket centerline, in radians.
+    FreeFormFin.root_chord : float
+        Fin root chord in meters.
+    FreeFormFin.span : float
+        Fin span in meters.
+    FreeFormFin.name : string
+        Name of fin set.
+    FreeFormFin.rocket_diameter : float
+        Reference diameter of the rocket, in meters.
+    FreeFormFin.reference_area : float
+        Reference area of the rocket, in m².
+    FreeFormFin.Af : float
+        Area of the longitudinal section of each fin in the set.
+    FreeFormFin.AR : float
+        Aspect ratio of the fin.
+    FreeFormFin.gamma_c : float
+        Fin mid-chord sweep angle.
+    FreeFormFin.Yma : float
+        Span wise position of the mean aerodynamic chord.
+    FreeFormFin.roll_geometrical_constant : float
+        Geometrical constant used in roll calculations.
+    FreeFormFin.tau : float
+        Geometrical relation used to simplify lift and roll calculations.
+    FreeFormFin.lift_interference_factor : float
+        Factor of Fin-Body interference in the lift coefficient.
+    FreeFormFin.cp : tuple
+        Tuple with the x, y and z local coordinates of the fin set center of
+        pressure. Has units of length and is given in meters.
+    FreeFormFin.cpx : float
+        Fin set local center of pressure x coordinate. Has units of length and
+        is given in meters.
+    FreeFormFin.cpy : float
+        Fin set local center of pressure y coordinate. Has units of length and
+        is given in meters.
+    FreeFormFin.cpz : float
+        Fin set local center of pressure z coordinate. Has units of length and
+        is given in meters.
+    FreeFormFin.cl : Function
+        Function which defines the lift coefficient as a function of the angle
+        of attack and the Mach number. Takes as input the angle of attack in
+        radians and the Mach number. Returns the lift coefficient.
+    FreeFormFin.clalpha : float
+        Lift coefficient slope. Has units of 1/rad.
+    FreeFormFin.mac_length : float
+        Mean aerodynamic chord length of the fin set.
+    FreeFormFin.mac_lead : float
+        Mean aerodynamic chord leading edge x coordinate.
+    """
+
+    def __init__(
+        self,
+        angular_position,
+        shape_points,
+        rocket_radius,
+        cant_angle=0,
+        airfoil=None,
+        name="Free Form Fin",
+    ):
+        """Initialize FreeFormFin class.
+
+        Parameters
+        ----------
+        angular_position : float
+            Angular position of the fin in degrees measured as the rotation
+            around the symmetry axis of the rocket relative to one of the other
+            principal axis. See :ref:`Angular Position Inputs `
+        shape_points : list
+            List of tuples (x, y) containing the coordinates of the fin's
+            geometry defining points. The point (0, 0) is the root leading edge.
+            Positive x is rearwards, positive y is upwards (span direction).
+            The shape will be interpolated between the points, in the order
+            they are given. The last point connects to the first point, and
+            represents the trailing edge.
+        rocket_radius : int, float
+            Reference radius to calculate lift coefficient, in meters.
+        cant_angle : int, float, optional
+            Fins cant angle with respect to the rocket centerline. Must
+            be given in degrees.
+        airfoil : tuple, optional
+            Default is null, in which case fins will be treated as flat plates.
+            Otherwise, if tuple, fins will be considered as airfoils. The
+            tuple's first item specifies the airfoil's lift coefficient
+            by angle of attack and must be either a .csv, .txt, ndarray
+            or callable. The .csv and .txt files can contain a single line
+            header and the first column must specify the angle of attack, while
+            the second column must specify the lift coefficient. The
+            ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...]
+            where x0 is the angle of attack and y0 is the lift coefficient.
+            If callable, it should take an angle of attack as input and
+            return the lift coefficient at that angle of attack.
+            The tuple's second item is the unit of the angle of attack,
+            accepting either "radians" or "degrees".
+        name : str
+            Name of the free form fin.
+
+        Returns
+        -------
+        None
+        """
+        root_chord, span = _FreeFormGeometry.infer_dimensions(shape_points)
+
+        super().__init__(
+            angular_position,
+            root_chord,
+            span,
+            rocket_radius,
+            cant_angle,
+            airfoil,
+            name,
+        )
+
+        self.geometry = _FreeFormGeometry(self, shape_points)
+        self._update_geometry_chain()
+        self.evaluate_shape()
+        self.evaluate_rotation_matrix()
+
+        self.prints = _FreeFormFinPrints(self)
+        self.plots = _FreeFormFinPlots(self)
+
+    def evaluate_center_of_pressure(self):
+        """Calculates and returns the center of pressure of the fin in local
+        coordinates. The center of pressure position is saved and stored as a
+        tuple.
+
+        Returns
+        -------
+        None
+        """
+        # Center of pressure position in local coordinates
+        cpz = self.mac_lead + 0.25 * self.mac_length
+        self.cpx = 0
+        self.cpy = self.Yma
+        self.cpz = cpz
+        self.cp = (self.cpx, self.cpy, self.cpz)
+
+    @property
+    def shape_points(self):
+        return self.geometry.shape_points
+
+    def to_dict(self, **kwargs):
+        data = super().to_dict(**kwargs)
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
+        return data
+
+    @classmethod
+    def from_dict(cls, data):
+        return cls(
+            angular_position=data["angular_position"],
+            shape_points=data["shape_points"],
+            rocket_radius=data["rocket_radius"],
+            cant_angle=data["cant_angle"],
+            airfoil=data["airfoil"],
+            name=data["name"],
+        )
diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fins.py b/rocketpy/rocket/aero_surface/fins/free_form_fins.py
index 72758171e..d7c7e9512 100644
--- a/rocketpy/rocket/aero_surface/fins/free_form_fins.py
+++ b/rocketpy/rocket/aero_surface/fins/free_form_fins.py
@@ -1,9 +1,6 @@
-import warnings
-
-import numpy as np
-
 from rocketpy.plots.aero_surface_plots import _FreeFormFinsPlots
 from rocketpy.prints.aero_surface_prints import _FreeFormFinsPrints
+from rocketpy.rocket.aero_surface.fins._geometry import _FreeFormGeometry
 
 from .fins import Fins
 
@@ -45,9 +42,9 @@ class FreeFormFins(Fins):
         Fin span in meters.
     FreeFormFins.name : string
         Name of fin set.
-    FreeFormFins.d : float
+    FreeFormFins.rocket_diameter : float
         Reference diameter of the rocket, in meters.
-    FreeFormFins.ref_area : float
+    FreeFormFins.reference_area : float
         Reference area of the rocket, in m².
     FreeFormFins.Af : float
         Area of the longitudinal section of each fin in the set.
@@ -135,23 +132,7 @@ def __init__(
         -------
         None
         """
-        self.shape_points = shape_points
-
-        down = False
-        for i in range(1, len(shape_points)):
-            if shape_points[i][1] > shape_points[i - 1][1] and down:
-                warnings.warn(
-                    "Jagged fin shape detected. This may cause small inaccuracies "
-                    "center of pressure and pitch moment calculations."
-                )
-                break
-            if shape_points[i][1] < shape_points[i - 1][1]:
-                down = True
-            i += 1
-
-        root_chord = abs(shape_points[0][0] - shape_points[-1][0])
-        ys = [point[1] for point in shape_points]
-        span = max(ys) - min(ys)
+        root_chord, span = _FreeFormGeometry.infer_dimensions(shape_points)
 
         super().__init__(
             n,
@@ -163,10 +144,9 @@ def __init__(
             name,
         )
 
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
+        self.geometry = _FreeFormGeometry(self, shape_points)
+        self._update_geometry_chain()
+        self.evaluate_shape()
 
         self.prints = _FreeFormFinsPrints(self)
         self.plots = _FreeFormFinsPlots(self)
@@ -187,215 +167,24 @@ def evaluate_center_of_pressure(self):
         self.cpz = cpz
         self.cp = (self.cpx, self.cpy, self.cpz)
 
-    def evaluate_geometrical_parameters(self):  # pylint: disable=too-many-statements
-        """
-        Calculates and saves the fin set's geometrical parameters such as the
-        fin area, aspect ratio, and parameters related to roll movement. This
-        method uses the same calculations to those in OpenRocket for free-form
-        fin shapes.
-
-        Returns
-        -------
-        None
-        """
-        # pylint: disable=invalid-name
-        # pylint: disable=too-many-locals
-        # Calculate the fin area (Af) using the Shoelace theorem (polygon area formula)
-        Af = 0
-        for i in range(len(self.shape_points) - 1):
-            x1, y1 = self.shape_points[i]
-            x2, y2 = self.shape_points[i + 1]
-            Af += (y1 + y2) * (x1 - x2)
-        Af = abs(Af) / 2
-        if Af < 1e-6:
-            raise ValueError("Fin area is too small. Check the shape_points.")
-
-        # Calculate aspect ratio (AR) and lift interference factors
-        AR = 2 * self.span**2 / Af  # Aspect ratio
-        tau = (self.span + self.rocket_radius) / self.rocket_radius
-        lift_interference_factor = 1 + 1 / tau
-
-        # Calculate roll forcing interference factor using OpenRocket's approach
-        roll_forcing_interference_factor = (1 / np.pi**2) * (
-            (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2)
-            + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2))
-            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
-            - (2 * np.pi * (tau + 1)) / (tau * (tau - 1))
-            + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
-            * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2
-            - (4 * (tau + 1))
-            / (tau * (tau - 1))
-            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
-            + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau))
-        )
-
-        # Define number of interpolation points along the span of the fin
-        points_per_line = 40  # Same as OpenRocket
-
-        # Initialize arrays for leading/trailing edge and chord lengths
-        chord_lead = np.ones(points_per_line) * np.inf  # Leading edge x coordinates
-        chord_trail = np.ones(points_per_line) * -np.inf  # Trailing edge x coordinates
-        chord_length = np.zeros(
-            points_per_line
-        )  # Chord length for each spanwise section
-
-        # Discretize fin shape and calculate chord length, leading, and trailing edges
-        for p in range(1, len(self.shape_points)):
-            x1, y1 = self.shape_points[p - 1]
-            x2, y2 = self.shape_points[p]
-
-            # Compute corresponding points along the fin span (clamp to valid range)
-            prev_idx = int(y1 / self.span * (points_per_line - 1))
-            curr_idx = int(y2 / self.span * (points_per_line - 1))
-            prev_idx = np.clip(prev_idx, 0, points_per_line - 1)
-            curr_idx = np.clip(curr_idx, 0, points_per_line - 1)
-
-            if prev_idx > curr_idx:
-                prev_idx, curr_idx = curr_idx, prev_idx
-
-            # Compute intersection of fin edge with each spanwise section
-            for i in range(prev_idx, curr_idx + 1):
-                y = i * self.span / (points_per_line - 1)
-                if y1 != y2:
-                    x = np.clip(
-                        (y - y2) / (y1 - y2) * x1 + (y1 - y) / (y1 - y2) * x2,
-                        min(x1, x2),
-                        max(x1, x2),
-                    )
-                else:
-                    x = x1  # Handle horizontal segments
-
-                # Update leading and trailing edge positions
-                chord_lead[i] = min(chord_lead[i], x)
-                chord_trail[i] = max(chord_trail[i], x)
-
-                # Update chord length
-                if y1 < y2:
-                    chord_length[i] -= x
-                else:
-                    chord_length[i] += x
-
-        # Replace infinities and handle invalid values in chord_lead and chord_trail
-        for i in range(points_per_line):
-            if (
-                np.isinf(chord_lead[i])
-                or np.isinf(chord_trail[i])
-                or np.isnan(chord_lead[i])
-                or np.isnan(chord_trail[i])
-            ):
-                chord_lead[i] = 0
-                chord_trail[i] = 0
-            if chord_length[i] < 0 or np.isnan(chord_length[i]):
-                chord_length[i] = 0
-            if chord_length[i] > chord_trail[i] - chord_lead[i]:
-                chord_length[i] = chord_trail[i] - chord_lead[i]
-
-        # Initialize integration variables for various aerodynamic and roll properties
-        radius = self.rocket_radius
-        total_area = 0
-        mac_length = 0  # Mean aerodynamic chord length
-        mac_lead = 0  # Mean aerodynamic chord leading edge
-        mac_span = 0  # Mean aerodynamic chord spanwise position (Yma)
-        cos_gamma_sum = 0  # Sum of cosine of the sweep angle
-        roll_geometrical_constant = 0
-        roll_damping_numerator = 0
-        roll_damping_denominator = 0
-
-        # Perform integration over spanwise sections
-        dy = self.span / (points_per_line - 1)
-        for i in range(points_per_line):
-            chord = chord_trail[i] - chord_lead[i]
-            y = i * dy
-
-            # Update integration variables
-            mac_length += chord * chord
-            mac_span += y * chord
-            mac_lead += chord_lead[i] * chord
-            total_area += chord
-            roll_geometrical_constant += chord_length[i] * (radius + y) ** 2
-            roll_damping_numerator += radius**3 * chord / (radius + y) ** 2
-            roll_damping_denominator += (radius + y) * chord
-
-            # Update cosine of sweep angle (cos_gamma)
-            if i > 0:
-                dx = (chord_trail[i] + chord_lead[i]) / 2 - (
-                    chord_trail[i - 1] + chord_lead[i - 1]
-                ) / 2
-                cos_gamma_sum += dy / np.hypot(dx, dy)
-
-        # Finalize mean aerodynamic chord properties
-        mac_length *= dy
-        mac_span *= dy
-        mac_lead *= dy
-        total_area *= dy
-        roll_geometrical_constant *= dy
-        roll_damping_numerator *= dy
-        roll_damping_denominator *= dy
-
-        mac_length /= total_area
-        mac_span /= total_area
-        mac_lead /= total_area
-        cos_gamma = cos_gamma_sum / (points_per_line - 1)
-
-        # Store computed values
-        self.Af = Af  # Fin area
-        self.AR = AR  # Aspect ratio
-        self.gamma_c = np.arccos(cos_gamma)  # Sweep angle
-        self.Yma = mac_span  # Mean aerodynamic chord spanwise position
-        self.mac_length = mac_length
-        self.mac_lead = mac_lead
-        self.tau = tau
-        self.roll_geometrical_constant = roll_geometrical_constant
-        self.lift_interference_factor = lift_interference_factor
-        self.roll_forcing_interference_factor = roll_forcing_interference_factor
-        self.roll_damping_interference_factor = 1 + (
-            roll_damping_numerator / roll_damping_denominator
-        )
-
-        # Evaluate the shape and finalize geometry
-        self.evaluate_shape()
-
-    def evaluate_shape(self):
-        x_array, y_array = zip(*self.shape_points)
-        self.shape_vec = [np.array(x_array), np.array(y_array)]
+    @property
+    def shape_points(self):
+        return self.geometry.shape_points
 
     def to_dict(self, **kwargs):
         data = super().to_dict(**kwargs)
-        data["shape_points"] = self.shape_points
-
-        if kwargs.get("include_outputs", False):
-            data.update(
-                {
-                    "Af": self.Af,
-                    "AR": self.AR,
-                    "gamma_c": self.gamma_c,
-                    "Yma": self.Yma,
-                    "mac_length": self.mac_length,
-                    "mac_lead": self.mac_lead,
-                    "roll_geometrical_constant": self.roll_geometrical_constant,
-                    "tau": self.tau,
-                    "lift_interference_factor": self.lift_interference_factor,
-                    "roll_forcing_interference_factor": self.roll_forcing_interference_factor,
-                    "roll_damping_interference_factor": self.roll_damping_interference_factor,
-                }
-            )
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
         return data
 
     @classmethod
     def from_dict(cls, data):
         return cls(
-            data["n"],
-            data["shape_points"],
-            data["rocket_radius"],
-            data["cant_angle"],
-            data["airfoil"],
-            data["name"],
+            n=data["n"],
+            shape_points=data["shape_points"],
+            rocket_radius=data["rocket_radius"],
+            cant_angle=data["cant_angle"],
+            airfoil=data["airfoil"],
+            name=data["name"],
         )
-
-    def info(self):
-        self.prints.geometry()
-        self.prints.lift()
-
-    def all_info(self):
-        self.prints.all()
-        self.plots.all()
diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py
new file mode 100644
index 000000000..872aebe97
--- /dev/null
+++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py
@@ -0,0 +1,242 @@
+from rocketpy.plots.aero_surface_plots import _TrapezoidalFinPlots
+from rocketpy.prints.aero_surface_prints import _TrapezoidalFinPrints
+from rocketpy.rocket.aero_surface.fins._geometry import _TrapezoidalGeometry
+
+from .fin import Fin
+
+
+class TrapezoidalFin(Fin):
+    """A class used to represent a single trapezoidal fin.
+
+    This class inherits from the Fin class.
+
+    Note
+    ----
+    Local coordinate system:
+        - Origin located at the top of the root chord.
+        - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom).
+        - Y axis perpendicular to the Z axis, in the span direction, positive upwards.
+        - X axis completes the right-handed coordinate system.
+
+    See Also
+    --------
+    Fin : Parent class
+
+    Attributes
+    ----------
+    TrapezoidalFin.angular_position : float
+        Angular position of the fin set with respect to the rocket centerline,
+        in degrees.
+    TrapezoidalFin.rocket_radius : float
+        The reference rocket radius used for lift coefficient normalization, in
+        meters.
+    TrapezoidalFin.airfoil : tuple
+        Tuple of two items. First is the airfoil lift curve.
+        Second is the unit of the curve (radians or degrees).
+    TrapezoidalFin.cant_angle : float
+        Fins cant angle with respect to the rocket centerline, in degrees.
+    TrapezoidalFin.cant_angle_rad : float
+        Fins cant angle with respect to the rocket centerline, in radians.
+    TrapezoidalFin.root_chord : float
+        Fin root chord in meters.
+    TrapezoidalFin.tip_chord : float
+        Fin tip chord in meters.
+    TrapezoidalFin.span : float
+        Fin span in meters.
+    TrapezoidalFin.name : string
+        Name of fin set.
+    TrapezoidalFin.sweep_length : float
+        Fins sweep length in meters. By sweep length, understand the axial
+        distance between the fin root leading edge and the fin tip leading edge
+        measured parallel to the rocket centerline.
+    TrapezoidalFin.sweep_angle : float
+        Fins sweep angle with respect to the rocket centerline. Must
+        be given in degrees.
+    TrapezoidalFin.rocket_diameter : float
+        Reference diameter of the rocket, in meters.
+    TrapezoidalFins.fin_area : float
+        Area of the longitudinal section of each fin in the set.
+    TrapezoidalFins.AR : float
+        Aspect ratio of the fin.
+    TrapezoidalFin.gamma_c : float
+        Fin mid-chord sweep angle.
+    TrapezoidalFin.yma : float
+        Span wise position of the mean aerodynamic chord.
+    TrapezoidalFin.roll_geometrical_constant : float
+        Geometrical constant used in roll calculations.
+    TrapezoidalFin.tau : float
+        Geometrical relation used to simplify lift and roll calculations.
+    TrapezoidalFin.lift_interference_factor : float
+        Factor of Fin-Body interference in the lift coefficient.
+    TrapezoidalFin.cp : tuple
+        Tuple with the x, y and z local coordinates of the fin set center of
+        pressure. Has units of length and is given in meters.
+    TrapezoidalFin.cpx : float
+        Fin set local center of pressure x coordinate. Has units of length and
+        is given in meters.
+    TrapezoidalFin.cpy : float
+        Fin set local center of pressure y coordinate. Has units of length and
+        is given in meters.
+    TrapezoidalFin.cpz : float
+        Fin set local center of pressure z coordinate. Has units of length and
+        is given in meters.
+    """
+
+    def __init__(
+        self,
+        angular_position,
+        root_chord,
+        tip_chord,
+        span,
+        rocket_radius,
+        cant_angle=0,
+        sweep_length=None,
+        sweep_angle=None,
+        airfoil=None,
+        name="Trapezoidal Fin",
+    ):
+        """Initializes the TrapezoidalFin class.
+
+        Parameters
+        ----------
+        angular_position : float
+            Angular position of the fin in degrees measured as the rotation
+            around the symmetry axis of the rocket relative to one of the other
+            principal axis. See :ref:`Angular Position Inputs `
+        root_chord : int, float
+            Fin root chord in meters.
+        tip_chord : int, float
+            Fin tip chord in meters.
+        span : int, float
+            Fin span in meters.
+        rocket_radius : int, float
+            Reference radius to calculate lift coefficient, in meters.
+        cant_angle : int, float, optional
+            Fins cant angle with respect to the rocket centerline. Must
+            be given in degrees.
+        sweep_length : int, float, optional
+            Fins sweep length in meters. By sweep length, understand the axial
+            distance between the fin root leading edge and the fin tip leading
+            edge measured parallel to the rocket centerline. If not given, the
+            sweep length is assumed to be equal the root chord minus the tip
+            chord, in which case the fin is a right trapezoid with its base
+            perpendicular to the rocket's axis. Cannot be used in conjunction
+            with sweep_angle.
+        sweep_angle : int, float, optional
+            Fins sweep angle with respect to the rocket centerline. Must
+            be given in degrees. If not given, the sweep angle is automatically
+            calculated, in which case the fin is assumed to be a right trapezoid
+            with its base perpendicular to the rocket's axis.
+            Cannot be used in conjunction with sweep_length.
+        airfoil : tuple, optional
+            Default is null, in which case fins will be treated as flat plates.
+            Otherwise, if tuple, fins will be considered as airfoils. The
+            tuple's first item specifies the airfoil's lift coefficient
+            by angle of attack and must be either a .csv, .txt, ndarray
+            or callable. The .csv and .txt files can contain a single line
+            header and the first column must specify the angle of attack, while
+            the second column must specify the lift coefficient. The
+            ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...]
+            where x0 is the angle of attack and y0 is the lift coefficient.
+            If callable, it should take an angle of attack as input and
+            return the lift coefficient at that angle of attack.
+            The tuple's second item is the unit of the angle of attack,
+            accepting either "radians" or "degrees".
+        name : str
+            Name of the trapezoidal fin.
+        """
+        super().__init__(
+            angular_position,
+            root_chord,
+            span,
+            rocket_radius,
+            cant_angle,
+            airfoil,
+            name,
+        )
+
+        self.geometry = _TrapezoidalGeometry(
+            self,
+            tip_chord=tip_chord,
+            sweep_length=sweep_length,
+            sweep_angle=sweep_angle,
+        )
+        self._update_geometry_chain()
+        self.evaluate_shape()
+        self.evaluate_rotation_matrix()
+
+        self.prints = _TrapezoidalFinPrints(self)
+        self.plots = _TrapezoidalFinPlots(self)
+
+    @property
+    def tip_chord(self):
+        return self.geometry.tip_chord
+
+    @tip_chord.setter
+    def tip_chord(self, value):
+        self.geometry.tip_chord = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
+
+    @property
+    def sweep_angle(self):
+        return self.geometry.sweep_angle
+
+    @sweep_angle.setter
+    def sweep_angle(self, value):
+        self.geometry.sweep_angle = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
+
+    @property
+    def sweep_length(self):
+        return self.geometry.sweep_length
+
+    @sweep_length.setter
+    def sweep_length(self, value):
+        self.geometry.sweep_length = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
+
+    def evaluate_center_of_pressure(self):
+        """Calculates and returns the center of pressure of the fin in local
+        coordinates. The center of pressure position is saved and stored as a
+        tuple.
+
+        Returns
+        -------
+        None
+        """
+        # Center of pressure position in local coordinates
+        cpz = (self.sweep_length / 3) * (
+            (self.root_chord + 2 * self.tip_chord) / (self.root_chord + self.tip_chord)
+        ) + (1 / 6) * (
+            self.root_chord
+            + self.tip_chord
+            - self.root_chord * self.tip_chord / (self.root_chord + self.tip_chord)
+        )
+        self.cpx = 0
+        self.cpy = self.Yma
+        self.cpz = cpz
+        self.cp = (self.cpx, self.cpy, self.cpz)
+
+    def to_dict(self, **kwargs):
+        data = super().to_dict(**kwargs)
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
+        return data
+
+    @classmethod
+    def from_dict(cls, data):
+        return cls(
+            angular_position=data["angular_position"],
+            root_chord=data["root_chord"],
+            tip_chord=data["tip_chord"],
+            span=data["span"],
+            rocket_radius=data["rocket_radius"],
+            cant_angle=data["cant_angle"],
+            sweep_length=data.get("sweep_length"),
+            airfoil=data["airfoil"],
+            name=data["name"],
+        )
diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py
index c6b4ea633..2c9adea58 100644
--- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py
+++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py
@@ -1,7 +1,6 @@
-import numpy as np
-
 from rocketpy.plots.aero_surface_plots import _TrapezoidalFinsPlots
 from rocketpy.prints.aero_surface_prints import _TrapezoidalFinsPrints
+from rocketpy.rocket.aero_surface.fins._geometry import _TrapezoidalGeometry
 
 from .fins import Fins
 
@@ -35,9 +34,6 @@ class TrapezoidalFins(Fins):
         Second is the unit of the curve (radians or degrees).
     TrapezoidalFins.cant_angle : float
         Fins cant angle with respect to the rocket centerline, in degrees.
-    TrapezoidalFins.changing_attribute_dict : dict
-        Dictionary that stores the name and the values of the attributes that
-        may be changed during a simulation. Useful for control systems.
     TrapezoidalFins.cant_angle_rad : float
         Fins cant angle with respect to the rocket centerline, in radians.
     TrapezoidalFins.root_chord : float
@@ -55,9 +51,9 @@ class TrapezoidalFins(Fins):
     TrapezoidalFins.sweep_angle : float
         Fins sweep angle with respect to the rocket centerline. Must
         be given in degrees.
-    TrapezoidalFins.d : float
+    TrapezoidalFins.rocket_diameter : float
         Reference diameter of the rocket, in meters.
-    TrapezoidalFins.ref_area : float
+    TrapezoidalFins.reference_area : float
         Reference area of the rocket, in m².
     TrapezoidalFins.Af : float
         Area of the longitudinal section of each fin in the set.
@@ -169,62 +165,47 @@ def __init__(
             name,
         )
 
-        # Check if sweep angle or sweep length is given
-        if sweep_length is not None and sweep_angle is not None:
-            raise ValueError("Cannot use sweep_length and sweep_angle together")
-        elif sweep_angle is not None:
-            sweep_length = np.tan(sweep_angle * np.pi / 180) * span
-        elif sweep_length is None:
-            sweep_length = root_chord - tip_chord
-
-        self._tip_chord = tip_chord
-        self._sweep_length = sweep_length
-        self._sweep_angle = sweep_angle
-
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
+        self.geometry = _TrapezoidalGeometry(
+            self,
+            tip_chord=tip_chord,
+            sweep_length=sweep_length,
+            sweep_angle=sweep_angle,
+        )
+        self._update_geometry_chain()
+        self.evaluate_shape()
 
         self.prints = _TrapezoidalFinsPrints(self)
         self.plots = _TrapezoidalFinsPlots(self)
 
     @property
     def tip_chord(self):
-        return self._tip_chord
+        return self.geometry.tip_chord
 
     @tip_chord.setter
     def tip_chord(self, value):
-        self._tip_chord = value
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
+        self.geometry.tip_chord = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
 
     @property
     def sweep_angle(self):
-        return self._sweep_angle
+        return self.geometry.sweep_angle
 
     @sweep_angle.setter
     def sweep_angle(self, value):
-        self._sweep_angle = value
-        self._sweep_length = np.tan(value * np.pi / 180) * self.span
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
+        self.geometry.sweep_angle = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
 
     @property
     def sweep_length(self):
-        return self._sweep_length
+        return self.geometry.sweep_length
 
     @sweep_length.setter
     def sweep_length(self, value):
-        self._sweep_length = value
-        self.evaluate_geometrical_parameters()
-        self.evaluate_center_of_pressure()
-        self.evaluate_lift_coefficient()
-        self.evaluate_roll_parameters()
+        self.geometry.sweep_length = value
+        self._update_geometry_chain()
+        self.evaluate_shape()
 
     def evaluate_center_of_pressure(self):
         """Calculates and returns the center of pressure of the fin set in local
@@ -248,124 +229,11 @@ def evaluate_center_of_pressure(self):
         self.cpz = cpz
         self.cp = (self.cpx, self.cpy, self.cpz)
 
-    def evaluate_geometrical_parameters(self):  # pylint: disable=too-many-statements
-        """Calculates and saves fin set's geometrical parameters such as the
-        fins' area, aspect ratio and parameters for roll movement.
-
-        Returns
-        -------
-        None
-        """
-        # pylint: disable=invalid-name
-        Yr = self.root_chord + self.tip_chord
-        Af = Yr * self.span / 2  # Fin area
-        AR = 2 * self.span**2 / Af  # Fin aspect ratio
-        gamma_c = np.arctan(
-            (self.sweep_length + 0.5 * self.tip_chord - 0.5 * self.root_chord)
-            / (self.span)
-        )
-        Yma = (
-            (self.span / 3) * (self.root_chord + 2 * self.tip_chord) / Yr
-        )  # Span wise coord of mean aero chord
-
-        # Fin–body interference correction parameters
-        tau = (self.span + self.rocket_radius) / self.rocket_radius
-        lift_interference_factor = 1 + 1 / tau
-        lambda_ = self.tip_chord / self.root_chord
-
-        # Parameters for Roll Moment.
-        # Documented at: https://docs.rocketpy.org/en/latest/technical/
-        roll_geometrical_constant = (
-            (self.root_chord + 3 * self.tip_chord) * self.span**3
-            + 4
-            * (self.root_chord + 2 * self.tip_chord)
-            * self.rocket_radius
-            * self.span**2
-            + 6 * (self.root_chord + self.tip_chord) * self.span * self.rocket_radius**2
-        ) / 12
-        roll_damping_interference_factor = 1 + (
-            ((tau - lambda_) / (tau)) - ((1 - lambda_) / (tau - 1)) * np.log(tau)
-        ) / (
-            ((tau + 1) * (tau - lambda_)) / (2)
-            - ((1 - lambda_) * (tau**3 - 1)) / (3 * (tau - 1))
-        )
-        roll_forcing_interference_factor = (1 / np.pi**2) * (
-            (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2)
-            + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2))
-            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
-            - (2 * np.pi * (tau + 1)) / (tau * (tau - 1))
-            + ((tau**2 + 1) ** 2)
-            / (tau**2 * (tau - 1) ** 2)
-            * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2
-            - (4 * (tau + 1))
-            / (tau * (tau - 1))
-            * np.arcsin((tau**2 - 1) / (tau**2 + 1))
-            + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau))
-        )
-
-        # Store values
-        self.Yr = Yr
-        self.Af = Af  # Fin area
-        self.AR = AR  # Aspect Ratio
-        self.gamma_c = gamma_c  # Mid chord angle
-        self.Yma = Yma  # Span wise coord of mean aero chord
-        self.roll_geometrical_constant = roll_geometrical_constant
-        self.tau = tau
-        self.lift_interference_factor = lift_interference_factor
-        self.λ = lambda_  # pylint: disable=non-ascii-name
-        self.roll_damping_interference_factor = roll_damping_interference_factor
-        self.roll_forcing_interference_factor = roll_forcing_interference_factor
-
-        self.evaluate_shape()
-
-    def evaluate_shape(self):
-        if self.sweep_length:
-            points = [
-                (0, 0),
-                (self.sweep_length, self.span),
-                (self.sweep_length + self.tip_chord, self.span),
-                (self.root_chord, 0),
-            ]
-        else:
-            points = [
-                (0, 0),
-                (self.root_chord - self.tip_chord, self.span),
-                (self.root_chord, self.span),
-                (self.root_chord, 0),
-            ]
-
-        x_array, y_array = zip(*points)
-        self.shape_vec = [np.array(x_array), np.array(y_array)]
-
-    def info(self):
-        self.prints.geometry()
-        self.prints.lift()
-
-    def all_info(self):
-        self.prints.all()
-        self.plots.all()
-
     def to_dict(self, **kwargs):
         data = super().to_dict(**kwargs)
-        data["tip_chord"] = self.tip_chord
-        data["sweep_length"] = self.sweep_length
-        data["sweep_angle"] = self.sweep_angle
-
-        if kwargs.get("include_outputs", False):
-            data.update(
-                {
-                    "shape_vec": self.shape_vec,
-                    "Af": self.Af,
-                    "AR": self.AR,
-                    "gamma_c": self.gamma_c,
-                    "Yma": self.Yma,
-                    "roll_geometrical_constant": self.roll_geometrical_constant,
-                    "tau": self.tau,
-                    "lift_interference_factor": self.lift_interference_factor,
-                    "roll_damping_interference_factor": self.roll_damping_interference_factor,
-                    "roll_forcing_interference_factor": self.roll_forcing_interference_factor,
-                }
-            )
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
         return data
 
     @classmethod
diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py
index 092ecd89e..bcbdb326f 100644
--- a/rocketpy/rocket/aero_surface/generic_surface.py
+++ b/rocketpy/rocket/aero_surface/generic_surface.py
@@ -85,6 +85,8 @@ def __init__(
         self.cpz = center_of_pressure[2]
         self.name = name
 
+        self._rotation_surface_to_body = Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
+
         default_coefficients = self._get_default_coefficients()
         self._check_coefficients(coefficients, default_coefficients)
         coefficients = self._complete_coefficients(coefficients, default_coefficients)
@@ -380,7 +382,7 @@ def _process_input(self, input_data, coeff_name):
                 " or a callable."
             )
 
-    def __load_generic_surface_csv(self, file_path, coeff_name):  # pylint: disable=too-many-statements,import-outside-toplevel
+    def __load_generic_surface_csv(self, file_path, coeff_name):  # pylint: disable=too-many-statements
         """Load GenericSurface coefficient CSV into a 7D Function.
 
         This loader expects header-based CSV data with one or more independent
diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py
index 240a61a5c..bebe63642 100644
--- a/rocketpy/rocket/aero_surface/nose_cone.py
+++ b/rocketpy/rocket/aero_surface/nose_cone.py
@@ -1,3 +1,4 @@
+import logging
 import warnings
 
 import numpy as np
@@ -9,6 +10,8 @@
 
 from .aero_surface import AeroSurface
 
+logger = logging.getLogger(__name__)
+
 
 class NoseCone(AeroSurface):
     """Keeps nose cone information.
@@ -78,7 +81,7 @@ class NoseCone(AeroSurface):
         more about it.
     """
 
-    def __init__(  # pylint: disable=too-many-statements
+    def __init__(
         self,
         length,
         kind,
@@ -219,7 +222,7 @@ def kind(self):
         return self._kind
 
     @kind.setter
-    def kind(self, value):  # pylint: disable=too-many-statements
+    def kind(self, value):
         # Analyzes nosecone type
         # Sets the k for Cp calculation
         # Sets the function which creates the respective curve
@@ -370,7 +373,7 @@ def evaluate_geometrical_parameters(self):
 
         self.fineness_ratio = self.length / (2 * self.base_radius)
 
-    def evaluate_nose_shape(self):  # pylint: disable=too-many-statements
+    def evaluate_nose_shape(self):
         """Calculates and saves nose cone's shape as lists and re-evaluates the
         nose cone's length for a given bluffness ratio. The shape is saved as
         two vectors, one for the x coordinates and one for the y coordinates.
@@ -445,9 +448,11 @@ def final_shape(x):
         self.shape_vec = [nosecone_x, nosecone_y]
         if abs(nosecone_x[-1] - self.length) >= 0.001:  # 1 millimeter
             self._length = nosecone_x[-1]
-            print(
-                "Due to the chosen bluffness ratio, the nose "
-                f"cone length was reduced to {self.length} m."
+            warnings.warn(
+                f"Due to the chosen bluffness ratio, the nose cone length was "
+                f"reduced to {self.length:.4f} m.",
+                UserWarning,
+                stacklevel=2,
             )
         self.fineness_ratio = self.length / (2 * self.base_radius)
 
diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py
index 89331c99f..7d3a9bd30 100644
--- a/rocketpy/rocket/aero_surface/rail_buttons.py
+++ b/rocketpy/rocket/aero_surface/rail_buttons.py
@@ -17,6 +17,7 @@ class RailButtons(AeroSurface):
         Angular position of the rail buttons in degrees measured
         as the rotation around the symmetry axis of the rocket
         relative to one of the other principal axis.
+        See :ref:`Angular Position Inputs `
     RailButtons.angular_position_rad : float
         Angular position of the rail buttons in radians.
     RailButtons.button_height : float, optional
diff --git a/rocketpy/rocket/components.py b/rocketpy/rocket/components.py
index 9ce8595b3..57e4d12f8 100644
--- a/rocketpy/rocket/components.py
+++ b/rocketpy/rocket/components.py
@@ -1,4 +1,5 @@
 from collections import namedtuple
+from copy import deepcopy
 
 
 class Components:
@@ -186,7 +187,7 @@ def clear(self):
         self._components.clear()
 
     def sort_by_position(self, reverse=False):
-        """Sort the list of components by z axis position.
+        """Returns a new Components object sorted components by z axis position.
 
         Parameters
         ----------
@@ -196,9 +197,12 @@ def sort_by_position(self, reverse=False):
 
         Returns
         -------
-        None
+        Components
+            A new Components object sorted by component position.
         """
-        self._components.sort(key=lambda x: x.position.z, reverse=reverse)
+        components = deepcopy(self)
+        components._components.sort(key=lambda x: x.position.z, reverse=reverse)
+        return components
 
     def to_dict(self, **kwargs):  # pylint: disable=unused-argument
         return {
diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py
index 4e0318d18..00b56d8b5 100644
--- a/rocketpy/rocket/parachute.py
+++ b/rocketpy/rocket/parachute.py
@@ -24,25 +24,34 @@ class Parachute:
         This parameter defines the trigger condition for the parachute ejection
         system. It can be one of the following:
 
-        - A callable function that takes three arguments:
-          1. Freestream pressure in pascals.
-          2. Height in meters above ground level.
-          3. The state vector of the simulation, which is defined as:
-
-             `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`.
-
-          4. A list of sensors that are attached to the rocket. The most recent
-             measurements of the sensors are provided with the
-             ``sensor.measurement`` attribute. The sensors are listed in the same
-             order as they are added to the rocket.
-
-          The function should return ``True`` if the parachute ejection system
-          should be triggered and False otherwise. The function will be called
-          according to the specified sampling rate.
+        - A callable function that can take 3, 4, or 5 arguments:
+
+          **3 arguments**:
+            1. Freestream pressure in pascals.
+            2. Height in meters above ground level.
+            3. The state vector: ``[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]``
+
+          **4 arguments** (sensors OR acceleration):
+            1. Freestream pressure in pascals.
+            2. Height in meters above ground level.
+            3. The state vector.
+            4. Either:
+               - ``sensors``: List of sensor objects attached to the rocket, OR
+               - ``u_dot``: State derivative including accelerations at indices [3:6]
+
+          **5 arguments** (sensors AND acceleration):
+            1. Freestream pressure in pascals.
+            2. Height in meters above ground level.
+            3. The state vector.
+            4. ``sensors``: List of sensor objects.
+            5. ``u_dot``: State derivative with accelerations ``[vx, vy, vz, ax, ay, az, ...]``
+
+          The function should return ``True`` to trigger deployment, ``False`` otherwise.
+          The function will be called according to the specified sampling rate.
 
         - A float value, representing an absolute height in meters. In this
           case, the parachute will be ejected when the rocket reaches this height
-          above ground level.
+          above ground level while descending.
 
         - The string "apogee" which triggers the parachute at apogee, i.e.,
           when the rocket reaches its highest point and starts descending.
@@ -51,12 +60,12 @@ class Parachute:
     Parachute.triggerfunc : function
         Trigger function created from the trigger used to evaluate the trigger
         condition for the parachute ejection system. It is a callable function
-        that takes three arguments: Freestream pressure in Pa, Height above
-        ground level in meters, and the state vector of the simulation. The
-        returns ``True`` if the parachute ejection system should be triggered
+        that takes five arguments: Freestream pressure in Pa, Height above
+        ground level in meters, the state vector, sensors list, and u_dot.
+        Returns ``True`` if the parachute ejection system should be triggered
         and ``False`` otherwise.
 
-        .. note:
+        .. note::
 
             The function will be called according to the sampling rate specified.
 
@@ -268,59 +277,102 @@ def __init_noise(self, noise):
         self.noisy_pressure_signal_function = Function(0)
         self.noise_signal_function = Function(0)
         alpha, beta = self.noise_corr
-        self.noise_function = lambda: (
-            alpha * self.noise_signal[-1][1]
-            + beta * np.random.normal(noise[0], noise[1])
-        )
+        if noise == (0, 0, 0):
+            self.noise_function = lambda: 0.0
+        else:
+            self.noise_function = lambda: (
+                alpha * self.noise_signal[-1][1]
+                + beta * np.random.normal(noise[0], noise[1])
+            )
 
-    def __evaluate_trigger_function(self, trigger):
+    def __evaluate_trigger_function(self, trigger):  # pylint: disable=too-many-statements
         """This is used to set the triggerfunc attribute that will be used to
         interact with the Flight class.
+
+        Notes
+        -----
+        The resulting triggerfunc always has signature (p, h, y, sensors, u_dot)
+        so Flight can pass both sensors and u_dot when needed.
         """
-        # pylint: disable=unused-argument, function-redefined
+        # pylint: disable=function-redefined
+        self._trigger_falling_only = False
+        self._trigger_needs_height = True
+
+        # Helper to wrap any callable to the internal (p, h, y, sensors, u_dot) API
+        def _make_wrapper(fn):
+            sig = signature(fn)
+            params = list(sig.parameters.keys())
+
+            # detect if user function expects acceleration-like argument
+            expects_udot = any(
+                name.lower() in ("u_dot", "udot", "acc", "acceleration")
+                for name in params[3:]
+            )
 
-        # Case 1: The parachute is deployed by a custom function
+            def wrapper(p, h, y, sensors, u_dot):
+                # Support 3, 4, and 5-arg user functions
+                num_params = len(sig.parameters)
+                if num_params == 3:
+                    return fn(p, h, y)
+                if num_params == 4:
+                    # Check which 4th arg to pass
+                    fourth_param = params[3].lower()
+                    if fourth_param in ("u_dot", "udot", "acc", "acceleration"):
+                        return fn(p, h, y, u_dot)
+                    else:
+                        return fn(p, h, y, sensors)
+                if num_params >= 5:
+                    # Pass both sensors and u_dot
+                    return fn(p, h, y, sensors, u_dot)
+                # If function signature is not supported, raise an error
+                raise TypeError(
+                    f"Trigger function '{fn.__name__}' has unsupported signature: "
+                    f"expected 3, 4, or 5+ arguments, got {num_params}. "
+                    "Please check the function definition."
+                )
+
+            # attach metadata so Flight can decide whether to compute u_dot
+            wrapper._expects_udot = expects_udot
+            return wrapper
+
+        # Callable provided by user
         if callable(trigger):
-            # work around for having added sensors to parachute triggers
-            # to avoid breaking changes
-            triggerfunc = trigger
-            sig = signature(triggerfunc)
-            if len(sig.parameters) == 3:
-
-                def triggerfunc(p, h, y, sensors):
-                    return trigger(p, h, y)
+            self.triggerfunc = _make_wrapper(trigger)
+            return
 
-            self.triggerfunc = triggerfunc
+        # Numeric altitude trigger
+        if isinstance(trigger, (int, float)):
+            self._trigger_falling_only = True
 
-        # Case 2: The parachute is deployed at a given height
-        elif isinstance(trigger, (int, float)):
-            # The parachute is deployed at a given height
-            def triggerfunc(p, h, y, sensors):
+            def triggerfunc(p, h, y, sensors, u_dot):  # pylint: disable=unused-argument
                 # p = pressure considering parachute noise signal
                 # h = height above ground level considering parachute noise signal
                 # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3]
                 return y[5] < 0 and h < trigger
 
+            triggerfunc._expects_udot = False
             self.triggerfunc = triggerfunc
+            return
 
-        # Case 3: The parachute is deployed at apogee
-        elif trigger.lower() == "apogee":
-            # The parachute is deployed at apogee
-            def triggerfunc(p, h, y, sensors):
-                # p = pressure considering parachute noise signal
-                # h = height above ground level considering parachute noise signal
-                # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3]
+        # Special case: "apogee"
+        if isinstance(trigger, str) and trigger.lower() == "apogee":
+            self._trigger_falling_only = True
+            self._trigger_needs_height = False
+
+            def triggerfunc(p, h, y, sensors, u_dot):  # pylint: disable=unused-argument
                 return y[5] < 0
 
+            triggerfunc._expects_udot = False
             self.triggerfunc = triggerfunc
-
-        # Case 4: Invalid trigger input
-        else:
-            raise ValueError(
-                f"Unable to set the trigger function for parachute '{self.name}'. "
-                + "Trigger must be a callable, a float value or the string 'apogee'. "
-                + "See the Parachute class documentation for more information."
-            )
+            return
+
+        # If we reach this point, the trigger is invalid
+        raise ValueError(
+            f"Unable to set the trigger function for parachute '{self.name}'. "
+            + "Trigger must be a callable, a float value or one of the strings "
+            + "('apogee'). "
+            + "See the Parachute class documentation for more information."
+        )
 
     def __str__(self):
         """Returns a string representation of the Parachute class.
diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py
index af183e30b..ec06b85bc 100644
--- a/rocketpy/rocket/rocket.py
+++ b/rocketpy/rocket/rocket.py
@@ -1,12 +1,19 @@
 import csv
 import inspect
+import logging
 import math
+import numbers
 import warnings
 from typing import Iterable
 
 import numpy as np
 
 from rocketpy.control.controller import _Controller
+from rocketpy.exceptions import (
+    InvalidInertiaError,
+    InvalidParameterError,
+    UnstableRocketWarning,
+)
 from rocketpy.mathutils.function import Function
 from rocketpy.mathutils.vector_matrix import Matrix, Vector
 from rocketpy.motors.empty_motor import EmptyMotor
@@ -24,7 +31,10 @@
     Tail,
     TrapezoidalFins,
 )
+from rocketpy.rocket.aero_surface.fins.elliptical_fin import EllipticalFin
+from rocketpy.rocket.aero_surface.fins.free_form_fin import FreeFormFin
 from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins
+from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin
 from rocketpy.rocket.aero_surface.generic_surface import GenericSurface
 from rocketpy.rocket.components import Components
 from rocketpy.rocket.parachute import Parachute
@@ -34,6 +44,8 @@
     parallel_axis_theorem_from_com,
 )
 
+logger = logging.getLogger(__name__)
+
 
 # pylint: disable=too-many-instance-attributes, too-many-public-methods, too-many-instance-attributes
 class Rocket:
@@ -318,6 +330,29 @@ def __init__(  # pylint: disable=too-many-statements
                     + '"tail_to_nose" and "nose_to_tail".'
                 )
 
+        # Validate inputs. Accept Python and NumPy numeric scalars for
+        # radius/mass, and any length-3 or length-6 sequence (tuple, list or
+        # numpy array) for inertia, matching the permissive behavior of earlier
+        # versions (numpy inputs are common when computing inertia tensors).
+        if not isinstance(radius, numbers.Real) or radius <= 0:
+            raise InvalidParameterError(
+                f"Rocket radius must be a positive number, got {radius!r}."
+            )
+        if not isinstance(mass, numbers.Real) or mass <= 0:
+            raise InvalidParameterError(
+                f"Rocket mass must be a positive number, got {mass!r}."
+            )
+        try:
+            inertia_length = len(inertia)
+        except TypeError:
+            inertia_length = None
+        if isinstance(inertia, str) or inertia_length not in (3, 6):
+            raise InvalidInertiaError(
+                "Inertia must be a length-3 (I_11, I_22, I_33) or length-6 "
+                "(I_11, I_22, I_33, I_12, I_13, I_23) sequence, "
+                f"got {inertia!r}."
+            )
+
         # Define rocket inertia attributes in SI units
         self.mass = mass
         inertia = (*inertia, 0, 0, 0) if len(inertia) == 3 else inertia
@@ -493,7 +528,7 @@ def evaluate_total_mass(self):
         """
         # Make sure there is a motor associated with the rocket
         if self.motor is None:
-            print("Please associate this rocket with a motor!")
+            logger.warning("Please associate this rocket with a motor!")
             return False
 
         self.total_mass = self.mass + self.motor.total_mass
@@ -513,7 +548,7 @@ def evaluate_dry_mass(self):
         """
         # Make sure there is a motor associated with the rocket
         if self.motor is None:
-            print("Please associate this rocket with a motor!")
+            logger.warning("Please associate this rocket with a motor!")
             return False
 
         self.dry_mass = self.mass + self.motor.dry_mass
@@ -593,10 +628,9 @@ def evaluate_reduced_mass(self):
         self.reduced_mass : Function
             Function of time expressing the reduced mass of the rocket.
         """
-        # TODO: add tests for reduced_mass values
         # Make sure there is a motor associated with the rocket
         if self.motor is None:
-            print("Please associate this rocket with a motor!")
+            logger.warning("Please associate this rocket with a motor!")
             return False
 
         # Get nicknames
@@ -679,14 +713,20 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position):
         """Calculates the relative position of each aerodynamic surface
         center of pressure to the rocket's center of dry mass in Body Axes
         Coordinate System."""
-        pos = Vector(
+        # position of the surfaces coordinate system origin in body frame
+        pos_origin = Vector(
             [
-                (position.x - self.cm_eccentricity_x) * self._csys - surface.cpx,
-                (position.y - self.cm_eccentricity_y) - surface.cpy,
-                (position.z - self.center_of_dry_mass_position) * self._csys
-                - surface.cpz,
+                (position.x - self.cm_eccentricity_x) * self._csys,
+                (position.y - self.cm_eccentricity_y),
+                (position.z - self.center_of_dry_mass_position) * self._csys,
             ]
         )
+        # position of the center of pressure in body frame
+        pos = (
+            surface._rotation_surface_to_body
+            @ Vector([surface.cpx, surface.cpy, surface.cpz])
+            + pos_origin
+        )  # TODO: this should be recomputed whenever cant angle changes for fin
         self.surfaces_cp_to_cdm[surface] = pos
 
     def evaluate_stability_margin(self):
@@ -745,6 +785,45 @@ def evaluate_static_margin(self):
         )
         return self.static_margin
 
+    def warn_if_unstable(self):
+        """Warn if the rocket is aerodynamically unstable at motor ignition.
+
+        Emits an :class:`UnstableRocketWarning` when the static margin at
+        ``t=0`` is negative. This is meant to be checked once the rocket is
+        fully assembled (e.g. when a :class:`Flight` is created), not during
+        incremental construction, so that partially-built-but-ultimately-stable
+        rockets do not raise spurious warnings.
+
+        The check is skipped when ``GenericSurface`` instances are present:
+        their lift coefficient derivative is not accounted for in
+        ``evaluate_center_of_pressure``, so the computed static margin does not
+        reflect their contribution and cannot be trusted for this check.
+
+        Returns
+        -------
+        bool
+            ``True`` if a warning was emitted, ``False`` otherwise.
+        """
+        has_generic_surface = any(
+            isinstance(aero_surface, GenericSurface)
+            for aero_surface, _position in self.aerodynamic_surfaces
+        )
+        if has_generic_surface:
+            return False
+
+        initial_static_margin = self.static_margin.get_value_opt(0)
+        if initial_static_margin < 0:
+            warnings.warn(
+                f"The rocket has a negative static margin "
+                f"({initial_static_margin:.2f} cal) at motor ignition (t=0), "
+                "indicating an aerodynamically unstable configuration. Check the "
+                "placement of fins and nose cone relative to the center of mass.",
+                UnstableRocketWarning,
+                stacklevel=2,
+            )
+            return True
+        return False
+
     def evaluate_volume(self):
         """Calculates the total volume of the rocket including the motor.
 
@@ -1058,9 +1137,9 @@ def add_motor(self, motor, position):  # pylint: disable=too-many-statements
         if hasattr(self, "motor"):
             # pylint: disable=access-member-before-definition
             if not isinstance(self.motor, EmptyMotor):
-                print(
+                logger.warning(
                     "Only one motor per rocket is currently supported. "
-                    + "Overwriting previous motor."
+                    "Overwriting previous motor."
                 )
         self.motor = motor
         self.motor_position = position
@@ -1097,11 +1176,20 @@ def __add_single_surface(self, surface, position):
         """Adds a single aerodynamic surface to the rocket. Makes checks for
         rail buttons case, and position type.
         """
-        position = (
-            Vector([0, 0, position])
-            if not isinstance(position, (Vector, tuple, list))
-            else Vector(position)
-        )
+        if isinstance(surface, (TrapezoidalFin, EllipticalFin, FreeFormFin)):
+            # TODO: the leading edge position should be recomputed whenever cant
+            # angle of the fin changes, but currently it is only computed at the
+            # moment the fin is added to the rocket. Detecting when the cant
+            # angle changes is hard, because it is a parameter of the fin, while
+            # the leading edge position is only defined on the rocket
+            position = surface._compute_leading_edge_position(position, self._csys)
+        else:
+            position = (
+                Vector([0, 0, position])
+                if not isinstance(position, (Vector, tuple, list))
+                else Vector(position)
+            )
+
         if isinstance(surface, RailButtons):
             self.rail_buttons = Components()
             self.rail_buttons.add(surface, position)
@@ -1116,17 +1204,23 @@ def add_surfaces(self, surfaces, positions):
 
         Parameters
         ----------
-        surfaces : list, AeroSurface, NoseCone, TrapezoidalFins, EllipticalFins, Tail, RailButtons
+        surfaces : list[AeroSurface], AeroSurface
             Aerodynamic surface to be added to the rocket. Can be a list of
             AeroSurface if more than one surface is to be added.
-        positions : int, float, list, tuple, Vector
-            Position, in m, of the aerodynamic surface's center of pressure
-            relative to the user defined rocket coordinate system.
-            If a list is passed, it will correspond to the position of each item
-            in the surfaces list.
-            For NoseCone type, position is relative to the nose cone tip.
-            For Fins type, position is relative to the point belonging to
-            the root chord which is highest in the rocket coordinate system.
+        positions : int, float, tuple, list, Vector
+            Position(s) of the aerodynamic surface's reference point. Can be:
+
+            - a single number (int or float) giving the z-coordinate along
+              the rocket axis.
+            - a sequence of three numbers (x, y, z) representing the full
+              position in the user-defined coordinate system.
+
+            If passing multiple surfaces, provide a list of positions matching
+            each surface in order.
+            For NoseCone type, position is the tip coordinate along the axis.
+            For Fins type, position refers to the z-coordinate of the root
+            chord leading-edge point closest to the nose cone, before any
+            cant-angle offset is considered.
             For Tail type, position is relative to the point belonging to the
             tail which is highest in the rocket coordinate system.
             For RailButtons type, position is relative to the lower rail button.
@@ -1139,10 +1233,18 @@ def add_surfaces(self, surfaces, positions):
         -------
         None
         """
-        try:
+        if isinstance(surfaces, Iterable):
+            if isinstance(positions, Iterable):
+                if len(surfaces) != len(positions):
+                    raise ValueError(
+                        "The number of surfaces and positions must be the same."
+                    )
+            else:
+                positions = [positions] * len(surfaces)
+
             for surface, position in zip(surfaces, positions):
                 self.__add_single_surface(surface, position)
-        except TypeError:
+        else:
             self.__add_single_surface(surfaces, positions)
 
         self.evaluate_center_of_pressure()
@@ -1316,10 +1418,10 @@ def add_trapezoidal_fins(
         tip_chord : int, float
             Fin tip chord in meters.
         position : int, float
-            Fin set position relative to the rocket's coordinate system.
-            By fin set position, understand the point belonging to the root
-            chord which is highest in the rocket coordinate system (i.e.
-            the point closest to the nose cone tip).
+            Fin set position in the z coordinate of the user defined rocket
+            coordinate system. By fin set position, understand the point
+            belonging to the root chord which is highest in the rocket
+            coordinate system (i.e. the point closest to the nose cone tip).
 
             See Also
             --------
@@ -1365,6 +1467,15 @@ def add_trapezoidal_fins(
         fin_set : TrapezoidalFins
             Fin set object created.
         """
+        if n <= 2:
+            warnings.warn(
+                "Fin sets with 2 or fewer fins assume a symmetric, evenly-spaced "
+                "configuration and may not accurately capture asymmetric forces. "
+                "For 1 or 2 fins, consider creating individual fin objects "
+                "(e.g. TrapezoidalFin) and adding them with add_surfaces.",
+                UserWarning,
+                stacklevel=2,
+            )
 
         # Modify radius if not given, use rocket radius, otherwise use given.
         radius = radius if radius is not None else self.radius
@@ -1412,10 +1523,10 @@ def add_elliptical_fins(
         span : int, float
             Fin span in meters.
         position : int, float
-            Fin set position relative to the rocket's coordinate system. By fin
-            set position, understand the point belonging to the root chord which
-            is highest in the rocket coordinate system (i.e. the point
-            closest to the nose cone tip).
+            Fin set position in the z coordinate of the user defined rocket
+            coordinate system. By fin set position, understand the point
+            belonging to the root chord which is highest in the rocket
+            coordinate system (i.e. the point closest to the nose cone tip).
 
             See Also
             --------
@@ -1451,6 +1562,16 @@ def add_elliptical_fins(
         fin_set : EllipticalFins
             Fin set object created.
         """
+        if n <= 2:
+            warnings.warn(
+                "Fin sets with 2 or fewer fins assume a symmetric, evenly-spaced "
+                "configuration and may not accurately capture asymmetric forces. "
+                "For 1 or 2 fins, consider creating individual fin objects "
+                "(e.g. TrapezoidalFin) and adding them with add_surfaces.",
+                UserWarning,
+                stacklevel=2,
+            )
+
         radius = radius if radius is not None else self.radius
         fin_set = EllipticalFins(n, root_chord, span, radius, cant_angle, airfoil, name)
         self.add_surfaces(fin_set, position)
@@ -1482,10 +1603,10 @@ def add_free_form_fins(
             The shape will be interpolated between the points, in the order
             they are given. The last point connects to the first point.
         position : int, float
-            Fin set position relative to the rocket's coordinate system.
-            By fin set position, understand the point belonging to the root
-            chord which is highest in the rocket coordinate system (i.e.
-            the point closest to the nose cone tip).
+            Fin set position in the z coordinate of the user defined rocket
+            coordinate system. By fin set position, understand the point
+            belonging to the root chord which is highest in the rocket
+            coordinate system (i.e. the point closest to the nose cone tip).
 
             See Also
             --------
@@ -1517,6 +1638,15 @@ def add_free_form_fins(
         fin_set : FreeFormFins
             Fin set object created.
         """
+        if n <= 2:
+            warnings.warn(
+                "Fin sets with 2 or fewer fins assume a symmetric, evenly-spaced "
+                "configuration and may not accurately capture asymmetric forces. "
+                "For 1 or 2 fins, consider creating individual fin objects "
+                "(e.g. TrapezoidalFin) and adding them with add_surfaces.",
+                UserWarning,
+                stacklevel=2,
+            )
 
         # Modify radius if not given, use rocket radius, otherwise use given.
         radius = radius if radius is not None else self.radius
@@ -1661,8 +1791,7 @@ def add_sensor(self, sensor, position):
             must be in the format (x, y, z) where x, y, and z are defined in the
             rocket's user defined coordinate system. If a single value is
             passed, it is assumed to be along the z-axis (centerline) of the
-            rocket's user defined coordinate system and angular_position and
-            radius must be given.
+            rocket's user defined coordinate system.
 
         Returns
         -------
@@ -1728,15 +1857,19 @@ def add_air_brakes(
             This function is expected to take the following arguments, in order:
 
             1. `time` (float): The current simulation time in seconds.
-            2. `sampling_rate` (float): The rate at which the controller
-               function is called, measured in Hertz (Hz).
+            2. `sampling_rate` (float or None): The rate at which the controller
+               function is called, measured in Hertz (Hz). It is None for
+               continuous controllers (called every solver step), so any
+               `1 / sampling_rate` computation must guard against None.
             3. `state` (list): The state vector of the simulation, structured as
                `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`.
             4. `state_history` (list): A record of the rocket's state at each
-               step throughout the simulation. The state_history is organized as a
-               list of lists, with each sublist containing a state vector. The last
-               item in the list always corresponds to the previous state vector,
-               providing a chronological sequence of the rocket's evolving states.
+               step throughout the simulation. It is organized as a list of
+               lists, ordered oldest to newest, where each sublist is a
+               *time-prefixed* state row `[t, x, y, z, vx, vy, vz, e0, e1, e2,
+               e3, wx, wy, wz]` (the same layout as `Flight.solution`, one
+               leading `time` element ahead of the `state` layout in item 3).
+               The last item corresponds to the most recent recorded step.
             5. `observed_variables` (list): A list containing the variables that
                the controller function returns. The initial value in the first
                step of the simulation of this list is provided by the
@@ -1927,7 +2060,6 @@ def add_thrust_vector_control(
             Controller object created (only if return_controller is True).
         """
         if hasattr(self, "thrust_vector_control"):
-            # pylint: disable=access-member-before-definition
             warnings.warn(
                 "Only one thrust_vector_control per rocket is currently supported. "
                 + "Overwriting previous thrust_vector_control and controllers."
@@ -2059,7 +2191,6 @@ def add_roll_control(
             Controller object created (only if return_controller is True).
         """
         if hasattr(self, "roll_control"):
-            # pylint: disable=access-member-before-definition
             warnings.warn(
                 "Only one roll control per rocket is currently supported. "
                 + "Overwriting previous roll control and controllers."
@@ -2257,7 +2388,7 @@ def set_rail_buttons(
             as the rotation around the symmetry axis of the rocket
             relative to one of the other principal axis.
             Default value is 45 degrees, generally used in rockets with
-            4 fins.
+            4 fins. See :ref:`Angular Position Inputs `
         radius : int, float, optional
             Fuselage radius where the rail buttons are located.
 
@@ -2752,7 +2883,7 @@ def _count_positional_args(callable_obj):
             "Function, or callable."
         )
 
-    def __load_rocket_drag_csv(self, file_path, coeff_name):  # pylint: disable=too-many-statements,import-outside-toplevel
+    def __load_rocket_drag_csv(self, file_path, coeff_name):  # pylint: disable=too-many-statements
         """Load Rocket drag CSV into a 7D Function.
 
         Supports either headerless two-column (mach, coefficient) tables or
diff --git a/rocketpy/sensitivity/sensitivity_model.py b/rocketpy/sensitivity/sensitivity_model.py
index 428897bff..737a46097 100644
--- a/rocketpy/sensitivity/sensitivity_model.py
+++ b/rocketpy/sensitivity/sensitivity_model.py
@@ -1,9 +1,13 @@
+import logging
+
 import numpy as np
 
 from rocketpy.plots.sensitivity_plots import _SensitivityModelPlots
 from rocketpy.prints.sensitivity_prints import _SensitivityModelPrints
 from rocketpy.tools import check_requirement_version, import_optional_dependency
 
+logger = logging.getLogger(__name__)
+
 
 class SensitivityModel:
     """Performs a 'local variance based first-order
@@ -354,11 +358,13 @@ def __check_requirements(self):
                 check_requirement_version(module_name, version)
             except (ValueError, ImportError) as e:  # pragma: no cover
                 has_error = True
-                print(
-                    f"The following error occurred while importing {module_name}: {e}"
+                logger.error(
+                    "The following error occurred while importing %s: %s",
+                    module_name,
+                    e,
                 )
         if has_error:  # pragma: no cover
-            print(
+            logger.error(
                 "Given the above errors, some methods may not work. Please run "
-                + "'pip install rocketpy[sensitivity]' to install extra requirements."
+                "'pip install rocketpy[sensitivity]' to install extra requirements."
             )
diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py
index a6ee7b27a..b6a477c11 100644
--- a/rocketpy/sensors/accelerometer.py
+++ b/rocketpy/sensors/accelerometer.py
@@ -170,6 +170,11 @@ def __init__(
             acceleration. Default is False.
         name : str, optional
             The name of the sensor. Default is "Accelerometer".
+        seed : int, optional
+            Seed for the random number generator that draws the measurement
+            noise. If given, the noise becomes reproducible and independent of
+            the process-global NumPy RNG. Default is None, meaning the noise is
+            seeded from fresh entropy per instance.
 
         Returns
         -------
@@ -301,4 +306,5 @@ def from_dict(cls, data):
             cross_axis_sensitivity=data["cross_axis_sensitivity"],
             consider_gravity=data["consider_gravity"],
             name=data["name"],
+            seed=data.get("seed"),
         )
diff --git a/rocketpy/sensors/barometer.py b/rocketpy/sensors/barometer.py
index 9a41df893..afb6c09eb 100644
--- a/rocketpy/sensors/barometer.py
+++ b/rocketpy/sensors/barometer.py
@@ -111,6 +111,11 @@ def __init__(
             meaning no temperature scale factor is applied.
         name : str, optional
             The name of the sensor. Default is "Barometer".
+        seed : int, optional
+            Seed for the random number generator that draws the measurement
+            noise. If given, the noise becomes reproducible and independent of
+            the process-global NumPy RNG. Default is None, meaning the noise is
+            seeded from fresh entropy per instance.
 
         Returns
         -------
@@ -208,4 +213,5 @@ def from_dict(cls, data):
             temperature_bias=data["temperature_bias"],
             temperature_scale_factor=data["temperature_scale_factor"],
             name=data["name"],
+            seed=data.get("seed"),
         )
diff --git a/rocketpy/sensors/gnss_receiver.py b/rocketpy/sensors/gnss_receiver.py
index 467e8531b..e46352e7c 100644
--- a/rocketpy/sensors/gnss_receiver.py
+++ b/rocketpy/sensors/gnss_receiver.py
@@ -57,6 +57,11 @@ def __init__(
             velocity in meters per second. Default is 0.
         name : str
             The name of the sensor. Default is "GnssReceiver".
+        seed : int, optional
+            Seed for the random number generator that draws the measurement
+            noise. If given, the noise becomes reproducible and independent of
+            the process-global NumPy RNG. Default is None, meaning the noise is
+            seeded from fresh entropy per instance.
         """
         super().__init__(sampling_rate=sampling_rate, name=name, seed=seed)
         self.position_accuracy = position_accuracy
@@ -133,6 +138,7 @@ def to_dict(self, **kwargs):
             "altitude_accuracy": self.altitude_accuracy,
             "velocity_accuracy": self.velocity_accuracy,
             "name": self.name,
+            "seed": self._seed,
         }
 
     @classmethod
@@ -143,4 +149,5 @@ def from_dict(cls, data):
             altitude_accuracy=data["altitude_accuracy"],
             velocity_accuracy=data["velocity_accuracy"],
             name=data["name"],
+            seed=data.get("seed"),
         )
diff --git a/rocketpy/sensors/gyroscope.py b/rocketpy/sensors/gyroscope.py
index 942411d8c..8d4169a19 100644
--- a/rocketpy/sensors/gyroscope.py
+++ b/rocketpy/sensors/gyroscope.py
@@ -170,6 +170,13 @@ def __init__(
             float or int is given, the same sensitivity is applied to all axes.
             The values of each axis can be set individually by passing a list of
             length 3.
+        name : str, optional
+            The name of the sensor. Default is "Gyroscope".
+        seed : int, optional
+            Seed for the random number generator that draws the measurement
+            noise. If given, the noise becomes reproducible and independent of
+            the process-global NumPy RNG. Default is None, meaning the noise is
+            seeded from fresh entropy per instance.
 
         Returns
         -------
@@ -322,4 +329,5 @@ def from_dict(cls, data):
             cross_axis_sensitivity=data["cross_axis_sensitivity"],
             acceleration_sensitivity=data["acceleration_sensitivity"],
             name=data["name"],
+            seed=data.get("seed"),
         )
diff --git a/rocketpy/sensors/sensor.py b/rocketpy/sensors/sensor.py
index 73a532db9..ecc873a95 100644
--- a/rocketpy/sensors/sensor.py
+++ b/rocketpy/sensors/sensor.py
@@ -1,4 +1,5 @@
 import json
+import logging
 import warnings
 from abc import ABC, abstractmethod
 
@@ -6,6 +7,8 @@
 
 from rocketpy.mathutils.vector_matrix import Matrix, Vector
 
+logger = logging.getLogger(__name__)
+
 
 # pylint: disable=too-many-statements
 class Sensor(ABC):
@@ -109,6 +112,11 @@ def __init__(
             meaning no temperature scale factor is applied.
         name : str, optional
             The name of the sensor. Default is "Sensor".
+        seed : int, optional
+            Seed for the random number generator that draws the measurement
+            noise. If given, the noise becomes reproducible and independent of
+            the process-global NumPy RNG. Default is None, meaning the noise is
+            seeded from fresh entropy per instance.
 
         Returns
         -------
@@ -142,10 +150,12 @@ def __init__(
         self._save_data = self._save_data_single
         self._random_walk_drift = 0
         self.normal_vector = Vector([0, 0, 0])
-        # Per-instance RNG seeded deterministically (from the scenario seed when
-        # provided) so sensor noise is reproducible and independent of the
-        # process-global numpy RNG -- and therefore safe under parallel/forked
-        # evaluation. ``seed=None`` keeps the noise random but still per-instance.
+
+        # Per-instance RNG, seeded deterministically when a seed is given, so
+        # the measurement noise is reproducible and independent of the
+        # process-global NumPy RNG (and therefore safe under parallel or
+        # forked evaluation). seed=None keeps the noise random but still
+        # drawn from this instance's generator.
         self._seed = seed
         self._rng = np.random.default_rng(seed)
 
@@ -242,24 +252,25 @@ def _generic_export_measured_data(self, filename, file_format, data_labels):
         if file_format.lower() == "csv":
             # if sensor has been added multiple times to the simulated rocket
             if isinstance(self.measured_data[0], list):
-                print("Data saved to", end=" ")
+                saved = []
                 for i, data in enumerate(self.measured_data):
                     with open(filename + f"_{i + 1}", "w") as f:
                         f.write(",".join(data_labels) + "\n")
                         for entry in data:
                             f.write(",".join(map(str, entry)) + "\n")
-                    print(filename + f"_{i + 1},", end=" ")
+                    saved.append(filename + f"_{i + 1}")
+                logger.info("Data saved to: %s", ", ".join(saved))
             else:
                 with open(filename, "w") as f:
                     f.write(",".join(data_labels) + "\n")
                     for entry in self.measured_data:
                         f.write(",".join(map(str, entry)) + "\n")
-                print(f"Data saved to {filename}")
+                logger.info("Data saved to %s", filename)
             return
 
         if file_format.lower() == "json":
             if isinstance(self.measured_data[0], list):
-                print("Data saved to", end=" ")
+                saved = []
                 for i, data in enumerate(self.measured_data):
                     data_dict = {label: [] for label in data_labels}
                     for entry in data:
@@ -267,7 +278,8 @@ def _generic_export_measured_data(self, filename, file_format, data_labels):
                             data_dict[label].append(value)
                     with open(filename + f"_{i + 1}", "w") as f:
                         json.dump(data_dict, f)
-                    print(filename + f"_{i + 1},", end=" ")
+                    saved.append(filename + f"_{i + 1}")
+                logger.info("Data saved to: %s", ", ".join(saved))
             else:
                 data_dict = {label: [] for label in data_labels}
                 for entry in self.measured_data:
@@ -275,7 +287,7 @@ def _generic_export_measured_data(self, filename, file_format, data_labels):
                         data_dict[label].append(value)
                 with open(filename, "w") as f:
                     json.dump(data_dict, f)
-                print(f"Data saved to {filename}")
+                logger.info("Data saved to %s", filename)
             return
 
     # pylint: disable=unused-argument
@@ -293,6 +305,7 @@ def to_dict(self, **kwargs):
             "temperature_bias": self.temperature_bias,
             "temperature_scale_factor": self.temperature_scale_factor,
             "name": self.name,
+            "seed": self._seed,
         }
 
 
@@ -344,7 +357,7 @@ class InertialSensor(Sensor):
         temperature drift.
     """
 
-    def __init__(
+    def __init__(  # pylint: disable=too-many-arguments
         self,
         sampling_rate,
         orientation=(0, 0, 0),
@@ -447,6 +460,11 @@ def __init__(
             no cross-axis sensitivity is applied.
         name : str, optional
             The name of the sensor. Default is "Sensor".
+        seed : int, optional
+            Seed for the random number generator that draws the measurement
+            noise. If given, the noise becomes reproducible and independent of
+            the process-global NumPy RNG. Default is None, meaning the noise is
+            seeded from fresh entropy per instance.
 
         Returns
         -------
@@ -714,6 +732,11 @@ def __init__(
             meaning no temperature scale factor is applied.
         name : str, optional
             The name of the sensor. Default is "Sensor".
+        seed : int, optional
+            Seed for the random number generator that draws the measurement
+            noise. If given, the noise becomes reproducible and independent of
+            the process-global NumPy RNG. Default is None, meaning the noise is
+            seeded from fresh entropy per instance.
 
         Returns
         -------
diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py
index 34ccd0143..43fb6210c 100644
--- a/rocketpy/simulation/flight.py
+++ b/rocketpy/simulation/flight.py
@@ -1,4 +1,5 @@
 # pylint: disable=too-many-lines
+import logging
 import math
 import warnings
 from copy import deepcopy
@@ -7,8 +8,6 @@
 import numpy as np
 from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, OdeSolver, Radau
 
-from rocketpy.simulation.flight_data_exporter import FlightDataExporter
-
 from ..mathutils.function import Function, funcify_method
 from ..mathutils.vector_matrix import Matrix, Vector
 from ..motors.point_mass_motor import PointMassMotor
@@ -22,11 +21,14 @@
     find_closest,
     find_root_linear_interpolation,
     find_roots_cubic_function,
+    inverted_haversine,
     quaternions_to_nutation,
     quaternions_to_precession,
     quaternions_to_spin,
 )
 
+logger = logging.getLogger(__name__)
+
 ODE_SOLVER_MAP = {
     "RK23": RK23,
     "RK45": RK45,
@@ -318,13 +320,13 @@ class Flight:
         as a function of time. Expressed in Newtons (N).
     Flight.M1 : Function
         Aerodynamic moment acting along the x-axis of the rocket's body
-        frame as a function of time. Expressed in Newtons (N).
+        frame as a function of time. Expressed in Newton-Meters (N-m).
     Flight.M2 : Function
         Aerodynamic moment acting along the y-axis of the rocket's body
-        frame as a function of time. Expressed in Newtons (N).
+        frame as a function of time. Expressed in Newton-Meters (N-m).
     Flight.M3 : Function
         Aerodynamic moment and roll control moment acting along the z-axis of
-        the rocket's body frame as a function of time. Expressed in Newtons (N).
+        the rocket's body frame as a function of time. Expressed in Newton-Meters (N-m).
     Flight.net_thrust : Function
         Rocket's engine net thrust as a function of time in Newton.
         This is the actual thrust force experienced by the rocket.
@@ -588,11 +590,12 @@ def __init__(  # pylint: disable=too-many-arguments,too-many-statements
             A custom ``scipy.integrate.OdeSolver`` can be passed as well.
             For more information on the integration methods, see the scipy
             documentation [1]_.
+        simulation_mode : str, optional
+            Simulation mode to use. Can be "6 DOF" for 6 degrees of freedom or
+            "3 DOF" for 3 degrees of freedom. Default is "6 DOF".
         run_simulation : bool, optional
             Whether to run the simulation immediately after initialization.
             Default is True. Set to False to only initialize the Flight object.
-
-
         Returns
         -------
         None
@@ -604,6 +607,11 @@ def __init__(  # pylint: disable=too-many-arguments,too-many-statements
         # Save arguments
         self.env = environment
         self.rocket = rocket
+        # Warn about an aerodynamically unstable rocket now that it is fully
+        # assembled and about to be simulated. Doing it here (instead of on every
+        # add_surfaces call during construction) avoids spurious warnings for
+        # partially-built rockets that are ultimately stable.
+        self.rocket.warn_if_unstable()
         self.rail_length = rail_length
         if self.rail_length <= 0:  # pragma: no cover
             raise ValueError("Rail length must be a positive value.")
@@ -695,6 +703,7 @@ def post_process_simulation(self):
             self.__cache_sensor_data()
         if self.verbose:
             print(f"\n>>> Simulation Completed at Time: {self.t:3.4f} s")
+        logger.info("Simulation completed at time: %3.4f s", self.t)
 
     def __repr__(self):
         return (
@@ -706,7 +715,7 @@ def __repr__(self):
             f"name= {self.name})>"
         )
 
-    # pylint: disable=too-many-nested-blocks, too-many-branches, too-many-locals,too-many-statements
+    # pylint: disable=too-many-locals,too-many-statements
     def __simulate(self, verbose):
         """Simulate the flight trajectory."""
         for phase_index, phase in self.time_iterator(self.flight_phases):
@@ -763,11 +772,14 @@ def __simulate(self, verbose):
                     ) = self.__calculate_and_save_pressure_signals(
                         parachute, node.t, self.y_sol[2]
                     )
-                    if parachute.triggerfunc(
+                    if self._evaluate_parachute_trigger(
+                        parachute,
                         noisy_pressure,
                         height_above_ground_level,
                         self.y_sol,
                         self.sensors,
+                        phase.derivative,
+                        self.t,
                     ):
                         # Remove parachute from flight parachutes
                         self.parachutes.remove(parachute)
@@ -834,7 +846,16 @@ def __simulate(self, verbose):
                     self.y_sol = phase.solver.y
                     if verbose:
                         print(f"Current Simulation Time: {self.t:3.4f} s", end="\r")
-
+                        logger.debug("Current Simulation Time: %3.4f s", self.t)
+
+                    for controller in self._continuous_controllers:
+                        controller(
+                            self.t,
+                            self.y_sol,
+                            self.solution,
+                            self.sensors,
+                            self.env,
+                        )
                     if self.__check_simulation_events(phase, phase_index, node_index):
                         break  # Stop if simulation termination event occurred
 
@@ -946,6 +967,16 @@ def step_simulation(self):
             self.y_sol = phase.solver.y
             if self.verbose:
                 print(f"Current Simulation Time: {self.t:3.4f} s", end="\r")
+                logger.debug("Current Simulation Time: %3.4f s", self.t)
+
+            for controller in self._continuous_controllers:
+                controller(
+                    self.t,
+                    self.y_sol,
+                    self.solution,
+                    self.sensors,
+                    self.env,
+                )
 
             if self.__check_simulation_events(phase, phase_index, node_index):
                 break  # Stop if simulation termination event occurred
@@ -1088,11 +1119,14 @@ def __check_and_handle_parachute_triggers(
             ) = self.__calculate_and_save_pressure_signals(
                 parachute, node.t, self.y_sol[2]
             )
-            if not parachute.triggerfunc(
+            if not self._evaluate_parachute_trigger(
+                parachute,
                 noisy_pressure,
                 height_above_ground_level,
                 self.y_sol,
                 self.sensors,
+                phase.derivative,
+                node.t,
             ):
                 continue  # Check next parachute
 
@@ -1492,22 +1526,39 @@ def __check_overshootable_parachute_triggers(
             True if a parachute was triggered and the simulation should break.
         """
         for parachute in overshootable_node.parachutes:
+            is_ascending = overshootable_node.y_sol[5] >= 0
+            trigger_falling_only = getattr(parachute, "_trigger_falling_only", False)
+            trigger_needs_height = getattr(parachute, "_trigger_needs_height", True)
+
+            if trigger_falling_only and is_ascending:
+                # Fast path
+                self.__calculate_and_save_pressure_signals(
+                    parachute,
+                    overshootable_node.t,
+                    overshootable_node.y_sol[2],
+                    skip_height=True,
+                )
+                continue
+
             # Calculate and save pressure signal
-            (
-                noisy_pressure,
-                height_above_ground_level,
-            ) = self.__calculate_and_save_pressure_signals(
-                parachute,
-                overshootable_node.t,
-                overshootable_node.y_sol[2],
+            noisy_pressure, height_above_ground_level = (
+                self.__calculate_and_save_pressure_signals(
+                    parachute,
+                    overshootable_node.t,
+                    overshootable_node.y_sol[2],
+                    skip_height=not trigger_needs_height,
+                )
             )
 
             # Check for parachute trigger
-            if not parachute.triggerfunc(
+            if not self._evaluate_parachute_trigger(
+                parachute,
                 noisy_pressure,
                 height_above_ground_level,
                 overshootable_node.y_sol,
                 self.sensors,
+                phase.derivative,
+                overshootable_node.t,
             ):
                 continue  # Check next parachute
 
@@ -1573,7 +1624,7 @@ def __check_overshootable_parachute_triggers(
 
         return False
 
-    def __calculate_and_save_pressure_signals(self, parachute, t, z):
+    def __calculate_and_save_pressure_signals(self, parachute, t, z, skip_height=False):
         """Gets noise and pressure signals and saves them in the parachute
         object given the current time and altitude.
 
@@ -1600,6 +1651,9 @@ def __calculate_and_save_pressure_signals(self, parachute, t, z):
         parachute.clean_pressure_signal.append([t, pressure])
         parachute.noise_signal.append([t, noise])
 
+        if skip_height:
+            return noisy_pressure, 0.0
+
         # Gets height above ground level considering noise
         height_above_ground_level = (
             self.env.barometric_height.get_value_opt(noisy_pressure)
@@ -1608,6 +1662,51 @@ def __calculate_and_save_pressure_signals(self, parachute, t, z):
 
         return noisy_pressure, height_above_ground_level
 
+    def _evaluate_parachute_trigger(
+        self, parachute, pressure, height, y, sensors, derivative_func, t
+    ):
+        """Evaluate parachute trigger, passing both sensors and u_dot to wrapper.
+
+        This helper preserves backward compatibility with existing trigger
+        signatures. The wrapper in Parachute always expects (p, h, y, sensors, u_dot)
+        and Flight computes u_dot only when the trigger requests it (optimization).
+
+        Parameters
+        ----------
+        parachute : Parachute
+            Parachute object.
+        pressure : float
+            Noisy pressure value passed to trigger.
+        height : float
+            Height above ground level passed to trigger.
+        y : array
+            State vector at evaluation time.
+        sensors : list
+            Sensors list passed to trigger.
+        derivative_func : callable
+            Function to compute derivatives: derivative_func(t, y)
+        t : float
+            Time at which to evaluate derivatives.
+
+        Returns
+        -------
+        bool
+            True if trigger condition met, False otherwise.
+        """
+        triggerfunc = parachute.triggerfunc
+
+        # Check wrapper metadata for expectations
+        expects_udot = getattr(triggerfunc, "_expects_udot", False)
+
+        # Compute u_dot only if needed (performance optimization)
+        u_dot = None
+        if expects_udot:
+            u_dot = derivative_func(t, y)
+
+        # Call the wrapper with both sensors and u_dot
+        # The wrapper will decide which args to pass to the user's function
+        return triggerfunc(pressure, height, y, sensors, u_dot)
+
     def __init_solution_monitors(self):
         # Initialize solution monitors
         self.out_of_rail_time = 0
@@ -1745,6 +1844,7 @@ def __init_equations_of_motion(self):
     def __init_controllers(self):
         """Initialize controllers and sensors"""
         self._controllers = self.rocket._controllers[:]
+        self._continuous_controllers = [c for c in self._controllers if c.is_continuous]
         self.sensors = self.rocket.sensors.get_components()
 
         # reset controllable objects to initial state (air brakes, thrust vector control, throttle control, and roll control)
@@ -1943,7 +2043,6 @@ def udot_rail1(self, t, u, post_processing=False):
         effective_thrust = net_thrust * getattr(
             getattr(self.rocket, "throttle_control", None), "throttle", 1.0
         )
-        rho = self.env.density.get_value_opt(z)
         R3 = -0.5 * rho * (free_stream_speed**2) * self.rocket.area * (drag_coeff)
 
         # Calculate Linear acceleration
@@ -3174,19 +3273,19 @@ def R3(self):
     @funcify_method("Time (s)", "M1 (Nm)", "linear", "zero")
     def M1(self):
         """Aerodynamic moment acting along the x-axis of the rocket's body
-        frame as a function of time. Expressed in Newtons (N-m)."""
+        frame as a function of time. Expressed in Newton-Meters (N-m)."""
         return self.__evaluate_post_process[:, [0, 10]]
 
     @funcify_method("Time (s)", "M2 (Nm)", "linear", "zero")
     def M2(self):
         """Aerodynamic moment acting along the y-axis of the rocket's body
-        frame as a function of time. Expressed in Newtons (N-m)."""
+        frame as a function of time. Expressed in Newton-Meters (N-m)."""
         return self.__evaluate_post_process[:, [0, 11]]
 
     @funcify_method("Time (s)", "M3 (Nm)", "linear", "zero")
     def M3(self):
         """Aerodynamic moment and roll control moment acting along the z-axis
-        of the rocket's body frame as a function of time. Expressed in Newtons (N-m)."""
+        of the rocket's body frame as a function of time. Expressed in Newton-Meters (N-m)."""
         return self.__evaluate_post_process[:, [0, 12]]
 
     @funcify_method("Time (s)", "Net Thrust (N)", "linear", "zero")
@@ -4096,40 +4195,29 @@ def latitude(self):
         """Rocket latitude coordinate, in degrees, as a Function of
         time.
         """
-        lat1 = np.deg2rad(self.env.latitude)  # Launch lat point converted to radians
-
         # Applies the haversine equation to find final lat/lon coordinates
-        latitude = np.rad2deg(
-            np.arcsin(
-                np.sin(lat1) * np.cos(self.drift[:, 1] / self.env.earth_radius)
-                + np.cos(lat1)
-                * np.sin(self.drift[:, 1] / self.env.earth_radius)
-                * np.cos(np.deg2rad(self.bearing[:, 1]))
-            )
+        latitude, _ = inverted_haversine(
+            self.env.latitude,
+            self.env.longitude,
+            self.drift[:, 1],
+            self.bearing[:, 1],
+            self.env.earth_radius,
         )
         return np.column_stack((self.time, latitude))
 
-    # TODO: haversine should be defined in tools.py so we just invoke it in here.
     @funcify_method("Time (s)", "Longitude (°)", "linear", "constant")
     def longitude(self):
         """Rocket longitude coordinate, in degrees, as a Function of
         time.
         """
-        lat1 = np.deg2rad(self.env.latitude)  # Launch lat point converted to radians
-        lon1 = np.deg2rad(self.env.longitude)  # Launch lon point converted to radians
-
         # Applies the haversine equation to find final lat/lon coordinates
-        longitude = np.rad2deg(
-            lon1
-            + np.arctan2(
-                np.sin(np.deg2rad(self.bearing[:, 1]))
-                * np.sin(self.drift[:, 1] / self.env.earth_radius)
-                * np.cos(lat1),
-                np.cos(self.drift[:, 1] / self.env.earth_radius)
-                - np.sin(lat1) * np.sin(np.deg2rad(self.latitude[:, 1])),
-            )
+        _, longitude = inverted_haversine(
+            self.env.latitude,
+            self.env.longitude,
+            self.drift[:, 1],
+            self.bearing[:, 1],
+            self.env.earth_radius,
         )
-
         return np.column_stack((self.time, longitude))
 
     def get_controller_observed_variables(self):
@@ -4256,10 +4344,17 @@ def __transform_pressure_signals_lists_to_functions(self):
             parachute.noise_signal_function = Function(
                 parachute.noise_signal, "Time (s)", "Pressure Noise (Pa)", "linear"
             )
+            # Function arithmetic drops the axis labels/title, so restore them
+            # to keep the pressure-signal plots readable (see pressure_signals).
             parachute.noisy_pressure_signal_function = (
                 parachute.clean_pressure_signal_function
                 + parachute.noise_signal_function
             )
+            parachute.noisy_pressure_signal_function.set_inputs("Time (s)")
+            parachute.noisy_pressure_signal_function.set_outputs(
+                "Pressure - With Noise (Pa)"
+            )
+            parachute.noisy_pressure_signal_function.set_title("Noisy Pressure Signal")
 
     @cached_property
     def __evaluate_post_process(self):
@@ -4288,11 +4383,20 @@ def __evaluate_post_process(self):
 
         return np.array(self.__post_processed_variables)
 
-    def calculate_stall_wind_velocity(self, stall_angle):  # TODO: move to utilities
-        """Function to calculate the maximum wind velocity before the angle of
-        attack exceeds a desired angle, at the instant of departing rail launch.
-        Can be helpful if you know the exact stall angle of all aerodynamics
-        surfaces.
+    @deprecated(
+        reason="This method is deprecated in version 1.13.0 and will be fully "
+        "removed by version 1.15.0",
+        alternative="rocketpy.utilities.calculate_stall_wind_velocity",
+    )
+    def calculate_stall_wind_velocity(self, stall_angle):
+        """Calculate the maximum wind velocity before the angle of attack exceeds
+        a desired angle, at the instant of departing rail launch. Can be helpful
+        if you know the exact stall angle of all aerodynamics surfaces.
+
+        .. deprecated:: 1.13.0
+           This method is deprecated and will be fully removed by version
+           1.15.0. Use :func:`rocketpy.utilities.calculate_stall_wind_velocity`
+           instead.
 
         Parameters
         ----------
@@ -4302,97 +4406,23 @@ def calculate_stall_wind_velocity(self, stall_angle):  # TODO: move to utilities
 
         Return
         ------
-        None
-        """
-        v_f = self.out_of_rail_velocity
-
-        theta = np.radians(self.inclination)
-        stall_angle = np.radians(stall_angle)
-
-        c = (math.cos(stall_angle) ** 2 - math.cos(theta) ** 2) / math.sin(
-            stall_angle
-        ) ** 2
-        w_v = (
-            2 * v_f * math.cos(theta) / c
-            + (
-                4 * v_f * v_f * math.cos(theta) * math.cos(theta) / (c**2)
-                + 4 * 1 * v_f * v_f / c
-            )
-            ** 0.5
-        ) / 2
-
-        stall_angle = np.degrees(stall_angle)
-        print(
-            "Maximum wind velocity at Rail Departure time before angle"
-            + f" of attack exceeds {stall_angle:.3f}°: {w_v:.3f} m/s"
-        )
-
-    @deprecated(
-        reason="Moved to FlightDataExporter.export_pressures()",
-        version="v1.12.0",
-        alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_pressures",
-    )
-    def export_pressures(self, file_name, time_step):
-        """
-        .. deprecated:: 1.11
-           Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter`
-           and call ``.export_pressures(...)``.
-        """
-        return FlightDataExporter(self).export_pressures(file_name, time_step)
-
-    @deprecated(
-        reason="Moved to FlightDataExporter.export_data()",
-        version="v1.12.0",
-        alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_data",
-    )
-    def export_data(self, file_name, *variables, time_step=None):
-        """
-        .. deprecated:: 1.11
-           Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter`
-           and call ``.export_data(...)``.
+        float
+            Maximum wind velocity, in m/s, at rail departure before the angle of
+            attack exceeds ``stall_angle``.
         """
-        return FlightDataExporter(self).export_data(
-            file_name, *variables, time_step=time_step
+        # Imported lazily to avoid a circular import (utilities imports Flight).
+        from rocketpy.utilities import (  # pylint: disable=import-outside-toplevel
+            calculate_stall_wind_velocity,
         )
 
-    @deprecated(
-        reason="Moved to FlightDataExporter.export_sensor_data()",
-        version="v1.12.0",
-        alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_sensor_data",
-    )
-    def export_sensor_data(self, file_name, sensor=None):
-        """
-        .. deprecated:: 1.11
-           Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter`
-           and call ``.export_sensor_data(...)``.
-        """
-        return FlightDataExporter(self).export_sensor_data(file_name, sensor=sensor)
-
-    @deprecated(
-        reason="Moved to FlightDataExporter.export_kml()",
-        version="v1.12.0",
-        alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_kml",
-    )
-    def export_kml(
-        self,
-        file_name="trajectory.kml",
-        time_step=None,
-        extrude=True,
-        color="641400F0",
-        altitude_mode="absolute",
-    ):
-        """
-        .. deprecated:: 1.11
-           Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter`
-           and call ``.export_kml(...)``.
-        """
-        return FlightDataExporter(self).export_kml(
-            file_name=file_name,
-            time_step=time_step,
-            extrude=extrude,
-            color=color,
-            altitude_mode=altitude_mode,
+        w_v = calculate_stall_wind_velocity(self, stall_angle)
+        # Display for interactive use, but also return the value so it is never
+        # silently lost (it was previously only logged at INFO level).
+        print(
+            "Maximum wind velocity at Rail Departure time before angle "
+            f"of attack exceeds {stall_angle:.3f}°: {w_v:.3f} m/s"
         )
+        return w_v
 
     def info(self):
         """Prints out a summary of the data available about the Flight."""
@@ -4538,8 +4568,8 @@ def __repr__(self):
             return str(self.list)
 
         def display_warning(self, *messages):  # pragma: no cover
-            """A simple function to print a warning message."""
-            print("WARNING:", *messages)
+            """A simple function to log a warning message."""
+            logger.warning(" ".join(str(m) for m in messages))
 
         def add(self, flight_phase, index=None):  # TODO: quite complex method
             """Add a flight phase to the list. It will be inserted in the
@@ -4759,6 +4789,9 @@ def add_parachutes(self, parachutes, t_init, t_end):
 
         def add_controllers(self, controllers, t_init, t_end):
             for controller in controllers:
+                # Skip node creation for continuous controllers
+                if controller.is_continuous:
+                    continue
                 # Calculate start of sampling time nodes
                 controller_time_step = 1 / controller.sampling_rate
                 controller_node_list = [
diff --git a/rocketpy/simulation/flight_comparator.py b/rocketpy/simulation/flight_comparator.py
index 7f1b34286..e41e49f36 100644
--- a/rocketpy/simulation/flight_comparator.py
+++ b/rocketpy/simulation/flight_comparator.py
@@ -396,7 +396,6 @@ def _plot_external_sources(
                 (rmse / mean_abs_y_sim) * 100 if mean_abs_y_sim != 0 else np.inf
             )
 
-            # Print Metrics
             print(f"Source: {label}")
             print(f"  - MAE:            {mae:.4f}")
             print(f"  - RMSE:           {rmse:.4f}")
@@ -451,7 +450,7 @@ def _finalize_compare_figure(
         if filename:
             print(f"Plot saved to file: {filename}")
 
-    def compare(  # pylint: disable=too-many-statements
+    def compare(
         self,
         attribute,
         time_range=None,
@@ -731,7 +730,7 @@ def _format_key_events_table(self, results):
 
         return "\n".join(lines)
 
-    def summary(self):  # pylint: disable=too-many-statements
+    def summary(self):
         """
         Print comprehensive comparison summary including key events and metrics.
 
diff --git a/rocketpy/simulation/flight_data_exporter.py b/rocketpy/simulation/flight_data_exporter.py
index 8bdc53c17..4065358ae 100644
--- a/rocketpy/simulation/flight_data_exporter.py
+++ b/rocketpy/simulation/flight_data_exporter.py
@@ -3,10 +3,13 @@
 """
 
 import json
+import logging
 
 import numpy as np
 import simplekml
 
+logger = logging.getLogger(__name__)
+
 
 class FlightDataExporter:
     """Export data from a rocketpy.Flight object to various formats."""
@@ -54,10 +57,10 @@ def export_pressures(self, file_name, time_step):
         """
         f = self._flight
         time_points = np.arange(0, f.t_final, time_step)
-        # pylint: disable=W1514, E1121
+        # pylint: disable=W1514
         with open(file_name, "w") as file:
             if len(f.rocket.parachutes) == 0:
-                print("No parachutes in the rocket, saving static pressure.")
+                logger.info("No parachutes in the rocket, saving static pressure.")
                 for t in time_points:
                     file.write(f"{t:f}, {f.pressure.get_value_opt(t):.5f}\n")
             else:
@@ -210,7 +213,7 @@ def export_sensor_data(self, file_name, sensor=None):
 
         with open(file_name, "w") as file:
             json.dump(data_dict, file)
-        print("Sensor data exported to: ", file_name)
+        logger.info("Sensor data exported to: %s", file_name)
 
     def export_kml(
         self,
@@ -295,4 +298,4 @@ def export_kml(
 
         # Save the KML
         kml.save(file_name)
-        print("File ", file_name, " saved with success!")
+        logger.info("File %s saved with success!", file_name)
diff --git a/rocketpy/simulation/flight_data_importer.py b/rocketpy/simulation/flight_data_importer.py
index b4498a1dc..cec357fd5 100644
--- a/rocketpy/simulation/flight_data_importer.py
+++ b/rocketpy/simulation/flight_data_importer.py
@@ -2,6 +2,7 @@
 and build a rocketpy.Flight object from it.
 """
 
+import logging
 from os import listdir
 from os.path import isfile, join
 
@@ -10,6 +11,8 @@
 from rocketpy.mathutils import Function
 from rocketpy.units import UNITS_CONVERSION_DICT
 
+logger = logging.getLogger(__name__)
+
 FLIGHT_LABEL_MAP = {
     # "name of Flight Attribute": "Label to be displayed"
     "acceleration": "Acceleration (m/s^2)",
@@ -285,7 +288,7 @@ def __create_attributes(self, filepath, interpolation, extrapolation):
             # Convert units if necessary
             if units and col in units:
                 values /= UNITS_CONVERSION_DICT[units[col]]
-                print(f"Attribute '{name}' converted from {units[col]} to SI")
+                logger.debug("Attribute '%s' converted from %s to SI", name, units[col])
 
             # Create Function object and set as attribute
             setattr(
@@ -301,9 +304,8 @@ def __create_attributes(self, filepath, interpolation, extrapolation):
             )
             created.append(name)
 
-        print(
-            "The following attributes were create and are now available to be used: ",
-            created,
+        logger.info(
+            "The following attributes were created and are now available: %s", created
         )
 
     def read_data(
diff --git a/rocketpy/simulation/monte_carlo.py b/rocketpy/simulation/monte_carlo.py
index b6b71e0c3..751d048d1 100644
--- a/rocketpy/simulation/monte_carlo.py
+++ b/rocketpy/simulation/monte_carlo.py
@@ -13,6 +13,7 @@
 latest documentation.
 """
 
+import csv
 import json
 import os
 import traceback
@@ -37,7 +38,7 @@
 # TODO: Create evolution plots to analyze convergence
 
 
-class MonteCarlo:
+class MonteCarlo:  # pylint: disable=too-many-public-methods
     """Class to run a Monte Carlo simulation of a rocket flight.
 
     Attributes
@@ -95,7 +96,7 @@ def __init__(
         flight,
         export_list=None,
         data_collector=None,
-    ):  # pylint: disable=too-many-statements
+    ):
         """
         Initialize a MonteCarlo object.
 
@@ -171,7 +172,7 @@ def simulate(
         random_seed=None,
         n_workers=None,
         **kwargs,
-    ):  # pylint: disable=too-many-statements
+    ):
         """
         Runs the Monte Carlo simulation and saves all data.
 
@@ -226,7 +227,7 @@ def simulate(
         self.number_of_simulations = number_of_simulations
         self._initial_sim_idx = self.num_of_loaded_sims if append else 0
 
-        _SimMonitor.reprint("Starting Monte Carlo analysis")
+        print("Starting Monte Carlo analysis")
 
         self.__setup_files(append)
 
@@ -308,6 +309,9 @@ def __run_in_serial(self, random_seed=None):
             sim_monitor.print_final_status()
 
         except KeyboardInterrupt:
+            print("Keyboard interrupt received. Files saved.")
+            with open(self._error_file, "a", encoding="utf-8") as f:
+                f.write(inputs_json)
             self.__save_serial_error(inputs_json, "Keyboard Interrupt, files saved.")
 
         except Exception as error:
@@ -345,7 +349,7 @@ def __run_in_parallel(self, random_seed=None, n_workers=None):
         """
         n_workers = self.__validate_number_of_workers(n_workers)
 
-        _SimMonitor.reprint(f"Running Monte Carlo simulation with {n_workers} workers.")
+        print(f"Running Monte Carlo simulation with {n_workers} workers.")
 
         multiprocess, managers = _import_multiprocess()
 
@@ -438,8 +442,12 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event):  # pylint: disa
                 try:
                     mutex.acquire()
                     if error_event.is_set():
-                        sim_monitor.reprint(
-                            "Simulation Interrupt, files from simulation "
+                        # Runs in a worker process spawned via multiprocessing:
+                        # logging handlers configured in the main process are
+                        # not guaranteed to be inherited (e.g. Windows "spawn"),
+                        # so this must use print() to remain visible.
+                        _SimMonitor.reprint(
+                            f"Simulation interrupt. Files from simulation "
                             f"{sim_idx} saved."
                         )
                         with open(self.error_file, "a", encoding="utf-8") as f:
@@ -461,8 +469,11 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event):  # pylint: disa
             with open(self.error_file, "a", encoding="utf-8") as f:
                 f.write(inputs_json)
 
-            sim_monitor.reprint(f"Error on iteration {sim_idx}:")
-            sim_monitor.reprint(traceback.format_exc())
+            # See note above: must use print() to remain visible from a
+            # multiprocessing worker process.
+            _SimMonitor.reprint(
+                f"Error on iteration {sim_idx}:\n{traceback.format_exc()}"
+            )
             error_event.set()
             mutex.release()
 
@@ -549,6 +560,107 @@ def estimate_confidence_interval(
 
         return res.confidence_interval
 
+    def simulate_convergence(
+        self,
+        target_attribute="apogee_time",
+        target_confidence=0.95,
+        tolerance=0.5,
+        max_simulations=1000,
+        batch_size=50,
+        parallel=False,
+        n_workers=None,
+    ):
+        """Run Monte Carlo simulations in batches until the confidence interval
+        width converges within the specified tolerance or the maximum number of
+        simulations is reached.
+
+        Parameters
+        ----------
+        target_attribute : str
+            The target attribute to track its convergence (e.g., "apogee", "apogee_time", etc.).
+        target_confidence : float, optional
+            The confidence level for the interval (between 0 and 1). Default is 0.95.
+        tolerance : float, optional
+            The desired width of the confidence interval in seconds, meters, or other units. Default is 0.5.
+        max_simulations : int, optional
+            The maximum number of simulations to run to avoid infinite loops. Default is 1000.
+        batch_size : int, optional
+            The number of simulations to run in each batch. Default is 50.
+        parallel : bool, optional
+            Whether to run simulations in parallel. Default is False.
+        n_workers : int, optional
+            The number of worker processes to use if running in parallel. Default is None.
+
+        Returns
+        -------
+        confidence_interval_history : list of float
+            History of confidence interval widths, one value per batch of simulations.
+            The last element corresponds to the width when the simulation stopped for
+            either meeting the tolerance or reaching the maximum number of simulations.
+        """
+
+        # Validate inputs up-front. Without this, a non-positive batch_size makes
+        # the loop run zero new simulations every iteration and spin forever.
+        if not isinstance(batch_size, (int, np.integer)) or batch_size <= 0:
+            raise ValueError(
+                f"'batch_size' must be a positive integer, got {batch_size!r}."
+            )
+        if not isinstance(max_simulations, (int, np.integer)) or max_simulations <= 0:
+            raise ValueError(
+                f"'max_simulations' must be a positive integer, got "
+                f"{max_simulations!r}."
+            )
+        if not isinstance(tolerance, (int, float)) or tolerance <= 0:
+            raise ValueError(
+                f"'tolerance' must be a positive number, got {tolerance!r}."
+            )
+        if not 0 < target_confidence < 1:
+            raise ValueError(
+                "'target_confidence' must be between 0 and 1 (exclusive), got "
+                f"{target_confidence!r}."
+            )
+
+        self.import_outputs(self.filename.with_suffix(".outputs.txt"))
+        confidence_interval_history = []
+
+        while self.num_of_loaded_sims < max_simulations:
+            total_sims = min(self.num_of_loaded_sims + batch_size, max_simulations)
+
+            self.simulate(
+                number_of_simulations=total_sims,
+                append=True,
+                include_function_data=False,
+                parallel=parallel,
+                n_workers=n_workers,
+            )
+
+            self.import_outputs(self.filename.with_suffix(".outputs.txt"))
+
+            ci = self.estimate_confidence_interval(
+                attribute=target_attribute,
+                confidence_level=target_confidence,
+            )
+
+            width = float(ci.high - ci.low)
+            confidence_interval_history.append(width)
+
+            # A NaN width means the target attribute contains NaN values; the
+            # tolerance check would never pass, so the loop would run to
+            # max_simulations and silently return a NaN history. Stop and warn.
+            if np.isnan(width):
+                warnings.warn(
+                    f"The confidence interval width for '{target_attribute}' is "
+                    "NaN, likely because the attribute contains NaN values. "
+                    "Stopping convergence early; check the simulation outputs.",
+                    stacklevel=2,
+                )
+                break
+
+            if width <= tolerance:
+                break
+
+        return confidence_interval_history
+
     def __evaluate_flight_inputs(self, sim_idx):
         """Evaluates the inputs of a single flight simulation.
 
@@ -625,7 +737,7 @@ def __terminate_simulation(self):
         self.output_file = self._output_file
         self.error_file = self._error_file
 
-        _SimMonitor.reprint(f"Results saved to {self._output_file}")
+        print(f"Results saved to {self._output_file}")
 
     def __check_export_list(self, export_list):
         """
@@ -831,58 +943,232 @@ def error_file(self, value):
         self._error_file = value
         self.set_errors_log()
 
+    # File format helpers
+
+    @staticmethod
+    def _detect_file_format(filepath):
+        """Detect file format from the file extension.
+
+        Parameters
+        ----------
+        filepath : str or Path
+            Path to the file.
+
+        Returns
+        -------
+        str
+            One of ``"jsonl"``, ``"csv"``, or ``"json"``.
+
+        Raises
+        ------
+        ValueError
+            If the file extension is not supported.
+        """
+        suffix = Path(filepath).suffix.lower()
+        format_map = {".txt": "jsonl", ".csv": "csv", ".json": "json"}
+        if suffix not in format_map:
+            raise ValueError(
+                f"Unsupported file extension '{suffix}'. "
+                "Expected '.txt', '.csv', or '.json'."
+            )
+        return format_map[suffix]
+
+    @staticmethod
+    def _parse_csv_value(value):
+        """Parse a string value from a CSV cell into its appropriate type.
+
+        Parameters
+        ----------
+        value : str
+            The raw string value from the CSV cell.
+
+        Returns
+        -------
+        int, float, dict, list, or str
+            The parsed value in its appropriate Python type.
+        """
+        if value == "":
+            return value
+        # Try parsing JSON objects/arrays
+        if value.startswith(("{", "[")):
+            try:
+                return json.loads(value)
+            except (json.JSONDecodeError, ValueError):
+                pass
+        # Try numeric types
+        try:
+            int_val = int(value)
+            # Ensure the string was truly an integer (not "1.0")
+            if str(int_val) == value:
+                return int_val
+        except ValueError:
+            pass
+        try:
+            return float(value)
+        except ValueError:
+            pass
+        return value
+
+    def _read_log_file(self, filepath):
+        """Read a log file in any supported format and return a list of dicts.
+
+        Parameters
+        ----------
+        filepath : str or Path
+            Path to the log file. Format is detected from the extension.
+
+        Returns
+        -------
+        list of dict
+            A list of dictionaries, one per simulation record.
+        """
+        fmt = self._detect_file_format(filepath)
+        result = []
+        with open(filepath, mode="r", encoding="utf-8") as f:
+            if fmt == "jsonl":
+                for line in f:
+                    line = line.strip()
+                    if line:
+                        result.append(json.loads(line))
+            elif fmt == "json":
+                content = f.read().strip()
+                if content:
+                    result = json.loads(content)
+            elif fmt == "csv":
+                reader = csv.DictReader(f)
+                for row in reader:
+                    result.append({k: self._parse_csv_value(v) for k, v in row.items()})
+        return result
+
+    @staticmethod
+    def _write_log_to_csv(log_data, filepath, flatten=False):
+        """Write a list of dicts to a CSV file.
+
+        Parameters
+        ----------
+        log_data : list of dict
+            The data to write. Each dict is one row.
+        filepath : str or Path
+            Output file path.
+        flatten : bool, optional
+            If True, non-scalar columns (dicts, lists) are omitted.
+            If False (default), non-scalar values are serialized as JSON
+            strings in the CSV cells.
+
+        Raises
+        ------
+        ValueError
+            If ``log_data`` is empty.
+        """
+        if not log_data:
+            raise ValueError(
+                "No data to export. Run a simulation first or import existing data."
+            )
+        # Collect all keys preserving insertion order
+        all_keys = list(dict.fromkeys(k for row in log_data for k in row))
+
+        if flatten:
+            # Identify scalar-only keys
+            scalar_keys = []
+            for key in all_keys:
+                if all(not isinstance(row.get(key), (dict, list)) for row in log_data):
+                    scalar_keys.append(key)
+            fieldnames = scalar_keys
+        else:
+            fieldnames = all_keys
+
+        with open(filepath, mode="w", newline="", encoding="utf-8") as f:
+            writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
+            writer.writeheader()
+            for row in log_data:
+                csv_row = {}
+                for key in fieldnames:
+                    value = row.get(key, "")
+                    if isinstance(value, (dict, list)):
+                        csv_row[key] = json.dumps(value)
+                    else:
+                        csv_row[key] = value
+                writer.writerow(csv_row)
+
+    def _write_log_to_json(self, log_data, filepath):
+        """Write a list of dicts to a JSON file as a proper JSON array.
+
+        Parameters
+        ----------
+        log_data : list of dict
+            The data to write. Each dict becomes one element of the array.
+        filepath : str or Path
+            Output file path.
+
+        Raises
+        ------
+        ValueError
+            If ``log_data`` is empty.
+        """
+        if not log_data:
+            raise ValueError(
+                "No data to export. Run a simulation first or import existing data."
+            )
+        with open(filepath, mode="w", encoding="utf-8") as f:
+            json.dump(log_data, f, cls=RocketPyEncoder, indent=2)
+
     # Setters for post simulation attributes
 
     def set_inputs_log(self):
         """
         Sets inputs_log from a file into an attribute for easy access.
+        Supports .txt (JSONL), .csv, and .json file formats.
 
         Returns
         -------
         None
         """
-        self.inputs_log = []
-        with open(self.input_file, mode="r", encoding="utf-8") as rows:
-            for line in rows:
-                self.inputs_log.append(json.loads(line))
+        self.inputs_log = self._read_log_file(self.input_file)
 
     def set_outputs_log(self):
         """
         Sets outputs_log from a file into an attribute for easy access.
+        Supports .txt (JSONL), .csv, and .json file formats.
 
         Returns
         -------
         None
         """
-        self.outputs_log = []
-        with open(self.output_file, mode="r", encoding="utf-8") as rows:
-            for line in rows:
-                self.outputs_log.append(json.loads(line))
+        self.outputs_log = self._read_log_file(self.output_file)
 
     def set_errors_log(self):
         """
         Sets errors_log from a file into an attribute for easy access.
+        Supports .txt (JSONL), .csv, and .json file formats.
 
         Returns
         -------
         None
         """
-        self.errors_log = []
-        with open(self.error_file, mode="r", encoding="utf-8") as errors:
-            for line in errors:
-                self.errors_log.append(json.loads(line))
+        self.errors_log = self._read_log_file(self.error_file)
 
     def set_num_of_loaded_sims(self):
         """
         Determines the number of simulations loaded from output_file being
-        currently used.
+        currently used. Supports .txt (JSONL), .csv, and .json formats.
 
         Returns
         -------
         None
         """
+        fmt = self._detect_file_format(self.output_file)
         with open(self.output_file, mode="r", encoding="utf-8") as outputs:
-            self.num_of_loaded_sims = sum(1 for _ in outputs)
+            if fmt == "jsonl":
+                self.num_of_loaded_sims = sum(1 for _ in outputs)
+            elif fmt == "csv":
+                # Subtract 1 for the header row
+                self.num_of_loaded_sims = max(0, sum(1 for _ in outputs) - 1)
+            elif fmt == "json":
+                content = outputs.read().strip()
+                if content:
+                    self.num_of_loaded_sims = len(json.loads(content))
+                else:
+                    self.num_of_loaded_sims = 0
 
     def set_results(self):
         """
@@ -939,13 +1225,15 @@ def set_processed_results(self):
 
     def import_outputs(self, filename=None):
         """
-        Import Monte Carlo results from .txt file and save it into a dictionary.
+        Import Monte Carlo results from a file and save it into a dictionary.
+        Supports .txt (JSONL), .csv, and .json file formats.
 
         Parameters
         ----------
         filename : str, optional
             Name or directory path to the file to be imported. If none,
-            self.filename will be used.
+            self.filename will be used with the default .outputs.txt suffix.
+            Files with .csv or .json extensions are also accepted.
 
         Returns
         -------
@@ -953,7 +1241,7 @@ def import_outputs(self, filename=None):
 
         Notes
         -----
-        Notice that you can import the outputs, inputs, and errors from the a
+        Notice that you can import the outputs, inputs, and errors from a
         file without the need to run simulations. You can use previously saved
         files to process analyze the results or to continue a simulation.
         """
@@ -966,20 +1254,22 @@ def import_outputs(self, filename=None):
             with open(filepath, "w+", encoding="utf-8"):
                 self.output_file = filepath
 
-        _SimMonitor.reprint(
-            f"A total of {self.num_of_loaded_sims} simulations results were "
-            f"loaded from the following output file: {self.output_file}\n"
+        print(
+            f"A total of {self.num_of_loaded_sims} simulation results were "
+            f"loaded from: {self.output_file}"
         )
 
     def import_inputs(self, filename=None):
         """
-        Import Monte Carlo inputs from .txt file and save it into a dictionary.
+        Import Monte Carlo inputs from a file and save it into a dictionary.
+        Supports .txt (JSONL), .csv, and .json file formats.
 
         Parameters
         ----------
         filename : str, optional
             Name or directory path to the file to be imported. If none,
-            self.filename will be used.
+            self.filename will be used with the default .inputs.txt suffix.
+            Files with .csv or .json extensions are also accepted.
 
         Returns
         -------
@@ -994,17 +1284,19 @@ def import_inputs(self, filename=None):
             with open(filepath, "w+", encoding="utf-8"):
                 self.input_file = filepath
 
-        _SimMonitor.reprint(f"The following input file was imported: {self.input_file}")
+        print(f"The following input file was imported: {self.input_file}")
 
     def import_errors(self, filename=None):
         """
-        Import Monte Carlo errors from .txt file and save it into a dictionary.
+        Import Monte Carlo errors from a file and save it into a dictionary.
+        Supports .txt (JSONL), .csv, and .json file formats.
 
         Parameters
         ----------
         filename : str, optional
             Name or directory path to the file to be imported. If none,
-            self.filename will be used.
+            self.filename will be used with the default .errors.txt suffix.
+            Files with .csv or .json extensions are also accepted.
 
         Returns
         -------
@@ -1019,11 +1311,11 @@ def import_errors(self, filename=None):
             with open(filepath, "w+", encoding="utf-8"):
                 self.error_file = filepath
 
-        _SimMonitor.reprint(f"The following error file was imported: {self.error_file}")
+        print(f"The following error file was imported: {self.error_file}")
 
     def import_results(self, filename=None):
         """
-        Import Monte Carlo results from .txt file and save it into a dictionary.
+        Import Monte Carlo results from a file and save it into a dictionary.
 
         Parameters
         ----------
@@ -1238,6 +1530,109 @@ def compare_ellipses(self, other_monte_carlo, **kwargs):
         """
         self.plots.ellipses_comparison(other_monte_carlo, **kwargs)
 
+    # CSV and JSON export methods
+
+    def export_outputs_to_csv(self, filename):
+        """Export simulation outputs to a CSV file.
+
+        Each row represents one simulation. All output values are scalar,
+        so the CSV is directly usable in spreadsheet applications.
+
+        Parameters
+        ----------
+        filename : str
+            Path to the output CSV file.
+
+        Raises
+        ------
+        ValueError
+            If no output data is available to export.
+        """
+        self._write_log_to_csv(self.outputs_log, filename)
+
+    def export_outputs_to_json(self, filename):
+        """Export simulation outputs to a JSON file as an array of objects.
+
+        Parameters
+        ----------
+        filename : str
+            Path to the output JSON file.
+
+        Raises
+        ------
+        ValueError
+            If no output data is available to export.
+        """
+        self._write_log_to_json(self.outputs_log, filename)
+
+    def export_inputs_to_csv(self, filename, flatten=False):
+        """Export simulation inputs to a CSV file.
+
+        Parameters
+        ----------
+        filename : str
+            Path to the output CSV file.
+        flatten : bool, optional
+            If True, columns with non-scalar values (dicts, lists) are
+            omitted from the CSV. If False (default), non-scalar values
+            are serialized as JSON strings within the CSV cells.
+
+        Raises
+        ------
+        ValueError
+            If no input data is available to export.
+        """
+        self._write_log_to_csv(self.inputs_log, filename, flatten=flatten)
+
+    def export_inputs_to_json(self, filename):
+        """Export simulation inputs to a JSON file as an array of objects.
+
+        Parameters
+        ----------
+        filename : str
+            Path to the output JSON file.
+
+        Raises
+        ------
+        ValueError
+            If no input data is available to export.
+        """
+        self._write_log_to_json(self.inputs_log, filename)
+
+    def export_errors_to_csv(self, filename, flatten=False):
+        """Export simulation errors to a CSV file.
+
+        Parameters
+        ----------
+        filename : str
+            Path to the output CSV file.
+        flatten : bool, optional
+            If True, columns with non-scalar values (dicts, lists) are
+            omitted from the CSV. If False (default), non-scalar values
+            are serialized as JSON strings within the CSV cells.
+
+        Raises
+        ------
+        ValueError
+            If no error data is available to export.
+        """
+        self._write_log_to_csv(self.errors_log, filename, flatten=flatten)
+
+    def export_errors_to_json(self, filename):
+        """Export simulation errors to a JSON file as an array of objects.
+
+        Parameters
+        ----------
+        filename : str
+            Path to the output JSON file.
+
+        Raises
+        ------
+        ValueError
+            If no error data is available to export.
+        """
+        self._write_log_to_json(self.errors_log, filename)
+
 
 def _import_multiprocess():
     """Import the necessary modules and submodules for the
@@ -1332,15 +1727,12 @@ def print_final_status(self):
         msg = f"Completed {self.count - self.initial_count} iterations."
         msg += f" In total, {self.count} simulations are exported.\n"
         msg += f"Total wall time: {time() - self.start_time:.1f} s"
-
         _SimMonitor.reprint(msg, end="\n", flush=True)
 
     @staticmethod
     def reprint(msg, end="\n", flush=True):
-        """
-        Prints a message on the same line as the previous one and replaces the
-        previous message with the new one, deleting the extra characters from
-        the previous message.
+        """Prints a message replacing the previous line to avoid cluttering
+        the terminal output during concurrent simulation progress updates.
 
         Parameters
         ----------
@@ -1355,12 +1747,8 @@ def reprint(msg, end="\n", flush=True):
         -------
         None
         """
-
         padding = ""
-
         if len(msg) < _SimMonitor._last_print_len:
             padding = " " * (_SimMonitor._last_print_len - len(msg))
-
         print(msg + padding, end=end, flush=flush)
-
         _SimMonitor._last_print_len = len(msg)
diff --git a/rocketpy/stochastic/stochastic_model.py b/rocketpy/stochastic/stochastic_model.py
index 879b61e70..ca26f6578 100644
--- a/rocketpy/stochastic/stochastic_model.py
+++ b/rocketpy/stochastic/stochastic_model.py
@@ -550,6 +550,11 @@ def visualize_attributes(self):
         Model object. The report includes the variable name, the nominal value,
         the standard deviation, and the distribution function used to generate
         the random attributes.
+
+        Returns
+        -------
+        str
+            The formatted report. It is also printed for interactive use.
         """
 
         def format_attribute(attr, value):
@@ -630,4 +635,10 @@ def format_attribute(attr, value):
                 format_attribute(attr, attributes[attr]) for attr in custom_attributes
             )
 
-        print("\n".join(filter(None, report)))
+        # This is an explicit, user-invoked display method, so it prints
+        # unconditionally (like ``info``/``all_info`` elsewhere) rather than
+        # logging at INFO level, which is silenced by default. The report is
+        # also returned so it can be used programmatically.
+        report_str = "\n".join(filter(None, report))
+        print(report_str)
+        return report_str
diff --git a/rocketpy/tools.py b/rocketpy/tools.py
index 68ab3404a..0d7f1a74e 100644
--- a/rocketpy/tools.py
+++ b/rocketpy/tools.py
@@ -11,6 +11,7 @@
 import importlib
 import importlib.metadata
 import json
+import logging
 import math
 import re
 import time
@@ -25,6 +26,8 @@
 from matplotlib.patches import Ellipse
 from packaging import version as packaging_version
 
+logger = logging.getLogger(__name__)
+
 # Mapping of module name and the name of the package that should be installed
 INSTALL_MAPPING = {"IPython": "ipython"}
 
@@ -422,18 +425,18 @@ def inverted_haversine(lat0, lon0, distance, bearing, earth_radius=6.3781e6):
     lon0_rad = np.deg2rad(lon0)
 
     # Apply inverted Haversine formula
-    lat1_rad = math.asin(
-        math.sin(lat0_rad) * math.cos(distance / earth_radius)
-        + math.cos(lat0_rad)
-        * math.sin(distance / earth_radius)
-        * math.cos(math.radians(bearing))
+    lat1_rad = np.arcsin(
+        np.sin(lat0_rad) * np.cos(distance / earth_radius)
+        + np.cos(lat0_rad)
+        * np.sin(distance / earth_radius)
+        * np.cos(np.radians(bearing))
     )
 
-    lon1_rad = lon0_rad + math.atan2(
-        math.sin(math.radians(bearing))
-        * math.sin(distance / earth_radius)
-        * math.cos(lat0_rad),
-        math.cos(distance / earth_radius) - math.sin(lat0_rad) * math.sin(lat1_rad),
+    lon1_rad = lon0_rad + np.arctan2(
+        np.sin(np.radians(bearing))
+        * np.sin(distance / earth_radius)
+        * np.cos(lat0_rad),
+        np.cos(distance / earth_radius) - np.sin(lat0_rad) * np.sin(lat1_rad),
     )
 
     # Convert back to degrees and then return
@@ -1469,6 +1472,6 @@ def find_obj_from_hash(obj, hash_, depth_limit=None):
 
     res = doctest.testmod()
     if res.failed < 1:
-        print(f"All the {res.attempted} tests passed!")
+        logger.info("All the %d tests passed!", res.attempted)
     else:
-        print(f"{res.failed} out of {res.attempted} tests failed.")
+        logger.error("%d out of %d tests failed.", res.failed, res.attempted)
diff --git a/rocketpy/utilities.py b/rocketpy/utilities.py
index f7748324c..9bd83f8ba 100644
--- a/rocketpy/utilities.py
+++ b/rocketpy/utilities.py
@@ -1,10 +1,12 @@
 import inspect
 import json
+import logging
 import os
 import warnings
 from datetime import date
 from importlib.metadata import version
 from pathlib import Path
+from typing import TYPE_CHECKING
 
 import matplotlib.pyplot as plt
 import numpy as np
@@ -16,7 +18,60 @@
 from .mathutils.function import Function
 from .plots.plot_helpers import show_or_save_plot
 from .rocket.aero_surface import TrapezoidalFins
-from .simulation.flight import Flight
+
+if TYPE_CHECKING:  # pragma: no cover
+    from .simulation.flight import Flight
+
+
+def enable_logging(level="WARNING"):
+    """Enable RocketPy logging output to the console.
+
+    Attaches a StreamHandler to the ``rocketpy`` logger so that internal
+    runtime events (simulation progress, warnings, errors) are printed to
+    the terminal. Only RocketPy logs are affected — global/root logging
+    is not modified. By default, only WARNING and above are shown.
+
+    Parameters
+    ----------
+    level : str, optional
+        The minimum logging level to display. Options are "DEBUG", "INFO",
+        "WARNING", "ERROR", and "CRITICAL". Default is "WARNING".
+
+    Examples
+    --------
+    Show only warnings and errors (default):
+
+    >>> import rocketpy
+    >>> rocketpy.utilities.enable_logging()
+
+    Show all internal runtime messages, including simulation progress:
+
+    >>> import rocketpy
+    >>> rocketpy.utilities.enable_logging(level="DEBUG")
+
+    Show confirmations like "Simulation completed" and "File saved":
+
+    >>> import rocketpy
+    >>> rocketpy.utilities.enable_logging(level="INFO")
+    """
+    numeric_level = getattr(logging, level.upper(), None)
+    if not isinstance(numeric_level, int):
+        raise ValueError(f"Invalid logging level: '{level}'")
+
+    logger = logging.getLogger("rocketpy")
+
+    # Remove any existing StreamHandlers to avoid duplicate messages
+    logger.handlers = [
+        h for h in logger.handlers if not isinstance(h, logging.StreamHandler)
+    ]
+
+    logger.setLevel(numeric_level)
+
+    handler = logging.StreamHandler()
+    handler.setLevel(numeric_level)
+    handler.setFormatter(logging.Formatter("%(levelname)s | %(name)s | %(message)s"))
+
+    logger.addHandler(handler)
 
 
 def compute_cd_s_from_drop_test(
@@ -202,7 +257,43 @@ def du(z, u):
     return altitude_function, velocity_function, final_sol
 
 
-# pylint: disable=too-many-statements
+def calculate_stall_wind_velocity(flight, stall_angle):
+    """Calculate the maximum wind velocity before the angle of attack exceeds a
+    desired stall angle, at the instant of departing the launch rail.
+
+    Can be helpful if you know the exact stall angle of all aerodynamic
+    surfaces.
+
+    Parameters
+    ----------
+    flight : rocketpy.Flight
+        Flight object containing the rocket's flight data. Its
+        ``out_of_rail_velocity`` and ``inclination`` attributes are used.
+    stall_angle : float
+        Angle, in degrees, for which you would like to know the maximum wind
+        speed before the angle of attack exceeds it.
+
+    Returns
+    -------
+    float
+        Maximum wind velocity, in m/s, at rail departure before the angle of
+        attack exceeds ``stall_angle``.
+    """
+    v_f = flight.out_of_rail_velocity
+    theta = np.radians(flight.inclination)
+    stall_angle_rad = np.radians(stall_angle)
+
+    c = (np.cos(stall_angle_rad) ** 2 - np.cos(theta) ** 2) / np.sin(
+        stall_angle_rad
+    ) ** 2
+    w_v = (
+        2 * v_f * np.cos(theta) / c
+        + (4 * v_f * v_f * np.cos(theta) * np.cos(theta) / (c**2) + 4 * v_f * v_f / c)
+        ** 0.5
+    ) / 2
+    return w_v
+
+
 def fin_flutter_analysis(
     fin_thickness,
     shear_modulus,
@@ -231,8 +322,7 @@ def fin_flutter_analysis(
     see_prints : boolean, optional
         True if you want to see the prints, False otherwise.
     see_graphs : boolean, optional
-        True if you want to see the graphs, False otherwise. If False, the
-        function will return the vectors containing the data for the graphs.
+        True if you want to see the graphs, False otherwise.
     filename : str | None, optional
         The path the plot should be saved to. By default None, in which case the
         plot will be shown instead of saved. Supported file endings are: eps,
@@ -241,7 +331,12 @@ def fin_flutter_analysis(
 
     Return
     ------
-    None
+    flutter_mach : rocketpy.Function
+        The Mach Number at which the fin flutter occurs as a function of time,
+        considering the variation of the speed of sound with altitude.
+    safety_factor : rocketpy.Function
+        The Safety Factor for the fin flutter as a function of time, defined as
+        the flutter Mach Number divided by the freestream Mach Number.
     """
     found_fin = False
     surface_area = None
@@ -284,8 +379,12 @@ def fin_flutter_analysis(
         )
     if see_graphs:
         _flutter_plots(flight, flutter_mach, safety_factor, filename=filename)
-    else:
-        return flutter_mach, safety_factor
+
+    # Always return the computed results so callers can use the flutter margin
+    # programmatically. These are safety-critical numbers and must never be
+    # silently discarded (they were previously only returned when
+    # ``see_graphs=False`` and otherwise only logged at INFO level).
+    return flutter_mach, safety_factor
 
 
 def _flutter_mach_number(
@@ -428,18 +527,21 @@ def _flutter_prints(
     min_sf = safety_factor[time_index, 1]
     altitude_min_sf = flight.z(time_min_sf) - flight.env.elevation
 
-    print("\nFin's parameters")
-    print(f"Surface area (S): {surface_area:.4f} m2")
-    print(f"Aspect ratio (AR): {aspect_ratio:.3f}")
-    print(f"tip_chord/root_chord ratio = \u03bb = {lambda_:.3f}")
-    print(f"Fin Thickness: {fin_thickness:.5f} m")
-    print(f"Shear Modulus (G): {shear_modulus:.3e} Pa")
-
-    print("\nFin Flutter Analysis")
-    print(f"Minimum Fin Flutter Velocity: {min_vel:.3f} m/s at {time_min_mach:.2f} s")
-    print(f"Minimum Fin Flutter Mach Number: {min_mach:.3f} ")
-    print(f"Minimum Safety Factor: {min_sf:.3f} at {time_min_sf:.2f} s")
-    print(f"Altitude of minimum Safety Factor: {altitude_min_sf:.3f} m (AGL)\n")
+    # This is an explicit, opt-in report (gated on ``see_prints``), so it uses
+    # print() to display unconditionally rather than logging.info, which is
+    # silenced by default.
+    print(
+        f"Fin's parameters: Surface area (S)={surface_area:.4f} m2"
+        f" | AR={aspect_ratio:.3f} | \u03bb={lambda_:.3f}"
+        f" | Thickness={fin_thickness:.5f} m"
+        f" | Shear Modulus (G)={shear_modulus:.3e} Pa"
+    )
+    print(
+        f"Fin Flutter Analysis: Min flutter velocity={min_vel:.3f} m/s"
+        f" at t={time_min_mach:.2f} s | Min flutter Mach={min_mach:.3f}"
+        f" | Min safety factor={min_sf:.3f} at t={time_min_sf:.2f} s"
+        f" | Altitude of min safety factor={altitude_min_sf:.3f} m (AGL)"
+    )
 
 
 def apogee_by_mass(flight, min_mass, max_mass, points=10, plot=True):
@@ -477,6 +579,9 @@ def apogee_by_mass(flight, min_mass, max_mass, points=10, plot=True):
         Function object containing the estimated apogee as a function of the
         rocket's mass (without motor nor propellant).
     """
+    # Imported lazily to avoid a circular import (Flight imports utilities).
+    from .simulation.flight import Flight  # pylint: disable=import-outside-toplevel
+
     rocket = flight.rocket
 
     def apogee(mass):
@@ -547,6 +652,9 @@ def liftoff_speed_by_mass(flight, min_mass, max_mass, points=10, plot=True):
         Function object containing the estimated liftoff speed as a function of
         the rocket's mass (without motor nor propellant).
     """
+    # Imported lazily to avoid a circular import (Flight imports utilities).
+    from .simulation.flight import Flight  # pylint: disable=import-outside-toplevel
+
     rocket = flight.rocket
 
     def liftoff_speed(mass):
@@ -605,7 +713,7 @@ def get_instance_attributes(instance):
     return attributes_dict
 
 
-def save_to_rpy(flight: Flight, filename: str, include_outputs=False):
+def save_to_rpy(flight: "Flight", filename: str, include_outputs=False):
     """Saves a .rpy file into the given path, containing key simulation
     informations to reproduce the results.
 
@@ -663,9 +771,11 @@ def load_from_rpy(filename: str, resimulate=False):
             version("activerocketpy")
         ):
             warnings.warn(
-                "The file was saved in an updated version of",
-                f"ActiveRocketPy (v{data['version']}), the current",
+                "The file was saved in an updated version of "
+                f"ActiveRocketPy (v{data['version']}), the current "
                 f"imported module is v{version('activerocketpy')}",
+                UserWarning,
+                stacklevel=2,
             )
         simulation = json.dumps(data["simulation"])
         flight = json.loads(simulation, cls=RocketPyDecoder, resimulate=resimulate)
diff --git a/tests/acceptance/test_bella_lui_rocket.py b/tests/acceptance/test_bella_lui_rocket.py
index bcfe325bc..cdccb67d8 100644
--- a/tests/acceptance/test_bella_lui_rocket.py
+++ b/tests/acceptance/test_bella_lui_rocket.py
@@ -67,6 +67,7 @@ def test_bella_lui_rocket_data_asserts_acceptance():
         type="Reanalysis",
         file="data/weather/bella_lui_weather_data_ERA5.nc",
         dictionary="ECMWF",
+        pressure_conversion_factor="hPa",
     )
     env.max_expected_height = 2000
 
diff --git a/tests/acceptance/test_ndrt_2020_rocket.py b/tests/acceptance/test_ndrt_2020_rocket.py
index 7874ee164..04dae8725 100644
--- a/tests/acceptance/test_ndrt_2020_rocket.py
+++ b/tests/acceptance/test_ndrt_2020_rocket.py
@@ -83,6 +83,7 @@ def test_ndrt_2020_rocket_data_asserts_acceptance(env_file):
         type="Reanalysis",
         file=env_file,
         dictionary="ECMWF",
+        pressure_conversion_factor="hPa",
     )
     env.max_expected_height = 2000
 
diff --git a/tests/fixtures/environment/environment_fixtures.py b/tests/fixtures/environment/environment_fixtures.py
index 818f434c7..91b185a35 100644
--- a/tests/fixtures/environment/environment_fixtures.py
+++ b/tests/fixtures/environment/environment_fixtures.py
@@ -5,6 +5,42 @@
 from rocketpy import Environment, EnvironmentAnalysis
 
 
+class _DummyTimeArray:
+    """Minimal time array with netCDF-like units metadata."""
+
+    units = "hours since 2023-06-24 00:00:00"
+
+    def __init__(self, values):
+        self.values = values
+
+    def __getitem__(self, index):
+        return self.values[index]
+
+
+class _DummyCftimeDate:
+    """Small cftime-like datetime used to exercise NetCDF date conversion."""
+
+    year = 2023
+    month = 6
+    day = 24
+    hour = 9
+    minute = 30
+    second = 15
+    microsecond = 123456
+
+
+@pytest.fixture
+def dummy_time_array():
+    """NetCDF-like time array for environment date helper tests."""
+    return _DummyTimeArray([0, 6])
+
+
+@pytest.fixture
+def dummy_cftime_date():
+    """cftime-like date object for environment date helper tests."""
+    return _DummyCftimeDate()
+
+
 @pytest.fixture
 def example_plain_env():
     """Simple object of the Environment class to be used in the tests.
@@ -111,6 +147,7 @@ def environment_spaceport_america_2023():
         type="Reanalysis",
         file="data/weather/spaceport_america_pressure_levels_2023_hourly.nc",
         dictionary="ECMWF",
+        pressure_conversion_factor="hPa",
     )
 
     env.max_expected_height = 6000
diff --git a/tests/fixtures/motor/tanks_fixtures.py b/tests/fixtures/motor/tanks_fixtures.py
index 88721966d..aba5f5a7e 100644
--- a/tests/fixtures/motor/tanks_fixtures.py
+++ b/tests/fixtures/motor/tanks_fixtures.py
@@ -499,7 +499,7 @@ def temperature(t):
 
     return MassBasedTank(
         name="Variable Density N2O Tank",
-        geometry=CylindricalTank(height=0.8, radius=0.06, spherical_caps=True),
+        geometry=CylindricalTank(height=0.8, radius_function=0.06, spherical_caps=True),
         flux_time=7,
         liquid=nitrous_oxide_non_constant_fluid,
         gas=nitrous_oxide_non_constant_fluid,
diff --git a/tests/fixtures/sensors/sensors_fixtures.py b/tests/fixtures/sensors/sensors_fixtures.py
index d1be98097..aa515c808 100644
--- a/tests/fixtures/sensors/sensors_fixtures.py
+++ b/tests/fixtures/sensors/sensors_fixtures.py
@@ -74,6 +74,7 @@ def noisy_gnss():
         sampling_rate=1,
         position_accuracy=1,
         altitude_accuracy=1,
+        seed=42,
     )
 
 
diff --git a/tests/fixtures/surfaces/surface_fixtures.py b/tests/fixtures/surfaces/surface_fixtures.py
index c48627478..3819595dc 100644
--- a/tests/fixtures/surfaces/surface_fixtures.py
+++ b/tests/fixtures/surfaces/surface_fixtures.py
@@ -1,11 +1,14 @@
 import pytest
 
 from rocketpy.rocket.aero_surface import (
+    EllipticalFin,
     EllipticalFins,
+    FreeFormFin,
     FreeFormFins,
     NoseCone,
     RailButtons,
     Tail,
+    TrapezoidalFin,
     TrapezoidalFins,
 )
 
@@ -69,6 +72,29 @@ def calisto_trapezoidal_fins():
     )
 
 
+@pytest.fixture
+def calisto_trapezoidal_fin():
+    """A single trapezoidal fin based on Calisto dimensions.
+
+    Returns
+    -------
+    rocketpy.TrapezoidalFin
+        A single trapezoidal fin.
+    """
+    return TrapezoidalFin(
+        angular_position=0,
+        span=0.100,
+        root_chord=0.120,
+        tip_chord=0.040,
+        rocket_radius=0.0635,
+        name="calisto_trapezoidal_fin",
+        cant_angle=0,
+        sweep_length=None,
+        sweep_angle=None,
+        airfoil=None,
+    )
+
+
 @pytest.fixture
 def calisto_trapezoidal_fins_custom_sweep_length():
     """The trapezoidal fins of the Calisto rocket with
@@ -130,6 +156,23 @@ def calisto_free_form_fins():
     )
 
 
+@pytest.fixture
+def calisto_free_form_fin():
+    """A single free-form fin based on Calisto-like dimensions.
+
+    Returns
+    -------
+    rocketpy.FreeFormFin
+        A single free-form fin.
+    """
+    return FreeFormFin(
+        angular_position=0,
+        shape_points=[(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)],
+        rocket_radius=0.0635,
+        name="calisto_free_form_fin",
+    )
+
+
 @pytest.fixture
 def calisto_rail_buttons():
     """The rail buttons of the Calisto rocket.
@@ -157,3 +200,23 @@ def elliptical_fin_set():
         airfoil=None,
         name="Test Elliptical Fins",
     )
+
+
+@pytest.fixture
+def calisto_elliptical_fin():
+    """A single elliptical fin based on Calisto-like dimensions.
+
+    Returns
+    -------
+    rocketpy.EllipticalFin
+        A single elliptical fin.
+    """
+    return EllipticalFin(
+        angular_position=0,
+        span=0.100,
+        root_chord=0.120,
+        rocket_radius=0.0635,
+        cant_angle=0,
+        airfoil=None,
+        name="calisto_elliptical_fin",
+    )
diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py
index 3bdd5209a..d51551397 100644
--- a/tests/integration/environment/test_environment.py
+++ b/tests/integration/environment/test_environment.py
@@ -1,7 +1,8 @@
 import time
-from datetime import date, datetime, timezone
+from datetime import date, datetime, timedelta, timezone
 from unittest.mock import patch
 
+import numpy as np
 import pytest
 
 
@@ -40,6 +41,21 @@ def test_era5_atmosphere(mock_show, example_spaceport_env):  # pylint: disable=u
         Example environment object to be tested.
     """
     example_spaceport_env.set_date((2018, 10, 15, 12))
+    example_spaceport_env.set_atmospheric_model(
+        type="Reanalysis",
+        file="data/weather/SpaceportAmerica_2018_ERA-5.nc",
+        dictionary="ECMWF",
+        pressure_conversion_factor="hPa",
+    )
+    assert example_spaceport_env.all_info() is None
+
+
+@patch("matplotlib.pyplot.show")
+def test_era5_atmosphere_auto_detect_pressure(mock_show, example_spaceport_env):  # pylint: disable=unused-argument
+    """Tests the Reanalysis model with the ERA5 file using the default
+    pressure_conversion_factor=None (auto-detection).
+    """
+    example_spaceport_env.set_date((2018, 10, 15, 12))
     example_spaceport_env.set_atmospheric_model(
         type="Reanalysis",
         file="data/weather/SpaceportAmerica_2018_ERA-5.nc",
@@ -92,6 +108,60 @@ def test_standard_atmosphere(mock_show, example_plain_env):  # pylint: disable=u
     assert example_plain_env.prints.print_earth_details() is None
 
 
+@patch("matplotlib.pyplot.show")
+def test_wind_plots_wrapping_direction(mock_show, example_plain_env):  # pylint: disable=unused-argument
+    """Tests that wind direction plots handle 360°→0° wraparound without
+    drawing a horizontal line across the graph.
+
+    Parameters
+    ----------
+    mock_show : mock
+        Mock object to replace matplotlib.pyplot.show() method.
+    example_plain_env : rocketpy.Environment
+        Example environment object to be tested.
+    """
+    # Set a custom atmosphere where wind direction wraps from ~350° to ~10°
+    # across the altitude range by choosing wind_u and wind_v to create a
+    # direction near 350° at low altitude and ~10° at higher altitude.
+    # wind_direction = (180 + atan2(wind_u, wind_v)) % 360
+    # For direction ~350°: need atan2(wind_u, wind_v) ≈ 170° → wind_u>0, wind_v<0
+    # For direction ~10°:  need atan2(wind_u, wind_v) ≈ -170° → wind_u<0, wind_v<0
+    example_plain_env.set_atmospheric_model(
+        type="custom_atmosphere",
+        pressure=None,
+        temperature=300,
+        wind_u=[(0, 1), (5000, -1)],  # changes sign across altitude
+        wind_v=[(0, -6), (5000, -6)],  # stays negative → heading near 350°/10°
+    )
+    # Verify that the wind direction actually wraps through 0°/360° in this
+    # atmosphere so the test exercises the wraparound code path.
+    low_dir = example_plain_env.wind_direction(0)
+    high_dir = example_plain_env.wind_direction(5000)
+    assert abs(low_dir - high_dir) > 180, (
+        "Test setup error: wind direction should cross 0°/360° boundary"
+    )
+    # Verify that the helper inserts NaN breaks into the direction and altitude
+    # arrays at the wraparound point, which is the core of the fix.
+    directions = np.array(
+        [example_plain_env.wind_direction(i) for i in example_plain_env.plots.grid],
+        dtype=float,
+    )
+    altitudes = np.array(example_plain_env.plots.grid, dtype=float)
+    directions_broken, altitudes_broken = (
+        example_plain_env.plots._break_direction_wraparound(directions, altitudes)
+    )
+    assert np.any(np.isnan(directions_broken)), (
+        "Expected NaN breaks in direction array at 0°/360° wraparound"
+    )
+    assert np.any(np.isnan(altitudes_broken)), (
+        "Expected NaN breaks in altitude array at 0°/360° wraparound"
+    )
+    # Verify info() and atmospheric_model() plots complete without error
+    assert example_plain_env.info() is None
+    assert example_plain_env.plots.atmospheric_model() is None
+
+
+@pytest.mark.slow
 @pytest.mark.parametrize(
     "model_name",
     [
@@ -141,6 +211,42 @@ def test_gfs_atmosphere(mock_show, example_spaceport_env):  # pylint: disable=un
     assert example_spaceport_env.all_info() is None
 
 
+@pytest.mark.slow
+@patch("matplotlib.pyplot.show")
+def test_aigfs_atmosphere(mock_show, example_spaceport_env):  # pylint: disable=unused-argument
+    """Tests the Forecast model with the AIGFS file.
+
+    Parameters
+    ----------
+    mock_show : mock
+        Mock object to replace matplotlib.pyplot.show() method.
+    example_spaceport_env : rocketpy.Environment
+        Example environment object to be tested.
+    """
+    example_spaceport_env.set_atmospheric_model(type="Forecast", file="AIGFS")
+    assert example_spaceport_env.all_info() is None
+
+
+@pytest.mark.slow
+@patch("matplotlib.pyplot.show")
+def test_hrrr_atmosphere(mock_show, example_spaceport_env):  # pylint: disable=unused-argument
+    """Tests the Forecast model with the HRRR file.
+
+    Parameters
+    ----------
+    mock_show : mock
+        Mock object to replace matplotlib.pyplot.show() method.
+    example_spaceport_env : rocketpy.Environment
+        Example environment object to be tested.
+    """
+    # Sometimes the HRRR latest-model can fail due to not having at least 24
+    # hours in the future in the forecast, so we try with 12 hours in the future
+    # only.
+    example_spaceport_env.set_date(datetime.now() + timedelta(hours=12))
+    example_spaceport_env.set_atmospheric_model(type="Forecast", file="HRRR")
+    assert example_spaceport_env.all_info() is None
+
+
 @pytest.mark.slow
 @patch("matplotlib.pyplot.show")
 def test_nam_atmosphere(mock_show, example_spaceport_env):  # pylint: disable=unused-argument
@@ -178,8 +284,11 @@ def test_gefs_atmosphere(mock_show, example_spaceport_env):  # pylint: disable=u
     example_spaceport_env : rocketpy.Environment
         Example environment object to be tested.
     """
-    example_spaceport_env.set_atmospheric_model(type="Ensemble", file="GEFS")
-    assert example_spaceport_env.all_info() is None
+    with pytest.raises(
+        ValueError,
+        match="GEFS latest-model shortcut is currently unavailable",
+    ):
+        example_spaceport_env.set_atmospheric_model(type="Ensemble", file="GEFS")
 
 
 @pytest.mark.slow
@@ -199,21 +308,27 @@ def test_wyoming_sounding_atmosphere(mock_show, example_plain_env):  # pylint: d
 
     # TODO:: this should be added to the set_atmospheric_model() method as a
     #        "file" option, instead of receiving the URL as a string.
-    url = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779"
-    # give it at least 5 times to try to download the file
+    url = (
+        "https://weather.uwyo.edu/wsgi/sounding?"
+        "datetime=2019-02-05+00:00:00&id=83779&type=TEXT:LIST"
+    )
+    # give it at least 5 times to try to download the file, then skip instead
+    # of silently keeping the standard atmosphere and failing the assertions
     for i in range(5):
         try:
             example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=url)
             break
         except Exception:  # pylint: disable=broad-except
             time.sleep(2**i)
+    else:
+        pytest.skip("Could not fetch Wyoming sounding data from weather.uwyo.edu")
     assert example_plain_env.all_info() is None
     assert abs(example_plain_env.pressure(0) - 93600.0) < 1e-8
     assert (
         abs(example_plain_env.barometric_height(example_plain_env.pressure(0)) - 722.0)
         < 1e-8
     )
-    assert abs(example_plain_env.wind_velocity_x(0) - -2.9005178894925043) < 1e-8
+    assert abs(example_plain_env.wind_velocity_x(0) - -2.9130471244363165) < 1e-8
     assert abs(example_plain_env.temperature(100) - 291.75) < 1e-8
 
 
@@ -234,13 +349,15 @@ def test_hiresw_ensemble_atmosphere(mock_show, example_spaceport_env):  # pylint
 
     example_spaceport_env.set_date(date_info)
 
-    example_spaceport_env.set_atmospheric_model(
-        type="Forecast",
-        file="HIRESW",
-        dictionary="HIRESW",
-    )
-
-    assert example_spaceport_env.all_info() is None
+    with pytest.raises(
+        ValueError,
+        match="HIRESW latest-model shortcut is currently unavailable",
+    ):
+        example_spaceport_env.set_atmospheric_model(
+            type="Forecast",
+            file="HIRESW",
+            dictionary="HIRESW",
+        )
 
 
 @pytest.mark.skip(reason="CMC model is currently not working")
diff --git a/tests/integration/motors/test_ring_cluster_motor.py b/tests/integration/motors/test_ring_cluster_motor.py
new file mode 100644
index 000000000..413169ae7
--- /dev/null
+++ b/tests/integration/motors/test_ring_cluster_motor.py
@@ -0,0 +1,253 @@
+# pylint: disable=invalid-name
+import numpy as np
+import pytest
+
+from rocketpy import Flight, Function, SolidMotor
+from rocketpy.motors.ring_cluster_motor import RingClusterMotor
+
+
+@pytest.fixture
+def base_motor():
+    """
+    Creates a simplified SolidMotor for testing purposes.
+    Properties:
+    - Constant Thrust: 1000 N
+    - Burn time: 5 s
+    - Dry mass: 10 kg
+    - Dry Inertia: (1.0, 1.0, 0.1)
+    """
+    thrust_curve = Function(lambda t: 1000 if t < 5 else 0, "Time (s)", "Thrust (N)")
+
+    return SolidMotor(
+        thrust_source=thrust_curve,
+        burn_time=5,
+        dry_mass=10.0,
+        dry_inertia=(1.0, 1.0, 0.1),  # Ixx, Iyy, Izz
+        grain_number=1,
+        grain_density=1000,
+        grain_outer_radius=0.05,
+        grain_initial_inner_radius=0.02,
+        grain_initial_height=0.5,
+        coordinate_system_orientation="nozzle_to_combustion_chamber",
+        nozzle_radius=0.02,
+        grain_separation=0.001,
+        grains_center_of_mass_position=0.25,
+        center_of_dry_mass_position=0.25,
+    )
+
+
+def test_cluster_initialization(base_motor):
+    """
+    Tests if the ClusterMotor initializes basic attributes correctly.
+    """
+    N = 3
+    R = 0.5
+    cluster = RingClusterMotor(motor=base_motor, number=N, radius=R)
+
+    assert cluster.number == N
+    assert cluster.radius == R
+    assert cluster.grain_outer_radius == base_motor.grain_outer_radius
+
+
+def test_cluster_mass_and_thrust_scaling(base_motor):
+    """
+    Tests if scalar and derived properties are correctly multiplied by N and that functional properties preserve their Function behavior
+    """
+    N = 4
+    R = 0.2
+    cluster = RingClusterMotor(motor=base_motor, number=N, radius=R)
+
+    assert np.isclose(cluster.thrust(1), base_motor.thrust(1) * N)
+
+    assert np.isclose(cluster.dry_mass, base_motor.dry_mass * N)
+
+    assert np.isclose(cluster.propellant_mass(0), base_motor.propellant_mass(0) * N)  # pylint: disable=not-callable
+    assert np.isclose(cluster.total_impulse, base_motor.total_impulse * N)
+    assert np.isclose(cluster.average_thrust, base_motor.average_thrust * N)
+
+
+def test_cluster_dry_inertia_steiner_theorem(base_motor):
+    """
+    Tests the implementation of the Parallel Axis Theorem (Huygens-Steiner)
+    for the static (dry) mass of the cluster.
+
+    Theoretical Formulas:
+    I_zz_cluster = N * I_zz_local + N * m * R^2
+    I_xx_cluster = N * I_xx_local + (N/2) * m * R^2  (Radial symmetry approximation)
+    """
+    N = 3
+    R = 1.0
+    cluster = RingClusterMotor(motor=base_motor, number=N, radius=R)
+
+    m_dry = base_motor.dry_mass
+    Ixx_loc = base_motor.dry_I_11
+    Izz_loc = base_motor.dry_I_33
+
+    expected_Izz = N * Izz_loc + N * m_dry * (R**2)
+
+    expected_I_trans = N * Ixx_loc + (N / 2) * m_dry * (R**2)
+
+    assert np.isclose(cluster.dry_I_33, expected_Izz)
+    assert np.isclose(cluster.dry_I_11, expected_I_trans)
+    assert np.isclose(cluster.dry_I_22, expected_I_trans)
+
+
+def test_cluster_invalid_inputs(base_motor):
+    """Tests if the validation raises errors for bad inputs."""
+    with pytest.raises(ValueError):
+        RingClusterMotor(motor=base_motor, number=1, radius=0.5)
+    with pytest.raises(ValueError):
+        RingClusterMotor(motor=base_motor, number=2, radius=-1.0)
+    with pytest.raises(TypeError):
+        RingClusterMotor(motor=base_motor, number="two", radius=0.5)
+
+
+def test_cluster_methods_and_setters(base_motor):
+    """Touches the display methods and setters to ensure coverage."""
+    cluster = RingClusterMotor(motor=base_motor, number=2, radius=0.5)
+
+    cluster.info()
+
+    cluster.draw_cluster_layout(show=False)
+    cluster.draw_cluster_layout(rocket_radius=0.1, show=False)
+
+    cluster.propellant_mass = 50.0
+    assert cluster.propellant_mass == 50.0
+    cluster.propellant_I_11 = 2.0
+    assert cluster.propellant_I_11 == 2.0
+
+
+def test_cluster_propellant_inertia_dynamic(base_motor):
+    """
+    Tests if the Steiner theorem is correctly applied dynamically
+    via exact geometric summation, especially for N=2.
+    """
+    N = 2
+    R = 0.5
+    cluster = RingClusterMotor(motor=base_motor, number=N, radius=R)
+
+    t = 0
+
+    m_prop = base_motor.propellant_mass(t)
+    Ixx_prop_loc = base_motor.propellant_I_11(t)
+    Izz_prop_loc = base_motor.propellant_I_33(t)
+
+    expected_ixx = (Ixx_prop_loc * N) + 0
+
+    expected_izz = (Izz_prop_loc * N) + (m_prop * N * R**2)
+
+    assert np.isclose(cluster.propellant_I_11(t), expected_ixx)  # pylint: disable=not-callable
+    assert np.isclose(cluster.propellant_I_33(t), expected_izz)
+
+
+def test_ring_cluster_motor_full_flight(
+    calisto_motorless,
+    calisto_nose_cone,
+    calisto_tail,
+    calisto_trapezoidal_fins,
+    example_plain_env,
+):
+    """Integration test for PR #924: a ``RingClusterMotor`` must work
+    end-to-end when mounted on a ``Rocket`` and flown to apogee. The clustered
+    motor scales the base motor's thrust and total impulse by the number of
+    motors, and the rocket must reach a positive apogee.
+
+    A lightweight base motor is used on purpose so the Calisto airframe stays
+    aerodynamically stable (positive static margin) with the extra aft mass.
+    """
+    # Lightweight base motor so the clustered aft mass keeps the rocket stable.
+    base = SolidMotor(
+        thrust_source=lambda t: 800 if t < 3 else 0,
+        burn_time=3,
+        dry_mass=0.2,
+        dry_inertia=(0.01, 0.01, 0.001),
+        grain_number=1,
+        grain_density=1700,
+        grain_outer_radius=0.02,
+        grain_initial_inner_radius=0.01,
+        grain_initial_height=0.1,
+        nozzle_radius=0.01,
+        grain_separation=0.001,
+        grains_center_of_mass_position=0.1,
+        center_of_dry_mass_position=0.1,
+        coordinate_system_orientation="nozzle_to_combustion_chamber",
+    )
+    number = 2
+    cluster = RingClusterMotor(motor=base, number=number, radius=0.03)
+
+    # Clustered thrust / total impulse scale with the number of motors.
+    assert np.isclose(cluster.thrust(1), base.thrust(1) * number)
+    assert np.isclose(cluster.total_impulse, base.total_impulse * number)
+
+    rocket = calisto_motorless
+    rocket.add_motor(cluster, position=-1.373)
+    # Add all aerodynamic surfaces at once so the intermediate (fin-less) state
+    # is never evaluated as unstable during construction.
+    rocket.add_surfaces(
+        [calisto_nose_cone, calisto_tail, calisto_trapezoidal_fins],
+        [1.160, -1.313, -1.168],
+    )
+    rocket.set_rail_buttons(
+        upper_button_position=0.082,
+        lower_button_position=-0.618,
+        angular_position=0,
+    )
+
+    # The clustered motor keeps the rocket aerodynamically stable.
+    assert rocket.static_margin(0) > 0
+
+    flight = Flight(
+        rocket=rocket,
+        environment=example_plain_env,
+        rail_length=5.2,
+        inclination=85,
+        heading=0,
+        terminate_on_apogee=True,
+    )
+
+    assert flight.rocket.motor is cluster
+    assert flight.t_final > 0
+    assert flight.apogee > flight.env.elevation
+
+
+def test_cluster_transverse_inertia_asymmetry(base_motor):
+    """For N=2 the ring cluster is not axisymmetric, so the assembled I_22 must
+    differ from I_11. For a symmetric N=4 arrangement they must match. The base
+    Motor.I_22 previously returned I_11 unconditionally, which was wrong for the
+    N=2 case the class was specifically designed to handle."""
+    cluster_2 = RingClusterMotor(motor=base_motor, number=2, radius=0.5)
+    assert not np.isclose(cluster_2.I_22(1.0), cluster_2.I_11(1.0))
+
+    cluster_4 = RingClusterMotor(motor=base_motor, number=4, radius=0.5)
+    assert np.isclose(cluster_4.I_22(1.0), cluster_4.I_11(1.0))
+
+
+def test_cluster_scales_nozzle_area_and_forwards_reference_pressure():
+    """The cluster must forward the base motor's reference_pressure and scale
+    the nozzle exit area by the number of motors, so the pressure-thrust
+    correction stays consistent with the N-scaled thrust."""
+    thrust_curve = Function(lambda t: 1000 if t < 5 else 0, "Time (s)", "Thrust (N)")
+    motor = SolidMotor(
+        thrust_source=thrust_curve,
+        burn_time=5,
+        dry_mass=10.0,
+        dry_inertia=(1.0, 1.0, 0.1),
+        grain_number=1,
+        grain_density=1000,
+        grain_outer_radius=0.05,
+        grain_initial_inner_radius=0.02,
+        grain_initial_height=0.5,
+        coordinate_system_orientation="nozzle_to_combustion_chamber",
+        nozzle_radius=0.02,
+        grain_separation=0.001,
+        grains_center_of_mass_position=0.25,
+        center_of_dry_mass_position=0.25,
+        reference_pressure=101325,
+    )
+    number = 3
+    cluster = RingClusterMotor(motor=motor, number=number, radius=0.2)
+
+    assert cluster.reference_pressure == 101325
+    assert np.isclose(cluster.nozzle_area, np.pi * motor.nozzle_radius**2 * number)
+    # Vacuum thrust must be N times the single-motor vacuum thrust.
+    assert np.isclose(cluster.vacuum_thrust(1), motor.vacuum_thrust(1) * number)
diff --git a/tests/integration/motors/test_solid_motor.py b/tests/integration/motors/test_solid_motor.py
index b2b06409c..46638cca1 100644
--- a/tests/integration/motors/test_solid_motor.py
+++ b/tests/integration/motors/test_solid_motor.py
@@ -56,3 +56,32 @@ def test_evaluate_geometry_updates_properties(cesaroni_m1670):
 
     # evaluate at intermediate time
     assert isinstance(motor.grain_inner_radius(0.5), float)
+
+
+def test_only_radial_burn_grain_geometry_time_evolution(cesaroni_m1670):
+    """Regression for PR #944 (corrected Jacobian of the ``only_radial_burn``
+    branch of ``evaluate_geometry``).
+
+    With radial-only burning the integrated grain geometry over the full burn
+    must have a constant grain height (no axial regression) and a monotonically
+    growing inner radius, bounded by the initial inner radius and the grain
+    outer radius. A wrong Jacobian corrupts this integration.
+    """
+    motor = cesaroni_m1670
+    motor.only_radial_burn = True
+    motor.evaluate_geometry()
+
+    times = np.linspace(0, motor.grain_burn_out, 30)
+    heights = np.array([motor.grain_height(t) for t in times])
+    radii = np.array([motor.grain_inner_radius(t) for t in times])
+
+    # Radial-only burn: grain height stays at its initial value the whole burn.
+    assert np.allclose(heights, motor.grain_initial_height, atol=1e-9)
+
+    # Inner radius grows monotonically (non-decreasing) and actually increases.
+    assert np.all(np.diff(radii) >= -1e-12)
+    assert radii[-1] > radii[0]
+
+    # Physical bounds on the inner radius.
+    assert np.isclose(radii[0], motor.grain_initial_inner_radius)
+    assert radii[-1] <= motor.grain_outer_radius + 1e-12
diff --git a/tests/integration/simulation/test_flight.py b/tests/integration/simulation/test_flight.py
index 66f0848a4..7983c0348 100644
--- a/tests/integration/simulation/test_flight.py
+++ b/tests/integration/simulation/test_flight.py
@@ -2,6 +2,7 @@
 
 import matplotlib as plt
 import numpy as np
+import numpy.testing as npt
 import pytest
 
 from rocketpy import Flight
@@ -385,7 +386,8 @@ def test_freestream_speed_at_apogee(example_plain_env, calisto):
     """
     # NOTE: this rocket doesn't move in x or z direction. There's no wind.
     hard_atol = 1e-12
-    soft_atol = 1e-6
+    soft_atol = 1e-5
+    soft_rtol = 1e-4
     test_flight = Flight(
         environment=example_plain_env,
         rocket=calisto,
@@ -396,25 +398,36 @@ def test_freestream_speed_at_apogee(example_plain_env, calisto):
         atol=13 * [hard_atol],
     )
 
-    assert np.isclose(
+    npt.assert_allclose(
         test_flight.stream_velocity_x(test_flight.apogee_time),
-        0.4641492104717301,
+        0.4641507314747016,
         atol=hard_atol,
+        rtol=soft_rtol,
     )
-    assert np.isclose(
-        test_flight.stream_velocity_y(test_flight.apogee_time), 0.0, atol=hard_atol
+    npt.assert_allclose(
+        test_flight.stream_velocity_y(test_flight.apogee_time),
+        0.0,
+        atol=hard_atol,
+        rtol=soft_rtol,
     )
     # NOTE: stream_velocity_z has a higher error due to apogee detection estimation
-    assert np.isclose(
-        test_flight.stream_velocity_z(test_flight.apogee_time), 0.0, atol=soft_atol
+    npt.assert_allclose(
+        test_flight.stream_velocity_z(test_flight.apogee_time),
+        0.0,
+        atol=soft_atol,
+        rtol=soft_rtol,
     )
-    assert np.isclose(
+    npt.assert_allclose(
         test_flight.free_stream_speed(test_flight.apogee_time),
-        0.4641492104717798,
+        0.46415073147558955,
         atol=hard_atol,
+        rtol=soft_rtol,
     )
-    assert np.isclose(
-        test_flight.apogee_freestream_speed, 0.4641492104717798, atol=hard_atol
+    npt.assert_allclose(
+        test_flight.apogee_freestream_speed,
+        0.46415073147558955,
+        atol=hard_atol,
+        rtol=soft_rtol,
     )
 
 
@@ -811,3 +824,127 @@ def test_environment_methods_accessible_in_controller(
 
     # Verify all environment methods were successfully called
     assert all(methods_called.values()), f"Not all methods called: {methods_called}"
+
+
+def test_continuous_controller_invoked_every_step(calisto_robust, example_plain_env):
+    """A continuous controller (sampling_rate=None) must be called on every
+    solver step and receive the same state_history layout as a discrete one:
+    time-prefixed rows (`[t, *state]`), one element longer than ``state``.
+    This locks in the discrete/continuous parity contract."""
+    calls = {"count": 0, "sampling_rates": set(), "row_len_matches": True}
+
+    def recording_controller(  # pylint: disable=unused-argument
+        time, sampling_rate, state, state_history, observed_variables, air_brakes
+    ):
+        calls["count"] += 1
+        calls["sampling_rates"].add(sampling_rate)
+        # state_history rows are time-prefixed: exactly one longer than state
+        if len(state_history[-1]) != len(state) + 1:
+            calls["row_len_matches"] = False
+
+    calisto_robust.parachutes = []
+    calisto_robust.add_air_brakes(
+        drag_coefficient_curve="data/rockets/calisto/air_brakes_cd.csv",
+        controller_function=recording_controller,
+        sampling_rate=None,  # continuous
+        clamp=True,
+    )
+
+    flight = Flight(
+        rocket=calisto_robust,
+        environment=example_plain_env,
+        rail_length=5.2,
+        inclination=85,
+        heading=0,
+        time_overshoot=False,
+        terminate_on_apogee=True,
+    )
+
+    assert flight.t_final > 0
+    # Called many times (once per solver step), far more than any fixed rate
+    assert calls["count"] > 50
+    # The controller always saw sampling_rate=None (continuous)
+    assert calls["sampling_rates"] == {None}
+    # And time-prefixed rows, consistent with the discrete controller path
+    assert calls["row_len_matches"]
+
+
+def test_discrete_controller_invoked_once_per_node(calisto_robust, example_plain_env):
+    """Regression for PR #949 (remove duplicate controller process).
+
+    A discrete controller must be invoked exactly once per time node. The
+    removed duplicate loop invoked it a second time back-to-back with the
+    identical simulation time, so no consecutive controller call may share the
+    same time value.
+    """
+    times = []
+
+    def recording_controller(  # pylint: disable=unused-argument
+        time, sampling_rate, state, state_history, observed_variables, air_brakes
+    ):
+        times.append(time)
+
+    calisto_robust.parachutes = []
+    calisto_robust.add_air_brakes(
+        drag_coefficient_curve="data/rockets/calisto/air_brakes_cd.csv",
+        controller_function=recording_controller,
+        sampling_rate=10,  # discrete controller
+        clamp=True,
+    )
+
+    flight = Flight(
+        rocket=calisto_robust,
+        environment=example_plain_env,
+        rail_length=5.2,
+        inclination=85,
+        heading=0,
+        time_overshoot=False,
+        terminate_on_apogee=True,
+    )
+
+    assert flight.t_final > 0
+    assert len(times) > 10
+    # The duplicate-process bug produced two consecutive calls at the same time.
+    assert all(times[i] != times[i + 1] for i in range(len(times) - 1))
+    # No node time is processed more than once over the whole flight.
+    assert len(times) == len(set(times))
+
+
+def test_acceleration_based_parachute_trigger_deploys(
+    calisto_robust, example_plain_env
+):
+    """Integration test for PR #911: a parachute whose trigger consumes the
+    acceleration vector (a 4-argument trigger with a ``u_dot`` parameter) must
+    deploy during a full flight, exercising the u_dot code path end-to-end."""
+
+    def acc_trigger(p, h, y, u_dot):  # pylint: disable=unused-argument
+        # y[5] = vertical velocity; u_dot[5] = vertical acceleration.
+        # Deploy once descending with a downward acceleration (just past apogee).
+        return y[5] < 0 and u_dot[5] < 0
+
+    calisto_robust.parachutes = []
+    chute = calisto_robust.add_parachute(
+        name="acc_chute",
+        cd_s=10.0,
+        trigger=acc_trigger,
+        sampling_rate=100,
+        lag=0,
+    )
+
+    # Do NOT terminate at apogee: the flight must descend for the trigger to fire.
+    flight = Flight(
+        rocket=calisto_robust,
+        environment=example_plain_env,
+        rail_length=5.2,
+        inclination=85,
+        heading=0,
+    )
+
+    # The acceleration (u_dot) trigger path was selected for this trigger.
+    assert getattr(chute.triggerfunc, "_expects_udot", False)
+
+    # The chute deployed, and did so essentially at apogee (first downward accel).
+    assert len(flight.parachute_events) >= 1
+    deploy_time, deployed = flight.parachute_events[0]
+    assert deployed.name == "acc_chute"
+    assert abs(flight.z(deploy_time) - flight.apogee) <= 5
diff --git a/tests/integration/simulation/test_monte_carlo.py b/tests/integration/simulation/test_monte_carlo.py
index 4b1b82392..bcfb59505 100644
--- a/tests/integration/simulation/test_monte_carlo.py
+++ b/tests/integration/simulation/test_monte_carlo.py
@@ -119,7 +119,7 @@ def test_monte_carlo_prints(monte_carlo_calisto):
         _post_test_file_cleanup()
 
 
-@patch("matplotlib.pyplot.show")  # pylint: disable=unused-argument
+@patch("matplotlib.pyplot.show")
 def test_monte_carlo_plots(mock_show, monte_carlo_calisto_pre_loaded):
     """Tests the plots methods of the MonteCarlo class."""
     try:
@@ -236,3 +236,30 @@ def invalid_data_collector(flight):
             monte_carlo_calisto.simulate(number_of_simulations=10, append=False)
     finally:
         _post_test_file_cleanup()
+
+
+@pytest.mark.slow
+def test_monte_carlo_simulate_convergence(monte_carlo_calisto):
+    """Tests the simulate_convergence method of the MonteCarlo class.
+
+    Parameters
+    ----------
+    monte_carlo_calisto : MonteCarlo
+        The MonteCarlo object, this is a pytest fixture.
+    """
+    try:
+        ci_history = monte_carlo_calisto.simulate_convergence(
+            target_attribute="apogee",
+            target_confidence=0.95,
+            tolerance=5.0,
+            max_simulations=20,
+            batch_size=5,
+            parallel=False,
+        )
+
+        assert isinstance(ci_history, list)
+        assert all(isinstance(width, float) for width in ci_history)
+        assert len(ci_history) >= 1
+        assert monte_carlo_calisto.num_of_loaded_sims <= 20
+    finally:
+        _post_test_file_cleanup()
diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py
index 38d5aef41..0e3209aa9 100644
--- a/tests/integration/test_encoding.py
+++ b/tests/integration/test_encoding.py
@@ -100,7 +100,7 @@ def test_flight_save_load_resimulate(flight_name, include_outputs, request):
 
     assert np.isclose(flight_to_save.t_initial, flight_loaded.t_initial)
     assert np.isclose(flight_to_save.out_of_rail_time, flight_loaded.out_of_rail_time)
-    assert np.isclose(flight_to_save.apogee_time, flight_loaded.apogee_time)
+    assert np.isclose(flight_to_save.apogee_time, flight_loaded.apogee_time, rtol=1e-4)
 
     # Higher tolerance due to random parachute trigger
     assert np.isclose(flight_to_save.t_final, flight_loaded.t_final, rtol=5e-3)
@@ -273,10 +273,49 @@ def test_trapezoidal_fins_encoder(fin_name, request):
     assert np.isclose(fin_to_encode.tip_chord, fin_loaded.tip_chord)
     assert np.isclose(fin_to_encode.rocket_radius, fin_loaded.rocket_radius)
     assert np.isclose(fin_to_encode.sweep_length, fin_loaded.sweep_length)
-    if fin_to_encode._sweep_angle is not None and fin_loaded._sweep_angle is not None:
+    if fin_to_encode.sweep_angle is not None and fin_loaded.sweep_angle is not None:
         assert np.isclose(fin_to_encode.sweep_angle, fin_loaded.sweep_angle)
 
 
+@pytest.mark.parametrize(
+    "fin_name",
+    [
+        "calisto_trapezoidal_fin",
+        "calisto_elliptical_fin",
+        "calisto_free_form_fin",
+    ],
+)
+@pytest.mark.parametrize("include_outputs", [False, True])
+def test_individual_fin_encoder(fin_name, include_outputs, request):
+    """Test encoding an individual fin (``TrapezoidalFin``, ``EllipticalFin``
+    or ``FreeFormFin``).
+
+    Parameters
+    ----------
+    fin_name : str
+        Name of the fin fixture to encode.
+    include_outputs : bool
+        Whether to include outputs in the encoding.
+    request : pytest.FixtureRequest
+        Pytest request object.
+    """
+    fin_to_encode = request.getfixturevalue(fin_name)
+
+    json_encoded = json.dumps(
+        fin_to_encode, cls=RocketPyEncoder, include_outputs=include_outputs
+    )
+
+    fin_loaded = json.loads(json_encoded, cls=RocketPyDecoder)
+
+    assert isinstance(fin_loaded, type(fin_to_encode))
+    assert np.isclose(fin_to_encode.angular_position, fin_loaded.angular_position)
+    assert np.isclose(fin_to_encode.span, fin_loaded.span)
+    assert np.isclose(fin_to_encode.root_chord, fin_loaded.root_chord)
+    assert np.isclose(fin_to_encode.rocket_radius, fin_loaded.rocket_radius)
+    assert np.isclose(fin_to_encode.cant_angle, fin_loaded.cant_angle)
+    assert np.allclose(fin_to_encode.cp, fin_loaded.cp)
+
+
 @pytest.mark.parametrize("rocket_name", ["calisto_robust", "calisto_hybrid_modded"])
 def test_encoder_discretize(rocket_name, request):
     """Test encoding the total mass of ``rocketpy.Rocket`` with
diff --git a/tests/integration/test_plots.py b/tests/integration/test_plots.py
index 933737c4b..c01833f90 100644
--- a/tests/integration/test_plots.py
+++ b/tests/integration/test_plots.py
@@ -3,11 +3,36 @@
 from unittest.mock import patch
 
 import matplotlib.pyplot as plt
+import pytest
 
 from rocketpy import Flight
 from rocketpy.plots.compare import CompareFlights
 
 
+def test_flight_animations_run_off_screen(flight_calisto):
+    """Ensure both PyVista flight animations render successfully off screen."""
+
+    # Arrange
+    pytest.importorskip("pyvista")
+    animation_options = {
+        "start": 0,
+        "stop": 0.001,
+        "time_step": 0.001,
+        "playback_controls": False,
+        "backend": "none",
+        "off_screen": True,
+        "window_size": (320, 240),
+    }
+
+    # Act
+    trajectory_result = flight_calisto.plots.animate_trajectory(**animation_options)
+    rotation_result = flight_calisto.plots.animate_rotate(**animation_options)
+
+    # Assert
+    assert trajectory_result is None
+    assert rotation_result is None
+
+
 @patch("matplotlib.pyplot.show")
 def test_compare(mock_show, flight_calisto):
     """Here we want to test the 'x_attributes' argument, which is the only one
diff --git a/tests/integration/test_rocket.py b/tests/integration/test_rocket.py
index c47096617..e36dd933e 100644
--- a/tests/integration/test_rocket.py
+++ b/tests/integration/test_rocket.py
@@ -19,7 +19,7 @@ def test_airfoil(
     calisto.add_surfaces(calisto_tail, -1.313)
 
     test_rocket.add_trapezoidal_fins(
-        2,
+        3,
         span=0.100,
         root_chord=0.120,
         tip_chord=0.040,
@@ -28,7 +28,7 @@ def test_airfoil(
         name="NACA0012",
     )
     test_rocket.add_trapezoidal_fins(
-        2,
+        3,
         span=0.100,
         root_chord=0.120,
         tip_chord=0.040,
diff --git a/tests/integration/test_sensor.py b/tests/integration/test_sensor.py
index dc0c37258..1655d1319 100644
--- a/tests/integration/test_sensor.py
+++ b/tests/integration/test_sensor.py
@@ -11,6 +11,7 @@
 from rocketpy.sensors.barometer import Barometer
 from rocketpy.sensors.gnss_receiver import GnssReceiver
 from rocketpy.sensors.gyroscope import Gyroscope
+from rocketpy.simulation import FlightDataExporter
 
 
 def test_sensor_on_rocket(calisto_with_sensors):
@@ -113,7 +114,9 @@ def test_export_all_sensors_data(flight_calisto_with_sensors):
         Pytest fixture for the flight of the calisto rocket with a set of ideal
         sensors.
     """
-    flight_calisto_with_sensors.export_sensor_data("test_sensor_data.json")
+    FlightDataExporter(flight_calisto_with_sensors).export_sensor_data(
+        "test_sensor_data.json"
+    )
     # read the json and parse as dict
     filename = "test_sensor_data.json"
     with open(filename, "r") as f:
@@ -166,7 +169,9 @@ def test_export_single_sensor_data(flight_calisto_with_sensors):
         Pytest fixture for the flight of the calisto rocket with a set of ideal
         sensors.
     """
-    flight_calisto_with_sensors.export_sensor_data("test_sensor_data.json", "Gyroscope")
+    FlightDataExporter(flight_calisto_with_sensors).export_sensor_data(
+        "test_sensor_data.json", "Gyroscope"
+    )
     # read the json and parse as dict
     filename = "test_sensor_data.json"
     with open(filename, "r") as f:
diff --git a/tests/unit/environment/test_environment.py b/tests/unit/environment/test_environment.py
index 6ad3e51db..a7e50b392 100644
--- a/tests/unit/environment/test_environment.py
+++ b/tests/unit/environment/test_environment.py
@@ -1,12 +1,52 @@
 import json
 import os
+from datetime import datetime
 
 import numpy as np
+import numpy.testing as npt
 import pytest
 import pytz
 
 from rocketpy import Environment
-from rocketpy.environment.tools import geodesic_to_utm, utm_to_geodesic
+from rocketpy.environment.tools import (
+    find_longitude_index,
+    geodesic_to_lambert_conformal,
+    geodesic_to_utm,
+    get_final_date_from_time_array,
+    get_initial_date_from_time_array,
+    utm_to_geodesic,
+)
+from rocketpy.environment.weather_model_mapping import WeatherModelMapping
+
+
+class DummyLambertProjection:
+    """Minimal projection metadata container for unit tests."""
+
+    latitude_of_projection_origin = 40.0
+    longitude_of_central_meridian = 263.0
+    standard_parallel = np.array([30.0, 60.0])
+    earth_radius = 6371229.0
+
+
+@pytest.mark.parametrize(
+    "date_helper", [get_initial_date_from_time_array, get_final_date_from_time_array]
+)
+def test_time_array_date_helpers_convert_cftime_dates(
+    monkeypatch, date_helper, dummy_time_array, dummy_cftime_date
+):
+    """Convert NetCDF/cftime date objects to JSON-serializable datetimes."""
+
+    # Arrange
+    def fake_num2date(*_args, **_kwargs):
+        return dummy_cftime_date
+
+    monkeypatch.setattr("rocketpy.environment.tools.netCDF4.num2date", fake_num2date)
+
+    # Act
+    converted_date = date_helper(dummy_time_array)
+
+    # Assert
+    assert converted_date == datetime(2023, 6, 24, 9, 30, 15, 123456)
 
 
 @pytest.mark.parametrize(
@@ -111,6 +151,52 @@ class and checks the conversion results from UTM to geodesic
     assert np.isclose(lon, -106.9750, atol=1e-5)
 
 
+def test_geodesic_to_lambert_conformal_projection_origin_maps_to_zero():
+    """Tests wrapped central meridian maps to coordinate origin in Lambert conformal."""
+    projection = DummyLambertProjection()
+
+    x, y = geodesic_to_lambert_conformal(
+        lat=projection.latitude_of_projection_origin,
+        lon=projection.longitude_of_central_meridian % 360,
+        projection_variable=projection,
+        x_units="m",
+    )
+
+    assert np.isclose(x, 0.0, atol=1e-8)
+    assert np.isclose(y, 0.0, atol=1e-8)
+
+
+def test_geodesic_to_lambert_conformal_km_units_scale_from_meters():
+    """Tests Lambert conformal conversion scales outputs from meters to km."""
+    projection = DummyLambertProjection()
+
+    x_meters, y_meters = geodesic_to_lambert_conformal(
+        lat=39.0,
+        lon=-96.0,
+        projection_variable=projection,
+        x_units="m",
+    )
+    x_km, y_km = geodesic_to_lambert_conformal(
+        lat=39.0,
+        lon=-96.0,
+        projection_variable=projection,
+        x_units="km",
+    )
+
+    assert np.isclose(x_km, x_meters / 1000.0, atol=1e-8)
+    assert np.isclose(y_km, y_meters / 1000.0, atol=1e-8)
+
+
+def test_find_longitude_index_accepts_lower_grid_boundary():
+    """Tests longitude equal to first grid value is accepted as in-range."""
+    lon_list = [0.0, 0.25, 0.5]
+
+    lon, lon_index = find_longitude_index(0.0, lon_list)
+
+    assert lon == 0.0
+    assert lon_index == 1
+
+
 @pytest.mark.parametrize(
     "latitude, theoretical_radius",
     [(0, 6378137.0), (90, 6356752.31424518), (-90, 6356752.31424518)],
@@ -229,17 +315,506 @@ def test_environment_export_environment_exports_valid_environment_json(
     assert exported_env["atmospheric_model_type"] == env.atmospheric_model_type
     assert exported_env["atmospheric_model_file"] is None
     assert exported_env["atmospheric_model_dict"] is None
-    assert exported_env["atmospheric_model_pressure_profile"] == str(
+    assert str(exported_env["atmospheric_model_pressure_profile"]) == str(
         env.pressure.get_source()
     )
-    assert exported_env["atmospheric_model_temperature_profile"] == str(
+    assert str(exported_env["atmospheric_model_temperature_profile"]) == str(
         env.temperature.get_source()
     )
-    assert exported_env["atmospheric_model_wind_velocity_x_profile"] == str(
+    assert str(exported_env["atmospheric_model_wind_velocity_x_profile"]) == str(
         env.wind_velocity_x.get_source()
     )
-    assert exported_env["atmospheric_model_wind_velocity_y_profile"] == str(
+    assert str(exported_env["atmospheric_model_wind_velocity_y_profile"]) == str(
         env.wind_velocity_y.get_source()
     )
 
     os.remove("environment.json")
+
+
+@pytest.mark.parametrize(
+    "atmospheric_model_type", ["windy", "forecast", "reanalysis", "ensemble"]
+)
+def test_environment_to_dict_from_dict_round_trip_preserves_weather_metadata(
+    example_plain_env, atmospheric_model_type
+):
+    """Round-trip weather-model environments without losing metadata.
+
+    Parameters
+    ----------
+    example_plain_env : rocketpy.Environment
+        Baseline environment used to build the serialized state.
+    atmospheric_model_type : str
+        Weather-model label stored in the serialized payload.
+    """
+    # Arrange
+    env = example_plain_env
+
+    weather_metadata = {
+        "atmospheric_model_type": atmospheric_model_type,
+        "atmospheric_model_file": None,
+        "atmospheric_model_dict": {"time": "time"},
+        "atmospheric_model_init_date": datetime(2024, 1, 1, 0),
+        "atmospheric_model_end_date": datetime(2024, 1, 1, 6),
+        "atmospheric_model_interval": 6,
+        "atmospheric_model_init_lat": -10.0,
+        "atmospheric_model_end_lat": 10.0,
+        "atmospheric_model_init_lon": -20.0,
+        "atmospheric_model_end_lon": 20.0,
+    }
+
+    ensemble_metadata = {
+        "level_ensemble": None,
+        "height_ensemble": None,
+        "temperature_ensemble": None,
+        "wind_u_ensemble": None,
+        "wind_v_ensemble": None,
+        "wind_heading_ensemble": None,
+        "wind_direction_ensemble": None,
+        "wind_speed_ensemble": None,
+        "num_ensemble_members": None,
+    }
+
+    if atmospheric_model_type == "ensemble":
+        ensemble_metadata.update(
+            {
+                "level_ensemble": np.array([1000.0, 900.0]),
+                "height_ensemble": np.array([[0.0, 1000.0]]),
+                "temperature_ensemble": np.array([[288.15, 281.15]]),
+                "wind_u_ensemble": np.array([[2.0, 3.0]]),
+                "wind_v_ensemble": np.array([[4.0, 5.0]]),
+                "wind_heading_ensemble": np.array([[26.565051, 30.963757]]),
+                "wind_direction_ensemble": np.array([[206.565051, 210.963757]]),
+                "wind_speed_ensemble": np.array([[4.472136, 5.830952]]),
+                "num_ensemble_members": 1,
+            }
+        )
+
+    for metadata in (weather_metadata, ensemble_metadata):
+        for attribute, value in metadata.items():
+            setattr(env, attribute, value)
+
+    env_dict = env.to_dict()
+
+    # The serialized payload should be self-contained and not depend on files.
+    assert "atmospheric_model_file" not in env_dict
+    assert "atmospheric_model_dict" not in env_dict
+
+    # Act
+    restored_env = Environment.from_dict(env_dict)
+
+    # Assert
+    assert restored_env.atmospheric_model_type == atmospheric_model_type
+    assert restored_env.atmospheric_model_init_date == env.atmospheric_model_init_date
+    assert restored_env.atmospheric_model_end_date == env.atmospheric_model_end_date
+    assert restored_env.atmospheric_model_interval == env.atmospheric_model_interval
+    assert restored_env.atmospheric_model_init_lat == env.atmospheric_model_init_lat
+    assert restored_env.atmospheric_model_end_lat == env.atmospheric_model_end_lat
+    assert restored_env.atmospheric_model_init_lon == env.atmospheric_model_init_lon
+    assert restored_env.atmospheric_model_end_lon == env.atmospheric_model_end_lon
+
+    if atmospheric_model_type == "ensemble":
+        npt.assert_allclose(restored_env.level_ensemble, env.level_ensemble)
+        npt.assert_allclose(restored_env.height_ensemble, env.height_ensemble)
+        assert restored_env.num_ensemble_members == env.num_ensemble_members
+
+
+class _DummyDataset:
+    """Small test double that mimics a netCDF dataset variables mapping."""
+
+    def __init__(self, variable_names):
+        self.variables = {name: object() for name in variable_names}
+
+
+def test_resolve_dictionary_keeps_compatible_mapping(example_plain_env):
+    """Keep the user-selected mapping when it already matches dataset keys."""
+    gfs_mapping = example_plain_env._Environment__weather_model_map.get("GFS")
+    dataset = _DummyDataset(
+        [
+            "time",
+            "lat",
+            "lon",
+            "isobaric",
+            "Temperature_isobaric",
+            "Geopotential_height_isobaric",
+            "u-component_of_wind_isobaric",
+            "v-component_of_wind_isobaric",
+        ]
+    )
+
+    resolved = example_plain_env._Environment__resolve_dictionary_for_dataset(
+        gfs_mapping, dataset
+    )
+
+    assert resolved is gfs_mapping
+
+
+def test_resolve_dictionary_falls_back_to_first_compatible_mapping(example_plain_env):
+    """Fallback to the first compatible built-in mapping for legacy-style files."""
+    thredds_gfs_mapping = example_plain_env._Environment__weather_model_map.get("GFS")
+    dataset = _DummyDataset(
+        [
+            "time",
+            "lat",
+            "lon",
+            "lev",
+            "tmpprs",
+            "hgtprs",
+            "ugrdprs",
+            "vgrdprs",
+        ]
+    )
+
+    resolved = example_plain_env._Environment__resolve_dictionary_for_dataset(
+        thredds_gfs_mapping, dataset
+    )
+
+    assert resolved == example_plain_env._Environment__weather_model_map.get(
+        "GFS_LEGACY"
+    )
+    assert resolved["level"] == "lev"
+    assert resolved["temperature"] == "tmpprs"
+    assert resolved["geopotential_height"] == "hgtprs"
+
+
+def test_weather_model_mapping_exposes_legacy_aliases():
+    """Legacy mapping names should be available and case-insensitive."""
+    mapping = WeatherModelMapping()
+
+    assert mapping.get("GFS_LEGACY")["temperature"] == "tmpprs"
+    assert mapping.get("gfs_legacy")["temperature"] == "tmpprs"
+
+
+def test_dictionary_matches_dataset_rejects_missing_projection(example_plain_env):
+    """Reject mapping when projection key is declared but variable is missing."""
+    # Arrange
+    mapping = {
+        "time": "time",
+        "latitude": "y",
+        "longitude": "x",
+        "projection": "LambertConformal_Projection",
+        "level": "isobaric",
+        "temperature": "Temperature_isobaric",
+        "geopotential_height": "Geopotential_height_isobaric",
+        "geopotential": None,
+        "u_wind": "u-component_of_wind_isobaric",
+        "v_wind": "v-component_of_wind_isobaric",
+    }
+    dataset = _DummyDataset(
+        [
+            "time",
+            "y",
+            "x",
+            "isobaric",
+            "Temperature_isobaric",
+            "Geopotential_height_isobaric",
+            "u-component_of_wind_isobaric",
+            "v-component_of_wind_isobaric",
+        ]
+    )
+
+    # Act
+    is_compatible = example_plain_env._Environment__dictionary_matches_dataset(
+        mapping, dataset
+    )
+
+    # Assert
+    assert not is_compatible
+
+
+def test_dictionary_matches_dataset_accepts_geopotential_only(example_plain_env):
+    """Accept mapping when geopotential exists and geopotential height is absent."""
+    # Arrange
+    mapping = {
+        "time": "time",
+        "latitude": "latitude",
+        "longitude": "longitude",
+        "level": "level",
+        "temperature": "t",
+        "geopotential_height": None,
+        "geopotential": "z",
+        "u_wind": "u",
+        "v_wind": "v",
+    }
+    dataset = _DummyDataset(
+        [
+            "time",
+            "latitude",
+            "longitude",
+            "level",
+            "t",
+            "z",
+            "u",
+            "v",
+        ]
+    )
+
+    # Act
+    is_compatible = example_plain_env._Environment__dictionary_matches_dataset(
+        mapping, dataset
+    )
+
+    # Assert
+    assert is_compatible
+
+
+def test_resolve_dictionary_warns_when_falling_back(example_plain_env):
+    """Emit warning and return a built-in mapping when fallback is required."""
+    # Arrange
+    incompatible_mapping = {
+        "time": "bad_time",
+        "latitude": "bad_lat",
+        "longitude": "bad_lon",
+        "level": "bad_level",
+        "temperature": "bad_temp",
+        "geopotential_height": "bad_height",
+        "geopotential": None,
+        "u_wind": "bad_u",
+        "v_wind": "bad_v",
+    }
+    dataset = _DummyDataset(
+        [
+            "time",
+            "lat",
+            "lon",
+            "isobaric",
+            "Temperature_isobaric",
+            "Geopotential_height_isobaric",
+            "u-component_of_wind_isobaric",
+            "v-component_of_wind_isobaric",
+        ]
+    )
+
+    # Act
+    with pytest.warns(UserWarning, match="Falling back to built-in mapping"):
+        resolved = example_plain_env._Environment__resolve_dictionary_for_dataset(
+            incompatible_mapping, dataset
+        )
+
+    # Assert
+    assert resolved == example_plain_env._Environment__weather_model_map.get("GFS")
+
+
+def test_resolve_dictionary_returns_original_when_no_compatible_builtin(
+    example_plain_env,
+):
+    """Return original mapping unchanged when no built-in mapping can match."""
+    # Arrange
+    original_mapping = {
+        "time": "a",
+        "latitude": "b",
+        "longitude": "c",
+        "level": "d",
+        "temperature": "e",
+        "geopotential_height": "f",
+        "geopotential": None,
+        "u_wind": "g",
+        "v_wind": "h",
+    }
+    dataset = _DummyDataset(["foo", "bar"])
+
+    # Act
+    resolved = example_plain_env._Environment__resolve_dictionary_for_dataset(
+        original_mapping, dataset
+    )
+
+    # Assert
+    assert resolved is original_mapping
+
+
+@pytest.mark.parametrize(
+    "model_type,file_name,error_message",
+    [
+        (
+            "Forecast",
+            "hiresw",
+            "HIRESW latest-model shortcut is currently unavailable",
+        ),
+        (
+            "Ensemble",
+            "gefs",
+            "GEFS latest-model shortcut is currently unavailable",
+        ),
+    ],
+)
+def test_set_atmospheric_model_blocks_deactivated_shortcuts_case_insensitive(
+    example_plain_env,
+    model_type,
+    file_name,
+    error_message,
+):
+    """Reject deactivated shortcut aliases regardless of input string case."""
+    # Arrange
+    environment = example_plain_env
+
+    # Act / Assert
+    with pytest.raises(ValueError, match=error_message):
+        environment.set_atmospheric_model(type=model_type, file=file_name)
+
+
+def test_validate_dictionary_uses_case_insensitive_file_shortcut(example_plain_env):
+    """Infer built-in mapping from file shortcut even when shortcut is lowercase."""
+    # Arrange
+    environment = example_plain_env
+
+    # Act
+    mapping = environment._Environment__validate_dictionary("gfs", None)
+
+    # Assert
+    assert mapping == environment._Environment__weather_model_map.get("GFS")
+
+
+def test_validate_dictionary_raises_type_error_for_invalid_dictionary(
+    example_plain_env,
+):
+    """Raise TypeError when no valid dictionary can be inferred."""
+    # Arrange
+    environment = example_plain_env
+
+    # Act / Assert
+    with pytest.raises(TypeError, match="Please specify a dictionary"):
+        environment._Environment__validate_dictionary("not_a_model", None)
+
+
+def test_set_atmospheric_model_normalizes_shortcut_case_for_forecast(example_plain_env):
+    """Normalize shortcut name before lookup and process forecast data."""
+    # Arrange
+    environment = example_plain_env
+
+    environment._Environment__atm_type_file_to_function_map = {
+        "forecast": {
+            "GFS": lambda: "fake-dataset",
+        },
+        "ensemble": {},
+    }
+
+    called_arguments = {}
+
+    def fake_process_forecast_reanalysis(dataset, dictionary, conversion_factor):
+        called_arguments["dataset"] = dataset
+        called_arguments["dictionary"] = dictionary
+        called_arguments["conversion_factr"] = conversion_factor
+
+    environment.process_forecast_reanalysis = fake_process_forecast_reanalysis
+
+    # Act
+    environment.set_atmospheric_model(type="Forecast", file="gfs")
+
+    # Assert
+    assert called_arguments["dataset"] == "fake-dataset"
+    assert called_arguments[
+        "dictionary"
+    ] == environment._Environment__weather_model_map.get("GFS")
+
+
+def test_set_atmospheric_model_raises_for_unknown_model_type(example_plain_env):
+    """Raise ValueError for unknown atmospheric model selector."""
+    # Arrange
+    environment = example_plain_env
+
+    # Act / Assert
+    with pytest.raises(ValueError, match="Unknown model type"):
+        environment.set_atmospheric_model(type="unknown_type")
+
+
+def test_wind_heading_direction_wraparound_interpolation(example_plain_env):
+    """Test that wind heading and direction interpolation wraps around correctly
+    across the 360°/0° boundary when initialized with a 2D array.
+    """
+    # Create discrete points at 1000m and 1100m
+    # 350 deg at 1000m, 10 deg at 1100m.
+    # Midpoint should be 360 deg or 0 deg, NOT 180 deg.
+    heading_data = np.array([[1000, 350], [1100, 10]])
+    direction_data = np.array([[1000, 350], [1100, 10]])
+
+    example_plain_env._Environment__set_wind_heading_function(heading_data)
+    example_plain_env._Environment__set_wind_direction_function(direction_data)
+
+    # Evaluate at midpoint (1050m)
+    mid_heading = example_plain_env.wind_heading(1050)
+    mid_direction = example_plain_env.wind_direction(1050)
+
+    # Check that it's close to 0 or 360 (which is also 0 modulo 360)
+    assert np.isclose(mid_heading, 0.0) or np.isclose(mid_heading, 360.0)
+    assert np.isclose(mid_direction, 0.0) or np.isclose(mid_direction, 360.0)
+
+    # Also test another wrap-around case, e.g. 10 to 350
+    heading_data2 = np.array([[1000, 10], [1100, 350]])
+    example_plain_env._Environment__set_wind_heading_function(heading_data2)
+    mid_heading2 = example_plain_env.wind_heading(1050)
+    assert np.isclose(mid_heading2, 0.0) or np.isclose(mid_heading2, 360.0)
+
+
+@pytest.mark.parametrize("shortcut_name", ["AIGFS", "HRRR"])
+def test_forecast_shortcut_and_dictionary_are_case_insensitive(
+    monkeypatch, shortcut_name
+):
+    """Ensure forecast shortcuts and built-in dictionaries ignore input casing."""
+    # Arrange
+    env = Environment(date=(2026, 3, 17, 12), latitude=32.99, longitude=-106.97)
+
+    sentinel_dataset = object()
+    env._Environment__atm_type_file_to_function_map["forecast"][shortcut_name] = (
+        lambda: sentinel_dataset
+    )
+
+    captured = {}
+
+    def fake_process_forecast_reanalysis(file, dictionary, conversion_factor):
+        captured["file"] = file
+        captured["dictionary"] = dictionary
+        captured["conversion_factor"] = conversion_factor
+
+    monkeypatch.setattr(
+        env, "process_forecast_reanalysis", fake_process_forecast_reanalysis
+    )
+    monkeypatch.setattr(env, "calculate_density_profile", lambda: None)
+    monkeypatch.setattr(env, "calculate_speed_of_sound_profile", lambda: None)
+    monkeypatch.setattr(env, "calculate_dynamic_viscosity", lambda: None)
+
+    # Act
+    env.set_atmospheric_model(
+        type="forecast",
+        file=shortcut_name.lower(),
+        dictionary=shortcut_name.lower(),
+    )
+
+    # Assert
+    expected_dictionary = env._Environment__weather_model_map.get(shortcut_name)
+    assert captured["file"] is sentinel_dataset
+    assert captured["dictionary"] == expected_dictionary
+    assert env.atmospheric_model_file == shortcut_name
+    assert env.atmospheric_model_dict == expected_dictionary
+
+
+def test_weather_model_mapping_get_is_case_insensitive():
+    """Ensure built-in mapping names are resolved regardless of casing."""
+    mapping = WeatherModelMapping()
+    assert mapping.get("aigfs") == mapping.get("AIGFS")
+    assert mapping.get("ecmwf_v0") == mapping.get("ECMWF_v0")
+
+
+@pytest.mark.parametrize(
+    "model, expected_factor",
+    [
+        # NOMADS-GrADS models expose pressure on the 'lev' coordinate in hPa
+        # and MUST be scaled by 100 (regression: they were forced to factor 1).
+        ("GEFS", 100),
+        ("HIRESW", 100),
+        # THREDDS (UCAR) models expose pressure on 'isobaric' already in Pa.
+        ("GFS", 1),
+        ("NAM", 1),
+        ("RAP", 1),
+        ("HRRR", 1),
+        ("AIGFS", 1),
+    ],
+)
+def test_pressure_conversion_factor_autodetect_by_model(
+    example_plain_env, model, expected_factor
+):
+    """Regression test for the GEFS/HIRESW pressure-unit bug: NOMADS-GrADS
+    models report pressure in hPa (factor 100), THREDDS models in Pa (factor 1).
+    A wrong factor silently corrupts the whole atmospheric profile (100x)."""
+    factor = example_plain_env._Environment__determine_pressure_conversion_factor(
+        None, None, model
+    )
+    assert factor == expected_factor
diff --git a/tests/unit/environment/test_environment_analysis.py b/tests/unit/environment/test_environment_analysis.py
index fb86e9c04..11205ebea 100644
--- a/tests/unit/environment/test_environment_analysis.py
+++ b/tests/unit/environment/test_environment_analysis.py
@@ -161,3 +161,37 @@ def test_animation_plots(mock_show, env_analysis):  # pylint: disable=unused-arg
         HTML,
     )
     os.remove("wind_rose.gif")  # remove the files created by the method
+
+
+@pytest.mark.slow
+def test_pressure_level_wind_profile_uses_velocity_components(env_analysis):
+    """Regression for PR #1041: the redundant per-level ``wind_heading`` and
+    ``wind_direction`` functions were removed from the pressure-level data.
+
+    The surviving wind profile is expressed through ``wind_velocity_x`` /
+    ``wind_velocity_y`` (and ``wind_speed``), from which heading/direction are
+    derivable. This guards against re-introducing the removed entries and
+    checks that the surviving API is internally consistent.
+    """
+    data = env_analysis.original_pressure_level_data
+    first_date = next(iter(data))
+    first_hour = next(iter(data[first_date]))
+    hour_data = data[first_date][first_hour]
+
+    # Removed, redundant entries must not come back.
+    assert "wind_heading" not in hour_data
+    assert "wind_direction" not in hour_data
+
+    # Surviving, non-redundant wind-profile entries.
+    assert "wind_velocity_x" in hour_data
+    assert "wind_velocity_y" in hour_data
+    assert "wind_speed" in hour_data
+
+    # wind_speed must remain derivable from the velocity components. Sample at
+    # an actual grid node so interpolation and the sqrt() commute exactly.
+    grid_heights = hour_data["wind_speed"].x_array
+    height = float(grid_heights[len(grid_heights) // 2])
+    vx = hour_data["wind_velocity_x"](height)
+    vy = hour_data["wind_velocity_y"](height)
+    speed = hour_data["wind_speed"](height)
+    assert speed == pytest.approx((vx**2 + vy**2) ** 0.5, rel=1e-6)
diff --git a/tests/unit/environment/test_fetchers.py b/tests/unit/environment/test_fetchers.py
new file mode 100644
index 000000000..eea06f977
--- /dev/null
+++ b/tests/unit/environment/test_fetchers.py
@@ -0,0 +1,83 @@
+import pytest
+
+from rocketpy.environment import fetchers
+
+
+@pytest.mark.parametrize(
+    "fetcher,expected_url",
+    [
+        (
+            fetchers.fetch_gfs_file_return_dataset,
+            "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/GFS/Global_0p25deg/Best",
+        ),
+        (
+            fetchers.fetch_nam_file_return_dataset,
+            "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/NAM/CONUS_12km/Best",
+        ),
+        (
+            fetchers.fetch_rap_file_return_dataset,
+            "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/RAP/CONUS_13km/Best",
+        ),
+    ],
+)
+def test_fetcher_returns_dataset_on_first_attempt(fetcher, expected_url, monkeypatch):
+    """Return dataset immediately when the first OPeNDAP attempt succeeds."""
+    # Arrange
+    calls = []
+    sentinel_dataset = object()
+
+    def fake_dataset(url):
+        calls.append(url)
+        return sentinel_dataset
+
+    monkeypatch.setattr(fetchers.netCDF4, "Dataset", fake_dataset)
+
+    # Act
+    dataset = fetcher(max_attempts=3, base_delay=2)
+
+    # Assert
+    assert dataset is sentinel_dataset
+    assert calls == [expected_url]
+
+
+def test_fetch_gfs_retries_then_succeeds(monkeypatch):
+    """Retry GFS fetch after OSError and return data once endpoint responds."""
+    # Arrange
+    attempt_counter = {"count": 0}
+    sleep_calls = []
+
+    def fake_dataset(_):
+        attempt_counter["count"] += 1
+        if attempt_counter["count"] < 3:
+            raise OSError("temporary failure")
+        return "gfs-dataset"
+
+    monkeypatch.setattr(fetchers.netCDF4, "Dataset", fake_dataset)
+    monkeypatch.setattr(fetchers.time, "sleep", sleep_calls.append)
+
+    # Act
+    dataset = fetchers.fetch_gfs_file_return_dataset(max_attempts=3, base_delay=2)
+
+    # Assert
+    assert dataset == "gfs-dataset"
+    assert sleep_calls == [2, 4]
+
+
+def test_fetch_rap_raises_runtime_error_after_max_attempts(monkeypatch):
+    """Raise RuntimeError when all RAP attempts fail with OSError."""
+    # Arrange
+    sleep_calls = []
+
+    def always_fails(_):
+        raise OSError("endpoint down")
+
+    monkeypatch.setattr(fetchers.netCDF4, "Dataset", always_fails)
+    monkeypatch.setattr(fetchers.time, "sleep", sleep_calls.append)
+
+    # Act / Assert
+    with pytest.raises(
+        RuntimeError, match="Unable to load latest weather data for RAP"
+    ):
+        fetchers.fetch_rap_file_return_dataset(max_attempts=2, base_delay=2)
+
+    assert sleep_calls == [2, 4]
diff --git a/tests/unit/mathutils/test_function.py b/tests/unit/mathutils/test_function.py
index 93c439def..4243a4dbe 100644
--- a/tests/unit/mathutils/test_function.py
+++ b/tests/unit/mathutils/test_function.py
@@ -2,11 +2,15 @@
 individual method of the Function class. The tests are made on both the
 expected behaviour and the return instances."""
 
+import weakref
+
 import matplotlib as plt
 import numpy as np
 import pytest
 
 from rocketpy import Function
+from rocketpy.mathutils._calc.polation_nd import LinearNDPolation
+from rocketpy.mathutils.function import SourceType
 
 plt.rcParams.update({"figure.max_open_warning": 0})
 
@@ -111,6 +115,24 @@ def test_differentiate_complex_step(
     )
 
 
+@pytest.mark.parametrize(
+    "interpolation", ["linear", "polynomial", "spline", "akima", "pchip"]
+)
+def test_differentiate_complex_step_1d_array_source(interpolation):
+    """Complex-step differentiation supports every 1-D interpolator."""
+    # Arrange
+    source = np.column_stack((np.arange(4.0), np.arange(4.0) ** 2))
+    func = Function(source, interpolation=interpolation, extrapolation="natural")
+    point = 1.5
+    expected_derivative = func._evaluator.derivative(point)
+
+    # Act
+    derivative = func.differentiate_complex_step(point)
+
+    # Assert
+    assert derivative == pytest.approx(expected_derivative)
+
+
 def test_get_value():
     """Tests the get_value method of the Function class.
     Both with respect to return instances and expected behaviour.
@@ -119,6 +141,360 @@ def test_get_value():
     assert isinstance(func.get_value(1), (int, float))
 
 
+@pytest.mark.parametrize("vectorized_callable", [False, True])
+def test_get_value_1d_callable_preserves_batched_complex_values(
+    vectorized_callable,
+):
+    """Complex batches retain their imaginary component for 1-D callables."""
+    # Arrange
+    func = Function(lambda x: x**2, vectorized_callable=vectorized_callable)
+    points = np.array([1 + 2j, 2 + 3j])
+
+    # Act
+    values = func.get_value(points)
+
+    # Assert
+    assert np.iscomplexobj(values)
+    assert values == pytest.approx(points**2)
+
+
+@pytest.mark.parametrize("vectorized_callable", [False, True])
+def test_get_value_nd_callable_preserves_batched_complex_values(
+    vectorized_callable,
+):
+    """Complex batches retain their imaginary component for N-D callables."""
+    # Arrange
+    func = Function(
+        lambda x, y: x**2 + y,
+        inputs=["x", "y"],
+        vectorized_callable=vectorized_callable,
+    )
+    x_points = np.array([1 + 2j, 2 + 3j])
+    y_points = np.array([3.0, 4.0])
+
+    # Act
+    values = func.get_value(x_points, y_points)
+
+    # Assert
+    assert np.iscomplexobj(values)
+    assert values == pytest.approx(x_points**2 + y_points)
+
+
+@pytest.mark.parametrize(
+    "interpolation", ["linear", "polynomial", "spline", "akima", "pchip"]
+)
+@pytest.mark.parametrize("point_count", [2, 10])
+def test_get_value_1d_array_preserves_batched_complex_step(interpolation, point_count):
+    """All 1-D interpolators preserve short and vectorized complex batches."""
+    # Arrange
+    source = np.column_stack((np.arange(4.0), np.arange(4.0) ** 2))
+    func = Function(source, interpolation=interpolation, extrapolation="natural")
+    real_points = np.linspace(1.1, 1.9, point_count)
+    step = 1e-100
+    complex_points = real_points + step * 1j
+    expected_derivatives = func._evaluator.derivative(real_points)
+
+    # Act
+    values = func.get_value(complex_points)
+
+    # Assert
+    assert np.iscomplexobj(values)
+    assert np.imag(values) / step == pytest.approx(expected_derivatives)
+
+
+def test_get_value_1d_array_complex_batch_uses_real_part_for_interval_lookup():
+    """Scalar and vector complex queries select the same interval at a knot."""
+    # Arrange
+    func = Function(
+        [(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)],
+        interpolation="linear",
+        extrapolation="natural",
+    )
+    step = 1e-100
+    points = np.full(10, 1.0 + step * 1j)
+
+    # Act
+    derivatives = np.imag(func.get_value(points)) / step
+
+    # Assert
+    assert derivatives == pytest.approx(np.ones(10))
+
+
+@pytest.mark.parametrize("interpolation", ["linear", "shepard", "rbf"])
+def test_get_value_scattered_nd_array_rejects_complex_coordinates(interpolation):
+    """Scattered N-D interpolators reject scalar and batched complex inputs."""
+    # Arrange
+    source = np.array(
+        [
+            [0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0],
+            [1.0, 0.0, 1.0],
+            [1.0, 1.0, 2.0],
+        ]
+    )
+    func = Function(source, interpolation=interpolation, extrapolation="natural")
+    error_message = "Complex coordinates are not supported"
+
+    # Act / Assert
+    with pytest.raises(TypeError, match=error_message):
+        func.get_value(0.25 + 1e-100j, 0.25)
+    with pytest.raises(TypeError, match=error_message):
+        func.get_value(np.array([0.25 + 1e-100j]), np.array([0.25]))
+
+
+@pytest.mark.parametrize("extrapolation", ["natural", "constant", "zero"])
+def test_get_value_regular_grid_rejects_complex_coordinates(extrapolation):
+    """Regular-grid interpolation rejects scalar and batched complex inputs."""
+    # Arrange
+    axes = [np.array([0.0, 1.0]), np.array([0.0, 1.0])]
+    grid_data = np.array([[0.0, 1.0], [1.0, 2.0]])
+    func = Function(
+        (axes, grid_data),
+        interpolation="regular_grid",
+        extrapolation=extrapolation,
+    )
+    error_message = "Complex coordinates are not supported"
+
+    # Act / Assert
+    with pytest.raises(TypeError, match=error_message):
+        func.get_value(0.25 + 1e-100j, 0.25)
+    with pytest.raises(TypeError, match=error_message):
+        func.get_value(np.array([0.25 + 1e-100j]), np.array([0.25]))
+
+
+def test_get_value_complex_constant_preserves_scalar_and_batched_values():
+    """Complex constant sources construct and evaluate without losing dtype."""
+    # Arrange
+    func = Function(1 + 2j)
+    points = np.array([0.0, 1.0])
+
+    # Act
+    scalar_value = func.get_value(0.0)
+    batch_values = func.get_value(points)
+
+    # Assert
+    assert scalar_value == 1 + 2j
+    assert np.iscomplexobj(batch_values)
+    assert batch_values == pytest.approx(np.array([1 + 2j, 1 + 2j]))
+
+
+def test_get_value_treats_zero_dimensional_numpy_arrays_as_scalars():
+    """NumPy 0-D arrays expose __iter__, but Function should treat them as scalars."""
+    func = Function([(0, 0), (1, 2), (2, 4)], interpolation="linear")
+    scalar_arg = np.array(1.0)
+
+    assert np.isclose(func.get_value(scalar_arg), 2.0)
+    assert np.isclose(func.get_value_opt(scalar_arg), 2.0)
+    assert Function(3.0).get_value(scalar_arg) == 3.0
+
+
+@pytest.mark.parametrize("shape", [(2, 2), (2, 5)])
+def test_get_value_1d_array_preserves_multidimensional_batch_shape(shape):
+    """Sampled 1-D evaluation preserves batch shape on both threshold paths."""
+    # Arrange
+    func = Function([(0.0, 1.0), (1.0, 3.0), (2.0, 5.0)], interpolation="linear")
+    points = np.linspace(0.0, 2.0, np.prod(shape)).reshape(shape)
+    expected = 2.0 * points + 1.0
+
+    # Act
+    values = func.get_value(points)
+    optimized_values = func.get_value_opt(points)
+
+    # Assert
+    assert values.shape == shape
+    assert optimized_values.shape == shape
+    assert values == pytest.approx(expected)
+    assert optimized_values == pytest.approx(expected)
+
+
+def test_get_value_1d_nonvectorized_callable_flattens_and_restores_batch_shape():
+    """Plain 1-D callables receive scalar values from multidimensional batches."""
+
+    # Arrange
+    def scalar_source(x):
+        assert np.ndim(x) == 0
+        return 2.0 * x + 1.0
+
+    func = Function(scalar_source)
+    points = np.arange(6.0).reshape(2, 3)
+
+    # Act
+    values = func.get_value(points)
+
+    # Assert
+    assert values.shape == points.shape
+    assert values == pytest.approx(2.0 * points + 1.0)
+
+
+@pytest.mark.parametrize("source_type", ["callable", "scattered", "regular_grid"])
+def test_get_value_nd_preserves_broadcast_shape(source_type):
+    """N-D callable and sampled Functions restore the broadcast input shape."""
+    # Arrange
+    if source_type == "callable":
+        func = Function(lambda x, y: x + 2.0 * y, inputs=["x", "y"])
+    elif source_type == "scattered":
+        source = np.array(
+            [
+                [0.0, 0.0, 0.0],
+                [0.0, 1.0, 2.0],
+                [1.0, 0.0, 1.0],
+                [1.0, 1.0, 3.0],
+            ]
+        )
+        func = Function(source, interpolation="linear", extrapolation="constant")
+    else:
+        axes = [np.array([0.0, 1.0]), np.array([0.0, 1.0])]
+        grid_data = np.array([[0.0, 2.0], [1.0, 3.0]])
+        func = Function((axes, grid_data), interpolation="regular_grid")
+
+    x_points = np.array([[0.2], [0.8]])
+    y_points = np.array([[0.1, 0.4, 0.7]])
+    expected = x_points + 2.0 * y_points
+
+    # Act
+    values = func.get_value(x_points, y_points)
+    optimized_values = func.get_value_opt(x_points, y_points)
+
+    # Assert
+    assert values.shape == expected.shape
+    assert optimized_values.shape == expected.shape
+    assert values == pytest.approx(expected)
+    assert optimized_values == pytest.approx(expected)
+
+
+def test_evaluator_exposes_cached_dispatch_closures():
+    """Function construction reuses the evaluator's cached dispatch closures."""
+    # Arrange
+    func = Function([(0.0, 0.0), (1.0, 1.0)], interpolation="linear")
+
+    # Act / Assert
+    assert func._array_evaluate is func._evaluator.expose()
+    assert func._array_evaluate_scalar is func._evaluator.expose_scalar()
+    assert func._array_evaluate_vector is func._evaluator.expose_vector()
+
+
+def test_get_value_opt_does_not_keep_function_alive():
+    """The optimized dispatch closure does not create a Function reference cycle."""
+    # Arrange
+    func = Function([(0.0, 0.0), (1.0, 1.0)], interpolation="linear")
+    function_reference = weakref.ref(func)
+
+    # Act
+    del func
+
+    # Assert
+    assert function_reference() is None
+
+
+def test_vectorized_callable_opt_in_and_arithmetic_propagation():
+    """Callable vectorization is opt-in and propagates through safe arithmetic."""
+    calls = {"count": 0}
+
+    def vectorized_source(x):
+        calls["count"] += 1
+        return np.asarray(x) * 2
+
+    vectorized = Function(vectorized_source, vectorized_callable=True)
+    plain = Function(lambda x: x * 2)
+    points = np.array([1.0, 2.0, 3.0])
+
+    assert np.array_equal(vectorized.get_value(points), np.array([2.0, 4.0, 6.0]))
+    assert calls["count"] == 1
+
+    calls["count"] = 0
+    assert np.array_equal((vectorized + 1).get_value(points), np.array([3.0, 5.0, 7.0]))
+    assert calls["count"] == 1
+
+    calls["count"] = 0
+    assert np.array_equal(
+        (vectorized + plain).get_value(points), np.array([4.0, 8.0, 12.0])
+    )
+    assert calls["count"] == len(points)
+
+    array_func = Function([(1, 10), (2, 20), (3, 30)], interpolation="linear")
+    calls["count"] = 0
+    assert np.array_equal(
+        (vectorized + array_func).get_value(points), np.array([12.0, 24.0, 36.0])
+    )
+    assert calls["count"] == 1
+
+    calls["count"] = 0
+    assert np.array_equal(
+        (vectorized + array_func).get_value(points.tolist()),
+        np.array([12.0, 24.0, 36.0]),
+    )
+    assert calls["count"] == 1
+
+
+def test_get_value_broadcasts_nd_scalar_and_vector_inputs():
+    """ND evaluation should treat any vector argument as a batch."""
+    func = Function(lambda x, y: x + y, inputs=["x", "y"])
+    result = func.get_value(1.0, np.array([2.0, 3.0]))
+
+    assert np.array_equal(result, np.array([3.0, 4.0]))
+
+
+def test_regular_grid_get_value_opt_broadcasts_scalar_and_vector_inputs():
+    """Regular-grid fast evaluation should also broadcast mixed ND inputs."""
+    grid_data = np.array([[0.0, 1.0], [10.0, 11.0]])
+    func = Function.from_grid(
+        grid_data,
+        [np.array([0.0, 1.0]), np.array([0.0, 1.0])],
+        interpolation="Regular_Grid",
+        extrapolation="Natural",
+    )
+
+    assert func.get_interpolation_method() == "regular_grid"
+    assert func.get_extrapolation_method() == "natural"
+    assert np.array_equal(
+        func.get_value(1.0, np.array([0.0, 1.0])), np.array([10.0, 11.0])
+    )
+    assert np.array_equal(
+        func.get_value_opt(1.0, np.array([0.0, 1.0])), np.array([10.0, 11.0])
+    )
+
+
+def test_interpolation_and_extrapolation_names_are_normalized():
+    """Supported method names should be case-insensitive without changing behavior."""
+    func = Function(
+        [(0, 0), (1, 1), (2, 2)],
+        interpolation="Linear",
+        extrapolation="Natural",
+    )
+
+    assert func.get_interpolation_method() == "linear"
+    assert func.get_extrapolation_method() == "natural"
+    assert np.isclose(func.get_value(-1), -1.0)
+
+
+def test_1d_array_source_internal_data_is_sorted():
+    """For 1-D array sources, source, domain and image should share sorted order."""
+    func = Function([(2, 4), (0, 0), (1, 1)], interpolation="linear")
+
+    assert np.array_equal(func.source[:, 0], np.array([0.0, 1.0, 2.0]))
+    assert np.array_equal(func._domain[:, 0], np.array([0.0, 1.0, 2.0]))
+    assert np.array_equal(func._image, np.array([0.0, 1.0, 4.0]))
+
+
+def test_nd_callable_negation_does_not_require_eval():
+    """ND callable negation should preserve behavior with a normal closure."""
+    func = Function(lambda x, y: x + 2 * y, inputs=["x", "y"])
+
+    assert (-func).get_value(1.0, 2.0) == -5.0
+
+
+def test_vectorized_constant_extrapolation_integrals():
+    """Constant extrapolation definite integrals should accept vector bounds."""
+    func = Function(
+        [(0, 0), (1, 1)],
+        interpolation="linear",
+        extrapolation="constant",
+    )
+
+    assert np.allclose(func._evaluator.integral(np.array([-2.0, -1.0])), [0.0, 0.0])
+    assert np.allclose(func._evaluator.integral(np.array([1.0, 2.0])), [0.5, 1.5])
+
+
 def test_identity_function():
     """Tests the identity_function method of the Function class.
     Both with respect to return instances and expected behaviour.
@@ -271,7 +647,7 @@ def test_set_discrete_based_on_model_non_mutator(linear_func):
         (source_array, cropped_array),
     ],
 )
-def test_crop_ndarray(array3dsource, array3dcropped):  # pylint: disable=unused-argument
+def test_crop_ndarray(array3dsource, array3dcropped):
     """Tests the functionality of crop method of the Function class.
     The source is initialized as a ndarray before cropping.
     """
@@ -308,8 +684,109 @@ def test_crop_constant():
 
     assert isinstance(func, Function)
     assert isinstance(cropped_func, Function)
-    assert callable(func.source)
-    assert callable(cropped_func.source)
+    assert func._source_type is SourceType.SCALAR
+    assert func._scalar_value == 13.0
+    assert cropped_func._source_type is SourceType.SCALAR
+    assert cropped_func._scalar_value == 13.0
+
+
+@pytest.mark.parametrize(
+    "lower, upper",
+    [
+        (0.0, 3.0),
+        (-1.0, 2.0),
+    ],
+)
+def test_crop_1d_ndarray_values(lower, upper):
+    """Test crop on a 1-D ndarray source removes out-of-range rows and
+    preserves all in-range rows with correct values."""
+    source = np.array([(x, x**2) for x in range(-2, 6)])
+    func = Function(source)
+    cropped = func.crop([(lower, upper)])
+
+    assert isinstance(cropped, Function)
+    assert np.all(cropped.source[:, 0] >= lower)
+    assert np.all(cropped.source[:, 0] <= upper)
+    # Check that all source rows within bounds are present
+    expected = source[(source[:, 0] >= lower) & (source[:, 0] <= upper)]
+    assert np.array_equal(cropped.source, expected)
+
+
+@pytest.mark.parametrize(
+    "x_lim, expected_dim0_bounds",
+    [
+        ([(-1.0, 1.0)], (-1.0, 1.0)),
+        ([(0.0, 2.0)], (0.0, 2.0)),
+    ],
+)
+def test_crop_callable_records_cropped_domain_1d(x_lim, expected_dim0_bounds):
+    """Test that crop on a callable 1-D Function correctly records
+    __cropped_domain__ so that subsequent set_discrete honours the bounds."""
+    func = Function(lambda x: x**2)
+    cropped = func.crop(x_lim)
+
+    assert cropped.__cropped_domain__ is not None
+    recorded = cropped.__cropped_domain__[0]
+    assert recorded == expected_dim0_bounds
+
+
+@pytest.mark.parametrize(
+    "x_lim",
+    [
+        [(-1.0, 1.0), None],  # skip second dim
+        [None, (-2.0, 2.0)],  # skip first dim
+        [(-1.0, 1.0), (-2.0, 2.0)],  # both dims
+    ],
+)
+def test_crop_callable_nd_partial(x_lim):
+    """Test crop with None entries skips the corresponding dimension and
+    only sets __cropped_domain__ for the non-None dims."""
+    func = Function(lambda x1, x2: x1 + x2, inputs=["x1", "x2"])
+    cropped = func.crop(x_lim)
+
+    assert isinstance(cropped, Function)
+    assert callable(cropped.source)
+    domain = cropped.__cropped_domain__
+    for i, lim in enumerate(x_lim):
+        if lim is None:
+            assert domain[i] is None
+        else:
+            assert domain[i] == lim
+
+
+def test_crop_nd_ndarray_removes_out_of_range_rows():
+    """Test crop on a 3-D ndarray source keeps only rows whose inputs are
+    within all specified per-dimension ranges."""
+    rng = np.random.default_rng(42)
+    pts = rng.uniform(-3, 3, (200, 3))
+    zs = pts[:, 0] + pts[:, 1] + pts[:, 2]
+    source = np.column_stack([pts, zs])
+    func = Function(source, inputs=["x", "y", "z"])
+
+    x_lim = [(-1.0, 1.0), (-1.0, 1.0), (-1.0, 1.0)]
+    cropped = func.crop(x_lim)
+
+    assert isinstance(cropped, Function)
+    assert np.all(cropped.source[:, 0] >= -1.0)
+    assert np.all(cropped.source[:, 0] <= 1.0)
+    assert np.all(cropped.source[:, 1] >= -1.0)
+    assert np.all(cropped.source[:, 1] <= 1.0)
+    assert np.all(cropped.source[:, 2] >= -1.0)
+    assert np.all(cropped.source[:, 2] <= 1.0)
+
+
+@pytest.mark.parametrize(
+    "bad_x_lim, exc_type",
+    [
+        ((-1, 1), TypeError),  # not a list
+        ([(0, 1), (0, 1), (0, 1)], ValueError),  # too many dims for 2-D func
+    ],
+)
+def test_crop_invalid_input_raises(bad_x_lim, exc_type):
+    """Test that crop raises the appropriate error for invalid x_lim."""
+    func = Function(lambda x, y: x + y, inputs=["x", "y"])
+    with pytest.raises(exc_type):
+        func.crop(bad_x_lim)
 
 
 @pytest.mark.parametrize(
@@ -318,7 +795,7 @@ def test_crop_constant():
         (source_array, clipped_array),
     ],
 )
-def test_clip_ndarray(array3dsource, array3dclipped):  # pylint: disable=unused-argument
+def test_clip_ndarray(array3dsource, array3dclipped):
     """Tests the functionality of clip method of the Function class.
     The source is initialized as a ndarray before clipping.
     """
@@ -353,8 +830,76 @@ def test_clip_constant():
 
     assert isinstance(func, Function)
     assert isinstance(clipped_func, Function)
-    assert callable(func.source)
-    assert callable(clipped_func.source)
+    assert func._source_type is SourceType.SCALAR
+    assert func._scalar_value == 1.0
+    assert clipped_func._source_type is SourceType.SCALAR
+    assert clipped_func._scalar_value == 1.0
+
+
+@pytest.mark.parametrize(
+    "y_lo, y_hi, x_in, expected_out",
+    [
+        (-4.0, 4.0, 0.0, 0.0),  # inside range, unchanged
+        (-4.0, 4.0, 2.0, 4.0),  # inside range, unchanged
+        (-4.0, 4.0, 3.0, 4.0),  # clamped at upper bound
+        (-4.0, 4.0, -3.0, 4.0),  # clamped at upper bound (x**2)
+        (1.0, 9.0, 0.0, 1.0),  # clamped at lower bound
+        (1.0, 9.0, 4.0, 9.0),  # clamped at upper bound
+    ],
+)
+def test_clip_callable_clamps_output_values(y_lo, y_hi, x_in, expected_out):
+    """Test that clip on a callable Function clamps output values to the
+    specified range rather than removing any domain points."""
+    func = Function(lambda x: x**2, inputs="x", outputs="y")
+    clipped = func.clip([(y_lo, y_hi)])
+
+    assert np.isclose(clipped(x_in), expected_out)
+
+
+@pytest.mark.parametrize(
+    "y_lo, y_hi, n_rows_kept",
+    [
+        (-3.0, 3.0, 5),  # z=-6 and z=6 excluded; remaining 5 rows in range
+        (-10.0, 10.0, 7),  # all rows kept
+        (0.0, 0.1, 1),  # only row with z≈0
+    ],
+)
+def test_clip_ndarray_2d_removes_rows_outside_range(y_lo, y_hi, n_rows_kept):
+    """Test that clip on a 2-D ndarray source removes rows whose output
+    column falls outside the specified y_lim range."""
+    func = Function(source_array, inputs=["x1", "x2"], outputs="y")
+    clipped = func.clip([(y_lo, y_hi)])
+
+    assert isinstance(clipped, Function)
+    assert np.all(clipped.source[:, 2] >= y_lo)
+    assert np.all(clipped.source[:, 2] <= y_hi)
+    assert len(clipped.source) == n_rows_kept
+
+
+def test_clip_constant_clamps_output():
+    """Test that clipping a constant Function clamps its output to y_lim.
+
+    Function(5) stores source as a scalar, so clip clamps the value directly;
+    values are clamped to the specified range rather than raising."""
+    func = Function(5)  # constant 5, stored as SCALAR
+    clipped = func.clip([(10.0, 20.0)])  # 5 < 10, so output should be clamped to 10
+    assert clipped.get_value(0) == 10.0
+    clipped_hi = func.clip([(-1.0, 3.0)])  # 5 > 3, clamped to 3
+    assert clipped_hi.get_value(0) == 3.0
+
+
+@pytest.mark.parametrize(
+    "bad_y_lim, exc_type",
+    [
+        ((-1, 1), TypeError),  # not a list
+        ([(-1, 1), (-1, 1)], ValueError),  # wrong length for single-output func
+    ],
+)
+def test_clip_invalid_input_raises(bad_y_lim, exc_type):
+    """Test that clip raises the appropriate error for invalid y_lim."""
+    func = Function(lambda x: x**2, inputs="x", outputs="y")
+    with pytest.raises(exc_type):
+        func.clip(bad_y_lim)
 
 
 @pytest.mark.parametrize(
@@ -772,6 +1317,157 @@ def test_set_discrete_based_on_2d_model(func_2d_from_csv):
     assert discretized_func.__extrapolation__ == func_2d_from_csv.__extrapolation__
 
 
+@pytest.mark.parametrize(
+    "interpolation, extrapolation",
+    [
+        ("spline", "constant"),
+        ("linear", "natural"),
+        ("akima", "zero"),
+    ],
+)
+def test_set_discrete_1d_respects_interpolation_extrapolation(
+    interpolation, extrapolation
+):
+    """Test that set_discrete preserves the caller-specified interpolation and
+    extrapolation methods for 1-D functions after discretisation."""
+    func = Function(lambda x: x**2)
+    disc = func.set_discrete(
+        -5,
+        5,
+        50,
+        interpolation=interpolation,
+        extrapolation=extrapolation,
+        mutate_self=False,
+    )
+
+    assert disc.__interpolation__ == interpolation
+    assert disc.__extrapolation__ == extrapolation
+
+
+@pytest.mark.parametrize(
+    "samples",
+    [3, 5, 10],
+)
+def test_set_discrete_nd_shape_and_forced_interp(samples):
+    """Test that set_discrete on an N-D (3-D) function produces a source array
+    with the correct shape (samples^3, 4) and forces shepard/natural."""
+    func = Function(lambda x, y, z: x + y + z, inputs=["x", "y", "z"])
+    disc = func.set_discrete(0, 2, samples, mutate_self=False)
+
+    assert isinstance(disc, Function)
+    assert disc.source.shape == (samples**3, 4)
+    assert disc.__interpolation__ == "shepard"
+    assert disc.__extrapolation__ == "natural"
+
+
+def test_set_discrete_nd_scalar_bounds():
+    """Test set_discrete on a 3-D function using scalar bounds, which should
+    be broadcast to all three dimensions."""
+    func = Function(lambda x, y, z: x * y * z, inputs=["x", "y", "z"])
+    disc = func.set_discrete(-1, 1, 5, mutate_self=False)
+
+    assert disc.source.shape == (125, 4)
+    assert np.isclose(disc.source[:, 0].min(), -1.0)
+    assert np.isclose(disc.source[:, 0].max(), 1.0)
+
+
+def test_set_discrete_nd_list_bounds_and_samples():
+    """Test set_discrete on a 3-D function using per-dimension bounds and
+    sample counts."""
+    func = Function(lambda x, y, z: x + y + z, inputs=["x", "y", "z"])
+    lowers = [0.0, 1.0, 2.0]
+    uppers = [1.0, 2.0, 3.0]
+    samples = [3, 4, 5]
+    disc = func.set_discrete(lowers, uppers, samples, mutate_self=False)
+
+    assert disc.source.shape == (3 * 4 * 5, 4)
+    assert np.isclose(disc.source[:, 0].min(), 0.0)
+    assert np.isclose(disc.source[:, 1].min(), 1.0)
+    assert np.isclose(disc.source[:, 2].min(), 2.0)
+
+
+@pytest.mark.parametrize("mutate_self", [True, False])
+def test_set_discrete_nd_mutate_self(mutate_self):
+    """Test that mutate_self=True replaces in-place while mutate_self=False
+    leaves the original callable untouched for N-D functions."""
+    func = Function(lambda x, y, z: x + y + z, inputs=["x", "y", "z"])
+    result = func.set_discrete(0, 1, 3, mutate_self=mutate_self)
+
+    assert isinstance(result, Function)
+    if mutate_self:
+        assert func is result
+        assert isinstance(func.source, np.ndarray)
+    else:
+        assert callable(func.source)
+
+
+def test_set_discrete_based_on_model_nd():
+    """Test that set_discrete_based_on_model works for N-D (3-D) functions,
+    evaluating the target function at all domain points of the model."""
+    pts = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]], dtype=float)
+    zs_model = pts.sum(axis=1)
+    model = Function(np.column_stack([pts, zs_model]), inputs=["x", "y", "z"])
+
+    target = Function(lambda x, y, z: 2 * (x + y + z), inputs=["x", "y", "z"])
+    disc = target.set_discrete_based_on_model(model, mutate_self=False)
+
+    assert isinstance(disc, Function)
+    assert disc.source.shape == (len(pts), 4)
+    # Values should be 2 * sum of coordinates
+    for i, row in enumerate(pts):
+        expected_z = 2 * row.sum()
+        assert np.isclose(disc.source[i, 3], expected_z, atol=1e-6)
+
+
+def test_set_discrete_based_on_model_keep_self_false(linear_func):
+    """Test that keep_self=False replaces interpolation and extrapolation
+    with those from the model Function."""
+    func = Function([(0, 0), (1, 1), (2, 4), (3, 9), (4, 16)], interpolation="linear")
+    disc = func.set_discrete_based_on_model(
+        linear_func, keep_self=False, mutate_self=False
+    )
+
+    assert disc.__interpolation__ == linear_func.__interpolation__
+    assert disc.__extrapolation__ == linear_func.__extrapolation__
+
+
+def test_set_discrete_based_on_model_keep_self_true(linear_func):
+    """Test that keep_self=True (default) preserves the caller's interpolation
+    and extrapolation after discretisation."""
+    # Use an ndarray-based func with a non-default (linear) interpolation so
+    # the setting is not None and can be verified after discretisation.
+    func = Function([(0, 0), (1, 1), (2, 4), (3, 9), (4, 16)], interpolation="linear")
+    original_interp = func.__interpolation__  # "linear"
+    original_extrap = func.__extrapolation__
+    disc = func.set_discrete_based_on_model(
+        linear_func, keep_self=True, mutate_self=False
+    )
+
+    assert disc.__interpolation__ == original_interp
+    assert disc.__extrapolation__ == original_extrap
+
+
+@pytest.mark.parametrize(
+    "bad_model, exc_type",
+    [
+        (Function(lambda x: x), TypeError),  # callable model
+        # ndarray model with dim=2, but func has dim=1 → mismatch
+        (
+            Function(
+                np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [2.0, 2.0, 2.0]]),
+                inputs=["x1", "x2"],
+            ),
+            ValueError,
+        ),
+    ],
+)
+def test_set_discrete_based_on_model_invalid_model_raises(bad_model, exc_type):
+    """Test that set_discrete_based_on_model raises for invalid model inputs."""
+    func = Function(lambda x: x**2)
+    with pytest.raises(exc_type):
+        func.set_discrete_based_on_model(bad_model)
+
+
 @pytest.mark.parametrize("other", [1, 0.1, np.int_(1), np.float64(0.1), np.array([1])])
 def test_sum_arithmetic_priority(other):
     """Test the arithmetic priority of the add operation of the Function class,
@@ -1505,3 +2201,55 @@ def test_regular_grid_invalid_source_raises(bad_source, match):
             outputs=["z"],
             interpolation="regular_grid",
         )
+
+
+def test_2d_linear_interpolation_no_nan_outside_convex_hull():
+    """Test that querying a point inside the bounding box but outside the
+    convex hull does not silently return NaN.
+
+    Regression test for https://github.com/RocketPy-Team/RocketPy/issues/926.
+    The point (0.3, 0.3) lies within the axis-aligned bounding box of the
+    data but outside the convex hull, so LinearNDInterpolator would return
+    NaN without proper hull detection.
+    """
+    data = [
+        [0.0, 0.0, 0.000],
+        [0.0, 0.1, 0.100],
+        [0.0, 0.4, 0.400],
+        [0.3, 0.2, 0.150],
+    ]
+    func = Function(data, interpolation="linear")
+    result = func(0.3, 0.3)
+    linear_nd = LinearNDPolation(np.array(data)[:, :-1], np.array(data)[:, -1])
+
+    assert not np.isnan(result), (
+        "f(0.3, 0.3) returned NaN. Point is outside the convex hull and "
+        "should be routed to extrapolation, not silently return NaN."
+    )
+    assert linear_nd.extrapolation_mask(np.array([[0.3, 0.3]])).item()
+
+
+def test_3d_linear_interpolation_no_nan_outside_convex_hull():
+    """Extend the convex-hull regression (issue #926) to three dimensions.
+
+    The fix in PR #969 targets N-dimensional linear interpolation, so a point
+    inside the axis-aligned bounding box but outside the convex hull must not
+    silently return NaN for 3D data either.
+    """
+    # Tetrahedron-like point cloud in the [0, 1]^3 bounding box.
+    data = [
+        [0.0, 0.0, 0.0, 0.0],
+        [1.0, 0.0, 0.0, 1.0],
+        [0.0, 1.0, 0.0, 1.0],
+        [0.0, 0.0, 1.0, 1.0],
+        [0.2, 0.2, 0.2, 0.6],
+    ]
+    func = Function(data, interpolation="linear")
+    # (0.9, 0.9, 0.9) is inside the bounding box but far outside the hull
+    # (its coordinates sum to 2.7, well beyond the x + y + z <= 1 face).
+    result = func(0.9, 0.9, 0.9)
+
+    assert not np.isnan(result), (
+        "f(0.9, 0.9, 0.9) returned NaN. Point is outside the 3D convex hull "
+        "and should be routed to extrapolation, not silently return NaN."
+    )
diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py
index 7387189fb..7b4ff219b 100644
--- a/tests/unit/motors/test_genericmotor.py
+++ b/tests/unit/motors/test_genericmotor.py
@@ -440,3 +440,35 @@ def mock_read_fail(self, *args, **kwargs):
 
     with pytest.warns(UserWarning, match="Failed to read cached motor file"):
         GenericMotor.load_from_thrustcurve_api("M1670")
+
+
+def test_thrustcurve_api_requests_pass_timeout(monkeypatch, tmp_path):
+    """Regression for PR #940: every ThrustCurve API request must pass an
+    explicit (connect, read) timeout so the call cannot hang indefinitely."""
+    eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng"
+    with open(eng_path, "rb") as f:
+        encoded = base64.b64encode(f.read()).decode("utf-8")
+
+    search_json = {"results": [{"motorId": "12345"}]}
+    download_json = {"results": [{"data": encoded}]}
+
+    captured_timeouts = []
+
+    def _capturing_get(url, **kwargs):
+        captured_timeouts.append(kwargs.get("timeout"))
+        if "search.json" in url:
+            return MockResponse(search_json)
+        if "download.json" in url:
+            return MockResponse(download_json)
+        raise RuntimeError(f"Unexpected URL: {url}")
+
+    monkeypatch.setattr(requests, "get", _capturing_get)
+    monkeypatch.setattr("rocketpy.motors.motor.CACHE_DIR", tmp_path)
+
+    GenericMotor.load_from_thrustcurve_api("M1670", no_cache=True)
+
+    assert captured_timeouts, "requests.get was never called"
+    assert all(t == (5, 30) for t in captured_timeouts), (
+        "Every ThrustCurve API request must use a (connect, read) timeout of "
+        f"(5, 30); captured timeouts were {captured_timeouts}"
+    )
diff --git a/tests/unit/motors/test_hybridmotor.py b/tests/unit/motors/test_hybridmotor.py
index fb3ba954f..23cfbdf57 100644
--- a/tests/unit/motors/test_hybridmotor.py
+++ b/tests/unit/motors/test_hybridmotor.py
@@ -1,8 +1,5 @@
 import numpy as np
-import pytest
-import scipy.integrate
-
-from rocketpy import Function
+import numpy.testing as npt
 
 
 def thrust_function(t):
@@ -63,41 +60,30 @@ def test_hybrid_motor_thrust_parameters(hybrid_motor, oxidizer_tank):
     ----------
     hybrid_motor : rocketpy.HybridMotor
         The HybridMotor object to be used in the tests.
-    spherical_oxidizer_tank : rocketpy.SphericalTank
-        The SphericalTank object to be used in the tests.
+    oxidizer_tank : rocketpy.Tank
+        The oxidizer tank object to be used in the tests.
     """
-    # Function dependency for discretization validation
-    expected_thrust = Function(thrust_function).set_discrete(0, 10, 50)
-    expected_total_impulse = scipy.integrate.quad(expected_thrust, 0, 10)[0]
-
-    initial_grain_mass = (
-        GRAIN_DENSITY
-        * np.pi
-        * (GRAIN_OUTER_RADIUS**2 - GRAIN_INITIAL_INNER_RADIUS**2)
-        * GRAIN_INITIAL_HEIGHT
-        * GRAIN_NUMBER
+    time = np.linspace(0, BURN_TIME, 100)
+    expected_total_impulse = 15000
+    expected_exhaust_velocity = (
+        expected_total_impulse / hybrid_motor.propellant_initial_mass
     )
-    initial_oxidizer_mass = oxidizer_tank.fluid_mass(0)
-    initial_mass = initial_grain_mass + initial_oxidizer_mass
-
-    expected_exhaust_velocity = expected_total_impulse / initial_mass
-    expected_mass_flow_rate = -expected_thrust / expected_exhaust_velocity
+    expected_thrust_values = thrust_function(time)
+    expected_mass_flow_rate = -expected_thrust_values / expected_exhaust_velocity
     expected_grain_mass_flow_rate = (
-        expected_mass_flow_rate - oxidizer_tank.net_mass_flow_rate
+        expected_mass_flow_rate - oxidizer_tank.net_mass_flow_rate(time)
     )
 
-    assert pytest.approx(hybrid_motor.thrust.y_array) == expected_thrust.y_array
-    assert pytest.approx(hybrid_motor.total_impulse) == expected_total_impulse
-    assert pytest.approx(hybrid_motor.exhaust_velocity(0)) == expected_exhaust_velocity
-
-    # Assert mass flow rate grain/oxidizer balance
-    for t in np.linspace(0, 10, 100)[1:-1]:
-        assert pytest.approx(
-            hybrid_motor.total_mass_flow_rate(t)
-        ) == expected_mass_flow_rate(t)
-        assert pytest.approx(
-            hybrid_motor.solid.mass_flow_rate(t)
-        ) == expected_grain_mass_flow_rate(t)
+    npt.assert_allclose(hybrid_motor.thrust(time), expected_thrust_values)
+    npt.assert_allclose(hybrid_motor.total_impulse, expected_total_impulse)
+    npt.assert_allclose(hybrid_motor.exhaust_velocity(0), expected_exhaust_velocity)
+    npt.assert_allclose(
+        hybrid_motor.total_mass_flow_rate(time), expected_mass_flow_rate
+    )
+    npt.assert_allclose(
+        hybrid_motor.solid.mass_flow_rate(time),
+        expected_grain_mass_flow_rate,
+    )
 
 
 def test_hybrid_motor_center_of_mass(hybrid_motor, oxidizer_tank):
@@ -116,16 +102,22 @@ def test_hybrid_motor_center_of_mass(hybrid_motor, oxidizer_tank):
     propellant_balance = grain_mass * GRAINS_CENTER_OF_MASS_POSITION + oxidizer_mass * (
         OXIDIZER_TANK_POSITION + oxidizer_tank.center_of_mass
     )
-    balance = propellant_balance + DRY_MASS * CENTER_OF_DRY_MASS
 
+    balance = propellant_balance + DRY_MASS * CENTER_OF_DRY_MASS
     propellant_center_of_mass = propellant_balance / (grain_mass + oxidizer_mass)
     center_of_mass = balance / (grain_mass + oxidizer_mass + DRY_MASS)
 
-    for t in np.linspace(0, BURN_TIME, 100):
-        assert pytest.approx(
-            hybrid_motor.center_of_propellant_mass(t)
-        ) == propellant_center_of_mass(t)
-        assert pytest.approx(hybrid_motor.center_of_mass(t)) == center_of_mass(t)
+    t = np.linspace(0, BURN_TIME, 100)
+
+    npt.assert_allclose(
+        hybrid_motor.center_of_propellant_mass(t),
+        propellant_center_of_mass(t),
+    )
+
+    npt.assert_allclose(
+        hybrid_motor.center_of_mass(t),
+        center_of_mass(t),
+    )
 
 
 def test_hybrid_motor_inertia(hybrid_motor, oxidizer_tank):
@@ -140,8 +132,10 @@ def test_hybrid_motor_inertia(hybrid_motor, oxidizer_tank):
     """
     oxidizer_mass = oxidizer_tank.fluid_mass
     oxidizer_inertia = oxidizer_tank.inertia
+
     grain_mass = hybrid_motor.solid.propellant_mass
     grain_inertia = hybrid_motor.solid.propellant_I_11
+
     propellant_mass = oxidizer_mass + grain_mass
 
     # Validate parallel axis theorem translation
@@ -149,6 +143,7 @@ def test_hybrid_motor_inertia(hybrid_motor, oxidizer_tank):
         grain_mass
         * (GRAINS_CENTER_OF_MASS_POSITION - hybrid_motor.center_of_propellant_mass) ** 2
     )
+
     oxidizer_inertia += (
         oxidizer_mass
         * (
@@ -170,9 +165,14 @@ def test_hybrid_motor_inertia(hybrid_motor, oxidizer_tank):
         + DRY_MASS * (-hybrid_motor.center_of_mass + CENTER_OF_DRY_MASS) ** 2
     )
 
-    for t in np.linspace(0, BURN_TIME, 100):
-        assert pytest.approx(hybrid_motor.propellant_I_11(t)) == propellant_inertia(t)
-        assert pytest.approx(hybrid_motor.I_11(t)) == inertia(t)
+    t = np.linspace(0, BURN_TIME, 100)
 
-        # Assert cylindrical symmetry
-        assert pytest.approx(hybrid_motor.propellant_I_22(t)) == propellant_inertia(t)
+    actual_val = hybrid_motor.propellant_I_11(t)
+    desired_val = propellant_inertia(t)
+
+    npt.assert_allclose(actual_val, desired_val, rtol=1e-6)
+    npt.assert_allclose(hybrid_motor.I_11(t), inertia(t), rtol=1e-6)
+    # Assert cylindrical symmetry
+    npt.assert_allclose(
+        hybrid_motor.propellant_I_22(t), propellant_inertia(t), rtol=1e-6
+    )
diff --git a/tests/unit/motors/test_liquidmotor.py b/tests/unit/motors/test_liquidmotor.py
index ec8cf7223..5938a7b6c 100644
--- a/tests/unit/motors/test_liquidmotor.py
+++ b/tests/unit/motors/test_liquidmotor.py
@@ -1,9 +1,7 @@
 import numpy as np
-import pytest
+import numpy.testing as npt
 import scipy.integrate
 
-from rocketpy import Function
-
 BURN_TIME = (8, 20)
 DRY_MASS = 10
 DRY_INERTIA = (5, 5, 0.2)
@@ -55,22 +53,38 @@ def test_liquid_motor_thrust_parameters(
         The expected oxidizer tank.
     """
     expected_thrust = np.loadtxt(
-        "data/rockets/berkeley/test124_Thrust_Curve.csv", delimiter=","
+        "data/rockets/berkeley/test124_Thrust_Curve.csv",
+        delimiter=",",
     )
+
+    time = expected_thrust[:, 0]
+    expected_thrust_values = expected_thrust[:, 1]
+
     expected_mass_flow = (
-        pressurant_tank.net_mass_flow_rate
-        + fuel_tank.net_mass_flow_rate
-        + oxidizer_tank.net_mass_flow_rate
+        pressurant_tank.net_mass_flow_rate(time)
+        + fuel_tank.net_mass_flow_rate(time)
+        + oxidizer_tank.net_mass_flow_rate(time)
     )
+
     expected_total_impulse = scipy.integrate.trapezoid(
-        expected_thrust[:, 1], expected_thrust[:, 0]
+        expected_thrust_values,
+        time,
     )
 
-    assert pytest.approx(liquid_motor.thrust.y_array) == expected_thrust[:, 1]
-    assert (
-        pytest.approx(liquid_motor.mass_flow_rate.y_array) == expected_mass_flow.y_array
+    npt.assert_allclose(
+        liquid_motor.thrust(time),
+        expected_thrust_values,
+    )
+
+    npt.assert_allclose(
+        liquid_motor.mass_flow_rate(time),
+        expected_mass_flow,
+    )
+
+    npt.assert_allclose(
+        liquid_motor.total_impulse,
+        expected_total_impulse,
     )
-    assert pytest.approx(liquid_motor.total_impulse) == expected_total_impulse
 
 
 def test_liquid_motor_mass_volume(
@@ -108,59 +122,95 @@ def test_liquid_motor_mass_volume(
     test_fuel_tank = liquid_motor.positioned_tanks[1]["tank"]
     test_oxidizer_tank = liquid_motor.positioned_tanks[2]["tank"]
 
-    # Test is Function dependent for discretization validation
-    expected_pressurant_mass = Function(
-        "data/rockets/berkeley/pressurantMassFiltered.csv"
+    time = np.linspace(*BURN_TIME, 100)
+
+    pressurant_mass_data = np.loadtxt(
+        "data/rockets/berkeley/pressurantMassFiltered.csv",
+        delimiter=",",
     )
+
+    fuel_volume_data = np.loadtxt(
+        "data/rockets/berkeley/test124_Propane_Volume.csv",
+        delimiter=",",
+    )
+
+    oxidizer_volume_data = np.loadtxt(
+        "data/rockets/berkeley/test124_Lox_Volume.csv",
+        delimiter=",",
+    )
+
+    expected_pressurant_mass = np.interp(
+        time,
+        pressurant_mass_data[:, 0],
+        pressurant_mass_data[:, 1],
+    )
+
     expected_pressurant_volume = expected_pressurant_mass / pressurant_fluid.density
+
     expected_fuel_volume = (
-        Function("data/rockets/berkeley/test124_Propane_Volume.csv") * 1e-3
+        np.interp(
+            time,
+            fuel_volume_data[:, 0],
+            fuel_volume_data[:, 1],
+        )
+        * 1e-3
     )
+
     expected_fuel_mass = (
         expected_fuel_volume * fuel_fluid.density
         + (-expected_fuel_volume + fuel_tank.geometry.total_volume)
         * fuel_pressurant.density
     )
+
     expected_oxidizer_volume = (
-        Function("data/rockets/berkeley/test124_Lox_Volume.csv") * 1e-3
+        np.interp(
+            time,
+            oxidizer_volume_data[:, 0],
+            oxidizer_volume_data[:, 1],
+        )
+        * 1e-3
     )
+
     expected_oxidizer_mass = (
         expected_oxidizer_volume * oxidizer_fluid.density
         + (-expected_oxidizer_volume + oxidizer_tank.geometry.total_volume)
         * oxidizer_pressurant.density
     )
 
-    # Perform default discretization
-    expected_pressurant_mass.set_discrete(*BURN_TIME, 100)
-    expected_fuel_mass.set_discrete(*BURN_TIME, 100)
-    expected_oxidizer_mass.set_discrete(*BURN_TIME, 100)
-    expected_pressurant_volume.set_discrete(*BURN_TIME, 100)
-    expected_fuel_volume.set_discrete(*BURN_TIME, 100)
-    expected_oxidizer_volume.set_discrete(*BURN_TIME, 100)
-
-    assert (
-        pytest.approx(expected_pressurant_mass.y_array, 0.01)
-        == test_pressurant_tank.fluid_mass.y_array
+    npt.assert_allclose(
+        test_pressurant_tank.fluid_mass(time),
+        expected_pressurant_mass,
+        rtol=1e-2,
     )
-    assert (
-        pytest.approx(expected_fuel_mass.y_array, 0.01)
-        == test_fuel_tank.fluid_mass.y_array
+
+    npt.assert_allclose(
+        test_fuel_tank.fluid_mass(time),
+        expected_fuel_mass,
+        rtol=1e-2,
     )
-    assert (
-        pytest.approx(expected_oxidizer_mass.y_array, 0.01)
-        == test_oxidizer_tank.fluid_mass.y_array
+
+    npt.assert_allclose(
+        test_oxidizer_tank.fluid_mass(time),
+        expected_oxidizer_mass,
+        rtol=1e-2,
     )
-    assert (
-        pytest.approx(expected_pressurant_volume.y_array, 0.01)
-        == test_pressurant_tank.gas_volume.y_array
+
+    npt.assert_allclose(
+        test_pressurant_tank.gas_volume(time),
+        expected_pressurant_volume,
+        rtol=1e-2,
     )
-    assert (
-        pytest.approx(expected_fuel_volume.y_array, 0.01)
-        == test_fuel_tank.liquid_volume.y_array
+
+    npt.assert_allclose(
+        test_fuel_tank.liquid_volume(time),
+        expected_fuel_volume,
+        rtol=1e-2,
     )
-    assert (
-        pytest.approx(expected_oxidizer_volume.y_array, 0.01)
-        == test_oxidizer_tank.liquid_volume.y_array
+
+    npt.assert_allclose(
+        test_oxidizer_tank.liquid_volume(time),
+        expected_oxidizer_volume,
+        rtol=1e-2,
     )
 
 
@@ -183,6 +233,7 @@ def test_liquid_motor_center_of_mass(
     pressurant_mass = pressurant_tank.fluid_mass
     fuel_mass = fuel_tank.fluid_mass
     oxidizer_mass = oxidizer_tank.fluid_mass
+
     propellant_mass = pressurant_mass + fuel_mass + oxidizer_mass
 
     propellant_balance = (
@@ -190,16 +241,24 @@ def test_liquid_motor_center_of_mass(
         + fuel_mass * (fuel_tank.center_of_mass + FUEL_TANK_POSITION)
         + oxidizer_mass * (oxidizer_tank.center_of_mass + OXIDIZER_TANK_POSITION)
     )
+
     balance = propellant_balance + DRY_MASS * CENTER_OF_DRY_MASS
 
     propellant_center_of_mass = propellant_balance / propellant_mass
+
     center_of_mass = balance / (propellant_mass + DRY_MASS)
 
-    assert (
-        pytest.approx(liquid_motor.center_of_propellant_mass.y_array)
-        == propellant_center_of_mass.y_array
+    time = np.linspace(*BURN_TIME, 100)
+
+    npt.assert_allclose(
+        liquid_motor.center_of_propellant_mass(time),
+        propellant_center_of_mass(time),
+    )
+
+    npt.assert_allclose(
+        liquid_motor.center_of_mass(time),
+        center_of_mass(time),
     )
-    assert pytest.approx(liquid_motor.center_of_mass.y_array) == center_of_mass.y_array
 
 
 def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer_tank):
@@ -219,6 +278,7 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer
     pressurant_inertia = pressurant_tank.inertia
     fuel_inertia = fuel_tank.inertia
     oxidizer_inertia = oxidizer_tank.inertia
+
     propellant_mass = (
         pressurant_tank.fluid_mass + fuel_tank.fluid_mass + oxidizer_tank.fluid_mass
     )
@@ -233,6 +293,7 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer
         )
         ** 2
     )
+
     fuel_inertia += (
         fuel_tank.fluid_mass
         * (
@@ -242,6 +303,7 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer
         )
         ** 2
     )
+
     oxidizer_inertia += (
         oxidizer_tank.fluid_mass
         * (
@@ -263,14 +325,20 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer
         + DRY_MASS * (-liquid_motor.center_of_mass + CENTER_OF_DRY_MASS) ** 2
     )
 
-    assert (
-        pytest.approx(liquid_motor.propellant_I_11.y_array)
-        == propellant_inertia.y_array
+    time = np.linspace(*BURN_TIME, 100)
+
+    npt.assert_allclose(
+        liquid_motor.propellant_I_11(time),
+        propellant_inertia(time),
+    )
+
+    npt.assert_allclose(
+        liquid_motor.I_11(time),
+        inertia(time),
     )
-    assert pytest.approx(liquid_motor.I_11.y_array) == inertia.y_array
 
     # Assert cylindrical symmetry
-    assert (
-        pytest.approx(liquid_motor.propellant_I_22.y_array)
-        == propellant_inertia.y_array
+    npt.assert_allclose(
+        liquid_motor.propellant_I_22(time),
+        propellant_inertia(time),
     )
diff --git a/tests/unit/motors/test_tank.py b/tests/unit/motors/test_tank.py
index 87059be0b..c61eb49fd 100644
--- a/tests/unit/motors/test_tank.py
+++ b/tests/unit/motors/test_tank.py
@@ -2,6 +2,7 @@
 from pathlib import Path
 
 import numpy as np
+import numpy.testing as npt
 import pytest
 import scipy.integrate as spi
 
@@ -36,18 +37,32 @@ def test_mass_based_tank_fluid_mass(params, request):
     """
     tank, liq_path, gas_path = params
     tank = request.getfixturevalue(tank)
-    expected_liquid_mass = np.loadtxt(liq_path, skiprows=1, delimiter=",")
-    expected_gas_mass = np.loadtxt(gas_path, skiprows=1, delimiter=",")
 
-    assert np.allclose(
+    expected_liquid_mass = np.loadtxt(
+        liq_path,
+        skiprows=1,
+        delimiter=",",
+    )
+
+    expected_gas_mass = np.loadtxt(
+        gas_path,
+        skiprows=1,
+        delimiter=",",
+    )
+
+    liquid_time = expected_liquid_mass[:, 0]
+    gas_time = expected_gas_mass[:, 0]
+
+    npt.assert_allclose(
+        tank.liquid_mass(liquid_time),
         expected_liquid_mass[:, 1],
-        tank.liquid_mass(expected_liquid_mass[:, 0]),
         rtol=1e-2,
         atol=1e-4,
     )
-    assert np.allclose(
+
+    npt.assert_allclose(
+        tank.gas_mass(gas_time),
         expected_gas_mass[:, 1],
-        tank.gas_mass(expected_gas_mass[:, 0]),
         rtol=1e-1,
         atol=1e-3,
     )
@@ -81,20 +96,74 @@ def test_mass_based_tank_net_mass_flow_rate(params, request):
     """
     tank, liq_path, gas_path = params
     tank = request.getfixturevalue(tank)
-    expected_liquid_mass = np.loadtxt(liq_path, skiprows=1, delimiter=",")
-    expected_gas_mass = np.loadtxt(gas_path, skiprows=1, delimiter=",")
+
+    expected_liquid_mass = np.loadtxt(
+        liq_path,
+        skiprows=1,
+        delimiter=",",
+    )
+
+    expected_gas_mass = np.loadtxt(
+        gas_path,
+        skiprows=1,
+        delimiter=",",
+    )
 
     # Noisy derivatives, assert integrals
     initial_mass = expected_liquid_mass[0, 1] + expected_gas_mass[0, 1]
+
     expected_mass_variation = (
         expected_liquid_mass[-1, 1] + expected_gas_mass[-1, 1] - initial_mass
     )
+
+    time = tank.net_mass_flow_rate.x_array
+
     computed_final_mass = spi.simpson(
-        tank.net_mass_flow_rate.y_array,
-        x=tank.net_mass_flow_rate.x_array,
+        tank.net_mass_flow_rate(time),
+        x=time,
     )
 
-    assert isclose(expected_mass_variation, computed_final_mass, rel_tol=1e-2)
+    assert isclose(
+        expected_mass_variation,
+        computed_final_mass,
+        rel_tol=1e-2,
+    )
+
+
+def test_variable_density_mass_tank(cylindrical_variable_density_oxidizer_tank):
+    """Test variable-density mass, volume, and density consistency.
+
+    Parameters
+    ----------
+    cylindrical_variable_density_oxidizer_tank : MassBasedTank
+        The variable-density oxidizer tank to be tested.
+    """
+    tank = cylindrical_variable_density_oxidizer_tank
+    time = np.linspace(*tank.flux_time, 75)
+
+    liquid_density = tank._liquid_density(time)
+    gas_density = tank._gas_density(time)
+
+    assert np.all(liquid_density > 0)
+    assert np.all(gas_density > 0)
+    assert np.all(liquid_density < 1e5)
+    assert np.all(gas_density < 1e5)
+
+    npt.assert_allclose(
+        tank.liquid_mass(time),
+        tank.liquid_volume(time) * liquid_density,
+        atol=1e-2,
+    )
+    npt.assert_allclose(
+        tank.gas_mass(time),
+        tank.gas_volume(time) * gas_density,
+        atol=1e-2,
+    )
+    npt.assert_allclose(
+        tank.gas_mass(time),
+        0,
+        atol=1e-4,
+    )
 
 
 def test_level_based_tank_liquid_level(real_level_based_tank_seblm):
@@ -107,9 +176,15 @@ def test_level_based_tank_liquid_level(real_level_based_tank_seblm):
         The LevelBasedTank to be tested.
     """
     tank = real_level_based_tank_seblm
-    level_data = np.loadtxt(BASE_PATH / "loxUllage.csv", delimiter=",")
 
-    assert np.allclose(level_data, tank.liquid_height.get_source())
+    level_data = np.loadtxt(
+        BASE_PATH / "loxUllage.csv",
+        delimiter=",",
+    )
+
+    time = level_data[:, 0]
+
+    npt.assert_allclose(tank.liquid_height(time), level_data[:, 1], atol=1e-8)
 
 
 def test_level_based_tank_mass(real_level_based_tank_seblm):
@@ -121,14 +196,30 @@ def test_level_based_tank_mass(real_level_based_tank_seblm):
         The LevelBasedTank to be tested.
     """
     tank = real_level_based_tank_seblm
-    mass_data = np.loadtxt(BASE_PATH / "loxMass.csv", delimiter=",")
+
+    mass_data = np.loadtxt(
+        BASE_PATH / "loxMass.csv",
+        delimiter=",",
+    )
+
+    time = mass_data[:, 0]
+
+    computed_mass = tank.fluid_mass(time)
 
     # Soft tolerances for the whole curve
-    assert np.allclose(mass_data, tank.fluid_mass.get_source(), rtol=1e-1, atol=6e-1)
+    npt.assert_allclose(
+        computed_mass,
+        mass_data[:, 1],
+        rtol=1e-1,
+        atol=6e-1,
+    )
 
     # Tighter tolerances for middle of the curve
-    assert np.allclose(
-        mass_data[100:401], tank.fluid_mass.get_source()[100:401], rtol=5e-2, atol=1e-1
+    npt.assert_allclose(
+        computed_mass[100:401],
+        mass_data[100:401, 1],
+        rtol=5e-2,
+        atol=1e-1,
     )
 
 
@@ -145,7 +236,13 @@ def test_mass_flow_rate_tank_mass_flow_rate(example_mass_flow_rate_based_tank_se
 
     expected_mass_flow_rate = 0.1 - 0.2 + 0.01 - 0.02
 
-    assert np.allclose(expected_mass_flow_rate, tank.net_mass_flow_rate.y_array)
+    time = tank.net_mass_flow_rate.x_array
+
+    npt.assert_allclose(
+        tank.net_mass_flow_rate(time),
+        expected_mass_flow_rate,
+        atol=1e-6,
+    )
 
 
 def test_mass_flow_rate_tank_fluid_mass(example_mass_flow_rate_based_tank_seblm):
@@ -161,24 +258,32 @@ def test_mass_flow_rate_tank_fluid_mass(example_mass_flow_rate_based_tank_seblm)
 
     expected_initial_liquid_mass = 5
     expected_initial_gas_mass = 0.1
+
     expected_initial_mass = expected_initial_liquid_mass + expected_initial_gas_mass
+
     expected_liquid_mass_flow = 0.1 - 0.2
     expected_gas_mass_flow = 0.01 - 0.02
+
     expected_total_mass_flow = expected_liquid_mass_flow + expected_gas_mass_flow
 
-    times = np.linspace(0, 10, 11)
+    time = np.linspace(0, 10, 11)
 
-    assert np.allclose(
-        expected_initial_liquid_mass + expected_liquid_mass_flow * times,
-        tank.liquid_mass.y_array,
+    npt.assert_allclose(
+        tank.liquid_mass(time),
+        expected_initial_liquid_mass + expected_liquid_mass_flow * time,
+        atol=1e-6,
     )
-    assert np.allclose(
-        expected_initial_gas_mass + expected_gas_mass_flow * times,
-        tank.gas_mass.y_array,
+
+    npt.assert_allclose(
+        tank.gas_mass(time),
+        expected_initial_gas_mass + expected_gas_mass_flow * time,
+        atol=1e-6,
     )
-    assert np.allclose(
-        expected_initial_mass + expected_total_mass_flow * times,
-        tank.fluid_mass.y_array,
+
+    npt.assert_allclose(
+        tank.fluid_mass(time),
+        expected_initial_mass + expected_total_mass_flow * time,
+        atol=1e-6,
     )
 
 
@@ -205,21 +310,29 @@ def expected_liquid_volume(t):
     def expected_gas_volume(t):
         return (0.1 + (0.01 - 0.02) * t) / nitrogen_fluid_seblm.density
 
-    times = np.linspace(0, 10, 11)
+    time = np.linspace(0, 10, 11)
+
+    liquid_volume = expected_liquid_volume(time)
+    gas_volume = expected_gas_volume(time)
 
-    assert np.allclose(expected_liquid_volume(times), tank.liquid_volume.y_array)
-    assert np.allclose(
-        expected_liquid_volume(times) / tank.geometry.area(0),
-        tank.liquid_height.y_array,
+    npt.assert_allclose(
+        tank.liquid_volume(time),
+        liquid_volume,
+        atol=1e-6,
     )
-    assert np.allclose(
-        expected_gas_volume(times),
-        tank.gas_volume.y_array,
+
+    npt.assert_allclose(
+        tank.liquid_height(time),
+        liquid_volume / tank.geometry.area(0),
+        atol=1e-8,
     )
-    assert np.allclose(
-        (expected_gas_volume(times) + expected_liquid_volume(times))
-        / tank.geometry.area(0),
-        tank.gas_height.y_array,
+
+    npt.assert_allclose(tank.gas_volume(time), gas_volume, atol=1e-6)
+
+    npt.assert_allclose(
+        tank.gas_height(time),
+        (gas_volume + liquid_volume) / tank.geometry.area(0),
+        atol=1e-8,
     )
 
 
@@ -248,6 +361,7 @@ def expected_liquid_center_of_mass(t):
 
     def expected_gas_center_of_mass(t):
         liquid_height = (5 + (0.1 - 0.2) * t) / lox_fluid_seblm.density / np.pi
+
         gas_height = (0.1 + (0.01 - 0.02) * t) / nitrogen_fluid_seblm.density / np.pi
 
         return gas_height / 2 + liquid_height
@@ -261,23 +375,25 @@ def expected_center_of_mass(t):
             + gas_mass * expected_gas_center_of_mass(t)
         ) / (liquid_mass + gas_mass)
 
-    times = np.linspace(0, 10, 11)
+    time = np.linspace(0, 10, 11)
 
-    assert np.allclose(
-        expected_liquid_center_of_mass(times),
-        tank.liquid_center_of_mass.y_array,
+    npt.assert_allclose(
+        tank.liquid_center_of_mass(time),
+        expected_liquid_center_of_mass(time),
         atol=1e-4,
         rtol=1e-3,
     )
-    assert np.allclose(
-        expected_gas_center_of_mass(times),
-        tank.gas_center_of_mass.y_array,
+
+    npt.assert_allclose(
+        tank.gas_center_of_mass(time),
+        expected_gas_center_of_mass(time),
         atol=1e-4,
         rtol=1e-3,
     )
-    assert np.allclose(
-        expected_center_of_mass(times),
-        tank.center_of_mass.y_array,
+
+    npt.assert_allclose(
+        tank.center_of_mass(time),
+        expected_center_of_mass(time),
         atol=1e-4,
         rtol=1e-3,
     )
@@ -304,7 +420,9 @@ def test_mass_flow_rate_tank_inertia(
     def expected_center_of_mass(t):
         liquid_mass = 5 + (0.1 - 0.2) * t
         gas_mass = 0.1 + (0.01 - 0.02) * t
+
         liquid_height = liquid_mass / lox_fluid_seblm.density / np.pi
+
         gas_height = gas_mass / nitrogen_fluid_seblm.density / np.pi
 
         return (
@@ -314,8 +432,11 @@ def expected_center_of_mass(t):
 
     def expected_liquid_inertia(t):
         r = 1
+
         liquid_mass = 5 + (0.1 - 0.2) * t
+
         liquid_height = liquid_mass / lox_fluid_seblm.density / np.pi
+
         liquid_com = liquid_height / 2
 
         return (
@@ -326,10 +447,14 @@ def expected_liquid_inertia(t):
 
     def expected_gas_inertia(t):
         r = 1
+
         liquid_mass = 5 + (0.1 - 0.2) * t
         gas_mass = 0.1 + (0.01 - 0.02) * t
+
         liquid_height = liquid_mass / lox_fluid_seblm.density / np.pi
+
         gas_height = gas_mass / nitrogen_fluid_seblm.density / np.pi
+
         gas_com = gas_height / 2 + liquid_height
 
         return (
@@ -338,53 +463,28 @@ def expected_gas_inertia(t):
             + gas_mass * (gas_com - expected_center_of_mass(t)) ** 2
         )
 
-    times = np.linspace(0, 10, 11)
+    time = np.linspace(0, 10, 11)
+
+    liquid_inertia = expected_liquid_inertia(time)
+    gas_inertia = expected_gas_inertia(time)
 
-    assert np.allclose(
-        expected_liquid_inertia(times),
-        tank.liquid_inertia.y_array,
+    npt.assert_allclose(
+        tank.liquid_inertia(time),
+        liquid_inertia,
         atol=1e-3,
         rtol=1e-2,
     )
-    assert np.allclose(
-        expected_gas_inertia(times), tank.gas_inertia.y_array, atol=1e-3, rtol=1e-2
-    )
-    assert np.allclose(
-        expected_liquid_inertia(times) + expected_gas_inertia(times),
-        tank.inertia.y_array,
+
+    npt.assert_allclose(
+        tank.gas_inertia(time),
+        gas_inertia,
         atol=1e-3,
         rtol=1e-2,
     )
 
-
-def test_variable_density_mass_tank(cylindrical_variable_density_oxidizer_tank):
-    """Tests a cylindrical tank with variable density fluids
-    from its temperature and pressure values.
-
-    Parameters
-    ----------
-    cylindrical_variable_density_oxidizer_tank: MassBasedTank
-        The tank to be tested.
-    """
-    tank = cylindrical_variable_density_oxidizer_tank
-    time_steps = np.linspace(*tank.flux_time, 75)
-
-    assert (tank._liquid_density(time_steps) > 0).all()
-    assert (tank._gas_density(time_steps) > 0).all()
-    assert (tank._liquid_density(time_steps) < 1e5).all()
-    assert (tank._gas_density(time_steps) < 1e5).all()
-    np.testing.assert_allclose(
-        tank.liquid_mass(time_steps),
-        tank.liquid_volume(time_steps) * tank._liquid_density(time_steps),
-        atol=1e-2,
-    )
-    np.testing.assert_allclose(
-        tank.gas_mass(time_steps),
-        tank.gas_volume(time_steps) * tank._gas_density(time_steps),
-        atol=1e-2,
-    )
-    np.testing.assert_allclose(
-        tank.gas_mass(time_steps),
-        0,
-        atol=1e-4,
+    npt.assert_allclose(
+        tank.inertia(time),
+        liquid_inertia + gas_inertia,
+        atol=1e-3,
+        rtol=1e-2,
     )
diff --git a/tests/unit/motors/test_tank_geometry.py b/tests/unit/motors/test_tank_geometry.py
index ff4a525ba..051ec8f37 100644
--- a/tests/unit/motors/test_tank_geometry.py
+++ b/tests/unit/motors/test_tank_geometry.py
@@ -1,9 +1,12 @@
+import warnings
 from pathlib import Path
 from unittest.mock import patch
 
 import numpy as np
 import pytest
 
+from rocketpy import CylindricalTank, SphericalTank
+from rocketpy.mathutils.function import Function
 from rocketpy.motors import TankGeometry
 
 PRESSURANT_PARAMS = (0.135 / 2, 0.981)
@@ -122,8 +125,8 @@ def test_tank_inertia(params, request):
 
     # For higher accuracy: geometry.Ix_volume(geometry.bottom, h)(h)
     assert np.allclose(
-        expected_inertia[1:],
-        geometry.Ix_volume(geometry.bottom, geometry.top)(heights[1:]),
+        expected_inertia,
+        geometry.Ix_volume(geometry.bottom, geometry.top)(heights),
         rtol=1e-5,
         atol=1e-9,
     )
@@ -132,3 +135,127 @@ def test_tank_inertia(params, request):
 @patch("matplotlib.pyplot.show")
 def test_tank_geometry_plots_info(mock_show):  # pylint: disable=unused-argument
     assert TankGeometry({(0, 5): 1}).plots.all() is None
+
+
+def test_cylindrical_tank_radius_function_attribute():
+    """Test that CylindricalTank stores the input radius as 'radius_function'
+    and that it does not conflict with the 'radius' property (a Function of
+    height).
+    """
+    r = 0.1
+    tank = CylindricalTank(r, 2.0)
+
+    # radius_function stores the raw input scalar
+    assert tank.radius_function == r
+    # radius property is a callable Function, not the scalar
+    assert callable(tank.radius)
+    assert isinstance(tank.radius, Function)
+    # The two must differ in type
+    assert not isinstance(tank.radius_function, Function)
+
+
+def test_spherical_tank_radius_function_attribute():
+    """Test that SphericalTank stores the input radius as 'radius_function'
+    and that it does not conflict with the 'radius' property (a Function of
+    height).
+    """
+    r = 0.05
+    tank = SphericalTank(r)
+
+    # radius_function stores the raw input scalar
+    assert tank.radius_function == r
+    # radius property is a callable Function, not the scalar
+    assert callable(tank.radius)
+    assert isinstance(tank.radius, Function)
+    # The two must differ in type
+    assert not isinstance(tank.radius_function, Function)
+
+
+def test_cylindrical_tank_deprecated_radius_kwarg():
+    """Test that CylindricalTank issues a DeprecationWarning when the old
+    'radius' keyword argument is used, and still works correctly.
+    """
+    r = 0.1
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter("always")
+        tank = CylindricalTank(radius=r, height=2.0)
+        assert len(w) == 1
+        assert issubclass(w[0].category, DeprecationWarning)
+        assert "radius_function" in str(w[0].message)
+
+    assert tank.radius_function == r
+
+
+def test_spherical_tank_deprecated_radius_kwarg():
+    """Test that SphericalTank issues a DeprecationWarning when the old
+    'radius' keyword argument is used, and still works correctly.
+    """
+    r = 0.05
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter("always")
+        tank = SphericalTank(radius=r)
+        assert len(w) == 1
+        assert issubclass(w[0].category, DeprecationWarning)
+        assert "radius_function" in str(w[0].message)
+
+    assert tank.radius_function == r
+
+
+def test_cylindrical_tank_rejects_unknown_kwargs():
+    """The deprecated-radius **kwargs handling must not silently swallow other
+    (e.g. misspelled) keyword arguments."""
+    with pytest.raises(TypeError, match="unexpected keyword"):
+        CylindricalTank(radius_function=0.1, height=2.0, spherical_cap=True)
+
+
+def test_spherical_tank_rejects_unknown_kwargs():
+    """The deprecated-radius **kwargs handling must not silently swallow other
+    (e.g. misspelled) keyword arguments."""
+    with pytest.raises(TypeError, match="unexpected keyword"):
+        SphericalTank(radius_function=0.05, geometry_dicti={})
+
+
+def test_cylindrical_tank_to_dict_uses_radius_function_key():
+    """Test that CylindricalTank.to_dict() uses the 'radius_function' key."""
+    tank = CylindricalTank(0.1, 2.0)
+    data = tank.to_dict()
+    assert "radius_function" in data
+    assert "radius" not in data
+    assert data["radius_function"] == 0.1
+
+
+def test_spherical_tank_to_dict_uses_radius_function_key():
+    """Test that SphericalTank.to_dict() uses the 'radius_function' key."""
+    tank = SphericalTank(0.05)
+    data = tank.to_dict()
+    assert "radius_function" in data
+    assert "radius" not in data
+    assert data["radius_function"] == 0.05
+
+
+def test_cylindrical_tank_from_dict_deprecated_radius_key():
+    """Test that CylindricalTank.from_dict() issues a DeprecationWarning when
+    the serialized data contains the old 'radius' key.
+    """
+    old_data = {"radius": 0.1, "height": 2.0, "spherical_caps": False}
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter("always")
+        tank = CylindricalTank.from_dict(old_data)
+        assert len(w) == 1
+        assert issubclass(w[0].category, DeprecationWarning)
+
+    assert tank.radius_function == 0.1
+
+
+def test_spherical_tank_from_dict_deprecated_radius_key():
+    """Test that SphericalTank.from_dict() issues a DeprecationWarning when
+    the serialized data contains the old 'radius' key.
+    """
+    old_data = {"radius": 0.05}
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter("always")
+        tank = SphericalTank.from_dict(old_data)
+        assert len(w) == 1
+        assert issubclass(w[0].category, DeprecationWarning)
+
+    assert tank.radius_function == 0.05
diff --git a/tests/unit/rocket/aero_surface/test_fin_geometry.py b/tests/unit/rocket/aero_surface/test_fin_geometry.py
new file mode 100644
index 000000000..e4737bc40
--- /dev/null
+++ b/tests/unit/rocket/aero_surface/test_fin_geometry.py
@@ -0,0 +1,383 @@
+"""Unit tests for fin geometry strategy classes."""
+
+import numpy as np
+import pytest
+
+from rocketpy.rocket.aero_surface.fins import TrapezoidalFin, TrapezoidalFins
+from rocketpy.rocket.aero_surface.fins._geometry import (
+    _EllipticalGeometry,
+    _FreeFormGeometry,
+    _TrapezoidalGeometry,
+)
+
+
+def _make_trapezoidal_fins(cant_angle=0, n=4):
+    return TrapezoidalFins(
+        n=n,
+        root_chord=0.12,
+        tip_chord=0.04,
+        span=0.10,
+        rocket_radius=0.0635,
+        cant_angle=cant_angle,
+    )
+
+
+@pytest.mark.parametrize("cant", [3, -5, 0.5])
+def test_plural_fins_cant_angle_not_negated(cant):
+    """Regression: ``Fins.__init__`` used to store ``cant_angle`` negated, which
+    inverted the public attribute and flipped the cant-driven roll direction."""
+    fins = _make_trapezoidal_fins(cant_angle=cant)
+    assert fins.cant_angle == cant
+    np.testing.assert_allclose(fins.cant_angle_rad, np.radians(cant))
+
+
+def test_plural_fins_cant_roundtrip():
+    """``to_dict``/``from_dict`` must preserve the cant sign (it was double-
+    negated, so a save/load cycle flipped the physical cant)."""
+    fins = _make_trapezoidal_fins(cant_angle=5)
+    restored = TrapezoidalFins.from_dict(fins.to_dict())
+    assert restored.cant_angle == fins.cant_angle
+    np.testing.assert_allclose(restored.cant_angle_rad, fins.cant_angle_rad)
+
+
+def test_plural_fins_cant_setter_getter_consistency():
+    """Building with ``cant_angle=x`` and assigning ``cant_angle = x`` must yield
+    the same internal radian representation (constructor used to negate, setter
+    did not)."""
+    built = _make_trapezoidal_fins(cant_angle=5)
+    reset = _make_trapezoidal_fins(cant_angle=0)
+    reset.cant_angle = 5
+    np.testing.assert_allclose(built.cant_angle_rad, reset.cant_angle_rad)
+
+
+def test_singular_and_plural_cant_sign_parity():
+    """A single ``Fin`` and a plural ``Fins`` built with the same cant must agree
+    in ``cant_angle_rad`` (they diverged in sign when ``Fins`` negated it)."""
+    plural = _make_trapezoidal_fins(cant_angle=5)
+    single = TrapezoidalFin(
+        angular_position=0,
+        root_chord=0.12,
+        tip_chord=0.04,
+        span=0.10,
+        rocket_radius=0.0635,
+        cant_angle=5,
+    )
+    np.testing.assert_allclose(plural.cant_angle_rad, single.cant_angle_rad)
+
+
+def test_plural_fins_roll_forcing_scales_with_n():
+    """The roll forcing coefficient derivative must scale linearly with ``n``
+    (regression: it was changed to scale with ``fin_num_correction(n)``, which
+    ~halved the cant-driven roll authority for a 4-fin set)."""
+    fins4 = _make_trapezoidal_fins(cant_angle=2, n=4)
+    fins8 = _make_trapezoidal_fins(cant_angle=2, n=8)
+    clf4 = fins4.roll_parameters[0].get_value_opt(0.3)
+    clf8 = fins8.roll_parameters[0].get_value_opt(0.3)
+    np.testing.assert_allclose(clf8 / clf4, 8 / 4, rtol=1e-6)
+
+
+def test_trapezoidal_geometry_evaluate_geometrical_parameters(
+    calisto_trapezoidal_fin,
+):
+    """Ensure trapezoidal geometry populates the derived fin parameters."""
+    # Arrange
+    geometry = calisto_trapezoidal_fin.geometry
+
+    # Act
+    geometry.evaluate_geometrical_parameters()
+
+    # Assert
+    owner = calisto_trapezoidal_fin
+    expected_area = (owner.root_chord + owner.tip_chord) * owner.span / 2
+    expected_aspect_ratio = 2 * owner.span**2 / expected_area
+    expected_gamma_c = np.arctan(
+        (geometry.sweep_length + 0.5 * owner.tip_chord - 0.5 * owner.root_chord)
+        / owner.span
+    )
+    expected_mid_aerodynamic_span = (
+        owner.span
+        / 3
+        * (owner.root_chord + 2 * owner.tip_chord)
+        / (owner.root_chord + owner.tip_chord)
+    )
+    tau = (owner.span + owner.rocket_radius) / owner.rocket_radius
+    lambda_ = owner.tip_chord / owner.root_chord
+    expected_roll_constant = (
+        (owner.root_chord + 3 * owner.tip_chord) * owner.span**3
+        + 4
+        * (owner.root_chord + 2 * owner.tip_chord)
+        * owner.rocket_radius
+        * owner.span**2
+        + 6 * (owner.root_chord + owner.tip_chord) * owner.span * owner.rocket_radius**2
+    ) / 12
+    expected_lift_interference_factor = 1 + 1 / tau
+    expected_roll_damping_factor = 1 + (
+        ((tau - lambda_) / tau) - ((1 - lambda_) / (tau - 1)) * np.log(tau)
+    ) / (
+        ((tau + 1) * (tau - lambda_)) / 2
+        - ((1 - lambda_) * (tau**3 - 1)) / (3 * (tau - 1))
+    )
+    expected_roll_forcing_factor = (1 / np.pi**2) * (
+        (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2)
+        + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+        * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+        - (2 * np.pi * (tau + 1)) / (tau * (tau - 1))
+        + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2))
+        * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2
+        - (4 * (tau + 1)) / (tau * (tau - 1)) * np.arcsin((tau**2 - 1) / (tau**2 + 1))
+        + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau))
+    )
+
+    assert isinstance(geometry, _TrapezoidalGeometry)
+    assert owner.Af == pytest.approx(expected_area)
+    assert owner.AR == pytest.approx(expected_aspect_ratio)
+    assert owner.gamma_c == pytest.approx(expected_gamma_c)
+    assert owner.Yma == pytest.approx(expected_mid_aerodynamic_span)
+    assert owner.roll_geometrical_constant == pytest.approx(expected_roll_constant)
+    assert owner.tau == pytest.approx(tau)
+    assert owner.lift_interference_factor == pytest.approx(
+        expected_lift_interference_factor
+    )
+    assert owner.roll_damping_interference_factor == pytest.approx(
+        expected_roll_damping_factor
+    )
+    assert owner.roll_forcing_interference_factor == pytest.approx(
+        expected_roll_forcing_factor
+    )
+
+
+def test_trapezoidal_geometry_evaluate_shape_sets_expected_points(
+    calisto_trapezoidal_fin,
+):
+    """Ensure trapezoidal geometry shape points match the configured sweep."""
+    # Arrange
+    geometry = calisto_trapezoidal_fin.geometry
+
+    # Act
+    geometry.evaluate_shape()
+
+    # Assert
+    np.testing.assert_allclose(
+        calisto_trapezoidal_fin.shape_vec[0],
+        np.array([0.0, 0.08, 0.12, 0.12]),
+    )
+    np.testing.assert_allclose(
+        calisto_trapezoidal_fin.shape_vec[1],
+        np.array([0.0, 0.1, 0.1, 0.0]),
+    )
+
+
+def test_trapezoidal_geometry_get_data_returns_inputs_and_outputs(
+    calisto_trapezoidal_fin,
+):
+    """Ensure trapezoidal geometry serialization includes optional outputs."""
+    # Arrange
+    geometry = calisto_trapezoidal_fin.geometry
+
+    # Act
+    geometry.evaluate_geometrical_parameters()
+    data_without_outputs = geometry.get_data()
+    data_with_outputs = geometry.get_data(include_outputs=True)
+
+    # Assert
+    assert data_without_outputs["tip_chord"] == pytest.approx(
+        calisto_trapezoidal_fin.tip_chord
+    )
+    assert data_without_outputs["sweep_length"] == pytest.approx(
+        calisto_trapezoidal_fin.sweep_length
+    )
+    assert data_without_outputs["sweep_angle"] is None
+    assert set(data_with_outputs) >= {
+        "tip_chord",
+        "sweep_length",
+        "sweep_angle",
+        "shape_vec",
+        "Af",
+        "AR",
+        "gamma_c",
+        "Yma",
+        "roll_geometrical_constant",
+        "tau",
+        "lift_interference_factor",
+        "roll_damping_interference_factor",
+        "roll_forcing_interference_factor",
+    }
+    np.testing.assert_allclose(
+        data_with_outputs["shape_vec"][0], calisto_trapezoidal_fin.shape_vec[0]
+    )
+
+
+def test_elliptical_geometry_evaluate_geometrical_parameters(
+    calisto_elliptical_fin,
+):
+    """Ensure elliptical geometry populates the derived fin parameters."""
+    # Arrange
+    geometry = calisto_elliptical_fin.geometry
+
+    # Act
+    geometry.evaluate_geometrical_parameters()
+
+    # Assert
+    owner = calisto_elliptical_fin
+    expected_area = np.pi * owner.root_chord / 2 * owner.span / 2
+    expected_aspect_ratio = 2 * owner.span**2 / expected_area
+    expected_mid_aerodynamic_span = (
+        owner.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64)
+    )
+    expected_roll_constant = (
+        owner.root_chord
+        * owner.span
+        * (
+            3 * np.pi * owner.span**2
+            + 32 * owner.rocket_radius * owner.span
+            + 12 * np.pi * owner.rocket_radius**2
+        )
+        / 48
+    )
+    tau = (owner.span + owner.rocket_radius) / owner.rocket_radius
+    expected_lift_interference_factor = 1 + 1 / tau
+
+    assert isinstance(geometry, _EllipticalGeometry)
+    assert owner.Af == pytest.approx(expected_area)
+    assert owner.AR == pytest.approx(expected_aspect_ratio)
+    assert owner.gamma_c == pytest.approx(0)
+    assert owner.Yma == pytest.approx(expected_mid_aerodynamic_span)
+    assert owner.roll_geometrical_constant == pytest.approx(expected_roll_constant)
+    assert owner.tau == pytest.approx(tau)
+    assert owner.lift_interference_factor == pytest.approx(
+        expected_lift_interference_factor
+    )
+    assert owner.roll_damping_interference_factor > 0
+    assert owner.roll_forcing_interference_factor > 0
+
+
+def test_elliptical_geometry_evaluate_shape_sets_expected_points(
+    calisto_elliptical_fin,
+):
+    """Ensure elliptical geometry evaluates the expected semi-ellipse shape."""
+    # Arrange
+    geometry = calisto_elliptical_fin.geometry
+
+    # Act
+    geometry.evaluate_shape()
+
+    # Assert
+    angles = np.arange(0, 180, 5)
+    expected_x = calisto_elliptical_fin.root_chord / 2 + (
+        calisto_elliptical_fin.root_chord / 2 * np.cos(np.radians(angles))
+    )
+    expected_y = calisto_elliptical_fin.span * np.sin(np.radians(angles))
+    np.testing.assert_allclose(calisto_elliptical_fin.shape_vec[0], expected_x)
+    np.testing.assert_allclose(calisto_elliptical_fin.shape_vec[1], expected_y)
+
+
+def test_elliptical_geometry_get_data_returns_expected_outputs(
+    calisto_elliptical_fin,
+):
+    """Ensure elliptical geometry serialization includes optional outputs."""
+    # Arrange
+    geometry = calisto_elliptical_fin.geometry
+
+    # Act
+    geometry.evaluate_geometrical_parameters()
+    data_without_outputs = geometry.get_data()
+    data_with_outputs = geometry.get_data(include_outputs=True)
+
+    # Assert
+    assert data_without_outputs == {}
+    assert data_with_outputs["Af"] == pytest.approx(calisto_elliptical_fin.Af)
+    assert data_with_outputs["AR"] == pytest.approx(calisto_elliptical_fin.AR)
+    assert data_with_outputs["gamma_c"] == pytest.approx(calisto_elliptical_fin.gamma_c)
+    assert data_with_outputs["Yma"] == pytest.approx(calisto_elliptical_fin.Yma)
+    assert data_with_outputs["roll_geometrical_constant"] == pytest.approx(
+        calisto_elliptical_fin.roll_geometrical_constant
+    )
+    assert data_with_outputs["tau"] == pytest.approx(calisto_elliptical_fin.tau)
+
+
+def test_free_form_geometry_infer_dimensions_warns_on_jagged_shape():
+    """Ensure jagged free-form fins emit a warning while dimensions are inferred."""
+    # Arrange
+    shape_points = [(0, 0), (0.05, 0.1), (0.06, 0.05), (0.09, 0.07), (0.12, 0)]
+
+    # Act
+    with pytest.warns(UserWarning, match="Jagged fin shape detected"):
+        root_chord, span = _FreeFormGeometry.infer_dimensions(shape_points)
+
+    # Assert
+    assert root_chord == pytest.approx(0.12)
+    assert span == pytest.approx(0.1)
+
+
+def test_free_form_geometry_evaluate_geometrical_parameters(calisto_free_form_fin):
+    """Ensure free-form geometry populates the derived fin parameters."""
+    # Arrange
+    geometry = calisto_free_form_fin.geometry
+
+    # Act
+    geometry.evaluate_geometrical_parameters()
+
+    # Assert
+    owner = calisto_free_form_fin
+    assert isinstance(geometry, _FreeFormGeometry)
+    assert owner.Af == pytest.approx(0.008)
+    assert owner.AR == pytest.approx(2.5)
+    assert owner.gamma_c > 0
+    assert owner.Yma > 0
+    assert owner.mac_length > 0
+    assert owner.mac_lead >= 0
+    assert owner.roll_geometrical_constant > 0
+    assert owner.tau > 0
+    assert owner.lift_interference_factor > 1
+    assert owner.roll_damping_interference_factor > 1
+    assert owner.roll_forcing_interference_factor > 0
+
+
+def test_free_form_geometry_evaluate_shape_sets_expected_points(
+    calisto_free_form_fin,
+):
+    """Ensure free-form geometry exposes the configured shape points."""
+    # Arrange
+    geometry = calisto_free_form_fin.geometry
+
+    # Act
+    geometry.evaluate_shape()
+
+    # Assert
+    np.testing.assert_allclose(
+        calisto_free_form_fin.shape_vec[0],
+        np.array([0.0, 0.08, 0.12, 0.12]),
+    )
+    np.testing.assert_allclose(
+        calisto_free_form_fin.shape_vec[1],
+        np.array([0.0, 0.1, 0.1, 0.0]),
+    )
+
+
+def test_free_form_geometry_get_data_returns_expected_outputs(
+    calisto_free_form_fin,
+):
+    """Ensure free-form geometry serialization includes optional outputs."""
+    # Arrange
+    geometry = calisto_free_form_fin.geometry
+
+    # Act
+    geometry.evaluate_geometrical_parameters()
+    data_without_outputs = geometry.get_data()
+    data_with_outputs = geometry.get_data(include_outputs=True)
+
+    # Assert
+    assert data_without_outputs == {
+        "shape_points": [(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)],
+    }
+    assert data_with_outputs["shape_points"] == calisto_free_form_fin.shape_points
+    assert data_with_outputs["Af"] == pytest.approx(calisto_free_form_fin.Af)
+    assert data_with_outputs["AR"] == pytest.approx(calisto_free_form_fin.AR)
+    assert data_with_outputs["gamma_c"] == pytest.approx(calisto_free_form_fin.gamma_c)
+    assert data_with_outputs["Yma"] == pytest.approx(calisto_free_form_fin.Yma)
+    assert data_with_outputs["mac_length"] == pytest.approx(
+        calisto_free_form_fin.mac_length
+    )
+    assert data_with_outputs["mac_lead"] == pytest.approx(
+        calisto_free_form_fin.mac_lead
+    )
diff --git a/tests/unit/rocket/aero_surface/test_individual_fins.py b/tests/unit/rocket/aero_surface/test_individual_fins.py
new file mode 100644
index 000000000..f30bb52cc
--- /dev/null
+++ b/tests/unit/rocket/aero_surface/test_individual_fins.py
@@ -0,0 +1,460 @@
+"""Unit tests for individual fin classes."""
+
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+from rocketpy import (
+    EllipticalFin,
+    FreeFormFin,
+    Rocket,
+    TrapezoidalFin,
+    TrapezoidalFins,
+)
+from rocketpy.mathutils.vector_matrix import Vector
+
+
+@pytest.mark.parametrize(
+    "fixture_name,expected_class",
+    [
+        ("calisto_trapezoidal_fin", TrapezoidalFin),
+        ("calisto_elliptical_fin", EllipticalFin),
+        ("calisto_free_form_fin", FreeFormFin),
+    ],
+)
+def test_individual_fin_info_returns_none(request, fixture_name, expected_class):
+    """Ensure info() executes for all individual fin classes."""
+    # Arrange
+    fin = request.getfixturevalue(fixture_name)
+
+    # Act
+    result = fin.info()
+
+    # Assert
+    assert isinstance(fin, expected_class)
+    assert result is None
+
+
+@patch("matplotlib.pyplot.show")
+@pytest.mark.parametrize(
+    "fixture_name",
+    [
+        "calisto_trapezoidal_fin",
+        "calisto_elliptical_fin",
+        "calisto_free_form_fin",
+    ],
+)
+def test_individual_fin_draw_returns_none(mock_show, request, fixture_name):  # pylint: disable=unused-argument
+    """Ensure draw() executes for all individual fin classes."""
+    # Arrange
+    fin = request.getfixturevalue(fixture_name)
+
+    # Act
+    result = fin.draw(filename=None)
+
+    # Assert
+    assert result is None
+
+
+@pytest.mark.parametrize(
+    "fixture_name",
+    [
+        "calisto_trapezoidal_fin",
+        "calisto_elliptical_fin",
+        "calisto_free_form_fin",
+    ],
+)
+def test_individual_fin_angular_position_updates_radians(request, fixture_name):
+    """Ensure angular_position setter updates angular_position_rad."""
+    # Arrange
+    fin = request.getfixturevalue(fixture_name)
+
+    # Act
+    fin.angular_position = 45
+
+    # Assert
+    assert fin.angular_position == 45
+    np.testing.assert_allclose(fin.angular_position_rad, np.pi / 4)
+
+
+def test_trapezoidal_fin_setters_update_geometry(calisto_trapezoidal_fin):
+    """Ensure trapezoidal fin geometry setters update exposed values."""
+    # Arrange
+    fin = calisto_trapezoidal_fin
+
+    # Act
+    fin.tip_chord = 0.05
+    fin.sweep_angle = 12.0
+    fin.sweep_length = 0.03
+
+    # Assert
+    np.testing.assert_allclose(fin.tip_chord, 0.05)
+    np.testing.assert_allclose(fin.sweep_angle, 12.0)
+    np.testing.assert_allclose(fin.sweep_length, 0.03)
+
+
+def test_individual_fin_rocket_diameter_aliases_are_kept_in_sync(
+    calisto_trapezoidal_fin,
+):
+    """Ensure rocket_diameter is canonical and old aliases remain compatible."""
+    # Arrange
+    fin = calisto_trapezoidal_fin
+
+    # Act
+    fin.rocket_diameter = 0.15
+
+    # Assert
+    np.testing.assert_allclose(fin.rocket_diameter, 0.15)
+    np.testing.assert_allclose(fin.diameter, 0.15)
+    np.testing.assert_allclose(fin.d, 0.15)
+    np.testing.assert_allclose(fin.rocket_radius, 0.075)
+    np.testing.assert_allclose(fin.reference_length, 0.15)
+
+    # Act
+    fin.d = 0.20
+
+    # Assert
+    np.testing.assert_allclose(fin.rocket_diameter, 0.20)
+    np.testing.assert_allclose(fin.diameter, 0.20)
+    np.testing.assert_allclose(fin.d, 0.20)
+    np.testing.assert_allclose(fin.rocket_radius, 0.10)
+    np.testing.assert_allclose(fin.reference_length, 0.20)
+
+
+def test_individual_fin_reference_area_and_ref_area_alias_are_kept_in_sync(
+    calisto_trapezoidal_fin,
+):
+    """Ensure reference_area is canonical and ref_area remains compatible."""
+    # Arrange
+    fin = calisto_trapezoidal_fin
+
+    # Act
+    fin.reference_area = 0.123
+
+    # Assert
+    np.testing.assert_allclose(fin.reference_area, 0.123)
+    np.testing.assert_allclose(fin.ref_area, 0.123)
+
+    # Act
+    fin.ref_area = 0.456
+
+    # Assert
+    np.testing.assert_allclose(fin.reference_area, 0.456)
+    np.testing.assert_allclose(fin.ref_area, 0.456)
+
+
+def test_individual_fin_to_dict_include_outputs_exposes_diameter_aliases(
+    calisto_trapezoidal_fin,
+):
+    """Ensure output serialization exposes canonical and alias diameter keys."""
+    # Arrange
+    fin = calisto_trapezoidal_fin
+
+    # Act
+    data = fin.to_dict(include_outputs=True)
+
+    # Assert
+    np.testing.assert_allclose(data["rocket_diameter"], fin.rocket_diameter)
+    np.testing.assert_allclose(data["diameter"], fin.rocket_diameter)
+    np.testing.assert_allclose(data["d"], fin.rocket_diameter)
+    np.testing.assert_allclose(data["reference_area"], fin.reference_area)
+    np.testing.assert_allclose(data["ref_area"], fin.reference_area)
+
+
+def test_trapezoidal_fin_rejects_inconsistent_sweep_inputs():
+    """Ensure trapezoidal fin rejects sweep_length with sweep_angle together."""
+    # Arrange / Act / Assert
+    with pytest.raises(
+        ValueError, match="Cannot use sweep_length and sweep_angle together"
+    ):
+        TrapezoidalFin(
+            angular_position=0,
+            root_chord=0.12,
+            tip_chord=0.04,
+            span=0.1,
+            rocket_radius=0.0635,
+            sweep_length=0.02,
+            sweep_angle=10.0,
+        )
+
+
+def test_free_form_fin_shape_points_property(calisto_free_form_fin):
+    """Ensure free-form fin exposes the original shape points."""
+    # Arrange
+    fin = calisto_free_form_fin
+
+    # Act
+    shape_points = fin.shape_points
+
+    # Assert
+    assert shape_points == [(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)]
+
+
+@pytest.mark.parametrize(
+    "fixture_name,required_keys",
+    [
+        (
+            "calisto_trapezoidal_fin",
+            {
+                "angular_position",
+                "root_chord",
+                "span",
+                "rocket_radius",
+                "cant_angle",
+                "airfoil",
+                "name",
+                "tip_chord",
+                "sweep_length",
+                "sweep_angle",
+            },
+        ),
+        (
+            "calisto_elliptical_fin",
+            {
+                "angular_position",
+                "root_chord",
+                "span",
+                "rocket_radius",
+                "cant_angle",
+                "airfoil",
+                "name",
+            },
+        ),
+        (
+            "calisto_free_form_fin",
+            {
+                "angular_position",
+                "rocket_radius",
+                "cant_angle",
+                "airfoil",
+                "name",
+                "shape_points",
+            },
+        ),
+    ],
+)
+def test_individual_fin_to_dict_contains_expected_keys(
+    request, fixture_name, required_keys
+):
+    """Ensure to_dict for each individual fin exposes expected input keys."""
+    # Arrange
+    fin = request.getfixturevalue(fixture_name)
+
+    # Act
+    data = fin.to_dict()
+
+    # Assert
+    assert required_keys.issubset(data.keys())
+
+
+@pytest.mark.parametrize(
+    "fixture_name,fin_class,comparisons",
+    [
+        (
+            "calisto_trapezoidal_fin",
+            TrapezoidalFin,
+            ["angular_position", "root_chord", "tip_chord", "span", "rocket_radius"],
+        ),
+        (
+            "calisto_elliptical_fin",
+            EllipticalFin,
+            ["angular_position", "root_chord", "span", "rocket_radius"],
+        ),
+        (
+            "calisto_free_form_fin",
+            FreeFormFin,
+            ["angular_position", "rocket_radius"],
+        ),
+    ],
+)
+def test_individual_fin_from_dict_roundtrip(
+    request, fixture_name, fin_class, comparisons
+):
+    """Ensure each individual fin can be reconstructed with from_dict."""
+    # Arrange
+    fin = request.getfixturevalue(fixture_name)
+    data = fin.to_dict()
+
+    # Act
+    reconstructed = fin_class.from_dict(data)
+
+    # Assert
+    assert isinstance(reconstructed, fin_class)
+    for field in comparisons:
+        np.testing.assert_allclose(getattr(reconstructed, field), getattr(fin, field))
+
+    if fin_class is FreeFormFin:
+        assert reconstructed.shape_points == fin.shape_points
+
+
+def test_trapezoidal_fin_from_dict_roundtrip_preserves_sweep_length():
+    """Ensure TrapezoidalFin round-trip preserves non-default sweep geometry."""
+    # Arrange
+    original = TrapezoidalFin(
+        angular_position=0,
+        root_chord=0.12,
+        tip_chord=0.04,
+        span=0.1,
+        rocket_radius=0.0635,
+        cant_angle=0,
+        sweep_angle=15.0,
+        name="roundtrip_trapezoidal_fin",
+    )
+    data = original.to_dict()
+
+    # Act
+    reconstructed = TrapezoidalFin.from_dict(data)
+
+    # Assert
+    np.testing.assert_allclose(reconstructed.sweep_length, original.sweep_length)
+
+
+def test_calisto_finset_vs_four_individual_fins_close():
+    """Ensure a 4-fin set and 4 individual fins produce close aerodynamics.
+
+    Notes
+    -----
+    A fin set model includes finite-set lift correction for the number of fins.
+    For 4 fins, this correction is equivalent to scaling the sum of 4
+    individual-fin lift derivatives by 1/2.
+    """
+    # Arrange
+    finset_rocket = Rocket(
+        radius=0.0635,
+        mass=14.426,
+        inertia=(6.321, 6.321, 0.034),
+        power_off_drag="data/rockets/calisto/powerOffDragCurve.csv",
+        power_on_drag="data/rockets/calisto/powerOnDragCurve.csv",
+        center_of_mass_without_motor=0,
+        coordinate_system_orientation="tail_to_nose",
+    )
+    finset_rocket.add_surfaces(
+        TrapezoidalFins(
+            n=4,
+            span=0.100,
+            root_chord=0.120,
+            tip_chord=0.040,
+            rocket_radius=0.0635,
+            name="calisto_trapezoidal_fins",
+            cant_angle=0,
+            sweep_length=None,
+            sweep_angle=None,
+            airfoil=None,
+        ),
+        -1.168,
+    )
+
+    individual_fins_rocket = Rocket(
+        radius=0.0635,
+        mass=14.426,
+        inertia=(6.321, 6.321, 0.034),
+        power_off_drag="data/rockets/calisto/powerOffDragCurve.csv",
+        power_on_drag="data/rockets/calisto/powerOnDragCurve.csv",
+        center_of_mass_without_motor=0,
+        coordinate_system_orientation="tail_to_nose",
+    )
+
+    individual_fins = [
+        TrapezoidalFin(
+            angular_position=angle,
+            root_chord=0.120,
+            tip_chord=0.040,
+            span=0.100,
+            rocket_radius=0.0635,
+            name=f"calisto_trapezoidal_fin_{i}",
+            cant_angle=0,
+            sweep_length=None,
+            sweep_angle=None,
+            airfoil=None,
+        )
+        for i, angle in enumerate((0, 90, 180, 270), start=1)
+    ]
+    individual_fins_rocket.add_surfaces(individual_fins, [-1.168] * 4)
+
+    mach_grid = np.linspace(0, 2, 21)
+
+    # Act
+    cp_finset = finset_rocket.cp_position(mach_grid)
+    cp_individual = individual_fins_rocket.cp_position(mach_grid)
+    clalpha_finset = finset_rocket.total_lift_coeff_der(mach_grid)
+    clalpha_individual = individual_fins_rocket.total_lift_coeff_der(mach_grid)
+    lift_correction = TrapezoidalFins.fin_num_correction(4) / 4
+    clalpha_individual_corrected = np.array(clalpha_individual) * lift_correction
+
+    # Assert
+    np.testing.assert_allclose(cp_individual, cp_finset, rtol=1e-6, atol=1e-6)
+    np.testing.assert_allclose(clalpha_individual_corrected, clalpha_finset)
+
+
+def test_individual_fin_roll_damping_not_double_counted():
+    """Regression: roll damping for an individual fin must come solely from the
+    roll-rate velocity that the Flight loop injects into the local stream
+    velocity (``w ^ cp`` at the off-axis center of pressure), captured by the
+    ``cp ^ R`` moment. An extra explicit ``cld_omega * omega3`` term used to be
+    added on top, double-counting the damping. The roll moment must therefore be
+    invariant to the explicit ``omega`` argument for a fixed stream velocity, and
+    must still respond to a tangential (roll-induced) stream component."""
+    fin = TrapezoidalFin(
+        angular_position=0,  # fin located at +y, so its CP is off the roll axis
+        root_chord=0.12,
+        tip_chord=0.04,
+        span=0.10,
+        rocket_radius=0.0635,
+        cant_angle=0,
+    )
+    cp = Vector([0.0, 0.0635, -1.0])
+    axial_stream = Vector([0.0, 0.0, 30.0])
+    tangential_stream = Vector([3.0, 0.0, 30.0])  # +x mimics roll-induced flow
+
+    def roll_moment(stream, omega3):
+        return fin.compute_forces_and_moments(
+            stream, abs(stream), 0.1, 1.2, cp, (0.0, 0.0, omega3)
+        )[5]
+
+    # The explicit omega argument must not add a separate damping term.
+    m_omega_0 = roll_moment(tangential_stream, 0.0)
+    m_omega_50 = roll_moment(tangential_stream, 50.0)
+    np.testing.assert_allclose(m_omega_0, m_omega_50, atol=1e-12)
+
+    # Damping is still produced: a tangential (roll-induced) stream component
+    # changes the roll moment relative to purely axial flow.
+    m_axial = roll_moment(axial_stream, 0.0)
+    assert abs(m_omega_0 - m_axial) > 1e-6
+
+
+@pytest.mark.parametrize(
+    "position_input",
+    [
+        (0.02, -0.01, -1.2),
+        Vector([0.02, -0.01, -1.2]),
+    ],
+)
+def test_add_individual_fin_accepts_full_3d_position(position_input):
+    """Ensure individual fins accept full (x, y, z) position inputs."""
+    # Arrange
+    rocket = Rocket(
+        radius=0.0635,
+        mass=14.426,
+        inertia=(6.321, 6.321, 0.034),
+        power_off_drag="data/rockets/calisto/powerOffDragCurve.csv",
+        power_on_drag="data/rockets/calisto/powerOnDragCurve.csv",
+        center_of_mass_without_motor=0,
+        coordinate_system_orientation="tail_to_nose",
+    )
+    fin = TrapezoidalFin(
+        angular_position=30,
+        root_chord=0.120,
+        tip_chord=0.040,
+        span=0.100,
+        rocket_radius=0.0635,
+        cant_angle=0,
+        name="position_test_fin",
+    )
+
+    # Act
+    rocket.add_surfaces(fin, position_input)
+    stored_position = rocket.aerodynamic_surfaces[0].position
+
+    # Assert
+    assert stored_position == Vector([0.02, -0.01, -1.2])
diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py
index 3c7725fa5..7a37cbd4e 100644
--- a/tests/unit/rocket/test_rocket.py
+++ b/tests/unit/rocket/test_rocket.py
@@ -5,7 +5,12 @@
 import numpy as np
 import pytest
 
-from rocketpy import Function, NoseCone, Rocket, SolidMotor
+from rocketpy import Function, GenericSurface, NoseCone, Rocket, SolidMotor
+from rocketpy.exceptions import (
+    InvalidInertiaError,
+    InvalidParameterError,
+    UnstableRocketWarning,
+)
 from rocketpy.mathutils.vector_matrix import Vector
 from rocketpy.motors.empty_motor import EmptyMotor
 from rocketpy.motors.motor import Motor
@@ -835,3 +840,208 @@ def test_drag_input_types_supported_for_power_on_and_power_off(tmp_path):
 
         assert rocket.power_off_drag_7d(*query_point) == pytest.approx(expected)
         assert rocket.power_on_drag_7d(*query_point) == pytest.approx(expected)
+
+
+@pytest.mark.parametrize("radius", [-1, 0, -0.001])
+def test_rocket_invalid_radius_raises(radius):
+    """InvalidParameterError must be raised for non-positive radius values."""
+    with pytest.raises(InvalidParameterError, match="radius"):
+        Rocket(
+            radius=radius,
+            mass=10,
+            inertia=(0.1, 0.1, 0.01),
+            power_off_drag=0.3,
+            power_on_drag=0.3,
+            center_of_mass_without_motor=0,
+        )
+
+
+@pytest.mark.parametrize("mass", [-1, 0, -0.001])
+def test_rocket_invalid_mass_raises(mass):
+    """InvalidParameterError must be raised for non-positive mass values."""
+    with pytest.raises(InvalidParameterError, match="mass"):
+        Rocket(
+            radius=0.05,
+            mass=mass,
+            inertia=(0.1, 0.1, 0.01),
+            power_off_drag=0.3,
+            power_on_drag=0.3,
+            center_of_mass_without_motor=0,
+        )
+
+
+@pytest.mark.parametrize("inertia", [(0.1,), (0.1, 0.1), (0.1, 0.1, 0.01, 0.0, 0.0)])
+def test_rocket_invalid_inertia_length_raises(inertia):
+    """InvalidInertiaError must be raised when inertia tuple has wrong length."""
+    with pytest.raises(InvalidInertiaError):
+        Rocket(
+            radius=0.05,
+            mass=10,
+            inertia=inertia,
+            power_off_drag=0.3,
+            power_on_drag=0.3,
+            center_of_mass_without_motor=0,
+        )
+
+
+@pytest.mark.parametrize(
+    "inertia",
+    [
+        np.array([6.321, 6.321, 0.034]),
+        np.array([6.321, 6.321, 0.034, 0.0, 0.0, 0.0]),
+    ],
+)
+def test_rocket_accepts_numpy_inertia_and_scalars(inertia):
+    """Regression: numpy-array inertia and numpy numeric scalars for
+    radius/mass must be accepted (they were rejected by an overly strict
+    isinstance check, breaking code that computes inertia tensors with numpy)."""
+    rocket = Rocket(
+        radius=np.float64(0.05),
+        mass=np.int64(10),
+        inertia=inertia,
+        power_off_drag=0.3,
+        power_on_drag=0.3,
+        center_of_mass_without_motor=0,
+    )
+    assert rocket.I_11_without_motor == inertia[0]
+    assert rocket.I_33_without_motor == inertia[2]
+
+
+def test_add_trapezoidal_fins_two_fins_warns_but_succeeds(calisto):
+    """Regression: fin sets with n<=2 must still be accepted (as on master),
+    now with an informative warning instead of a hard error."""
+    with pytest.warns(UserWarning, match="2 or fewer fins"):
+        fins = calisto.add_trapezoidal_fins(
+            2, span=0.1, root_chord=0.12, tip_chord=0.04, position=-1.0
+        )
+    assert fins in [surface for surface, _ in calisto.aerodynamic_surfaces]
+
+
+def test_unstable_rocket_warning_raised(calisto):
+    """UnstableRocketWarning must be raised (at finalization, e.g. via
+    ``warn_if_unstable``) when the static margin at motor ignition is negative.
+    The warning must NOT fire during incremental ``add_surfaces`` construction,
+    to avoid spurious warnings for partially-built-but-stable rockets."""
+    nose = NoseCone(
+        length=0.55829,
+        kind="vonkarman",
+        base_radius=0.0635,
+        rocket_radius=0.0635,
+        name="Nose Cone",
+    )
+    # Adding surfaces during construction must not warn.
+    with warnings.catch_warnings():
+        warnings.simplefilter("error", UnstableRocketWarning)
+        calisto.add_surfaces(nose, 1.16)
+
+    # The check fires explicitly once the rocket is finalized.
+    with pytest.warns(UnstableRocketWarning):
+        calisto.warn_if_unstable()
+    assert calisto.static_margin(0) < 0
+
+
+def test_unstable_rocket_warning_skipped_with_generic_surface(calisto):
+    """UnstableRocketWarning must not be raised when the rocket has a
+    GenericSurface, since its lift coefficient derivative is not accounted
+    for in the center of pressure calculation, making the static margin
+    unreliable for this check."""
+    nose = NoseCone(
+        length=0.55829,
+        kind="vonkarman",
+        base_radius=0.0635,
+        rocket_radius=0.0635,
+        name="Nose Cone",
+    )
+    generic_surface = GenericSurface(
+        reference_area=None,
+        reference_length=None,
+        coefficients={
+            "cL": lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: 1
+        },
+    )
+    with warnings.catch_warnings():
+        warnings.simplefilter("error", UnstableRocketWarning)
+        calisto.add_surfaces([nose, generic_surface], [1.16, 0])
+        calisto.warn_if_unstable()
+    assert calisto.static_margin(0) < 0
+
+
+def test_power_drag_exposed_as_function_objects_and_inputs_preserved():
+    """Regression for PR #941: ``power_off_drag``/``power_on_drag`` must be
+    exposed as Mach-only ``Function`` objects, while the raw user input is
+    preserved in the ``_power_off_drag_input``/``_power_on_drag_input``
+    attributes."""
+    off_input = "data/rockets/calisto/powerOffDragCurve.csv"
+    on_input = "data/rockets/calisto/powerOnDragCurve.csv"
+    rocket = Rocket(
+        radius=0.0635,
+        mass=14.426,
+        inertia=(6.321, 6.321, 0.034),
+        power_off_drag=off_input,
+        power_on_drag=on_input,
+        center_of_mass_without_motor=0,
+        coordinate_system_orientation="tail_to_nose",
+    )
+
+    # Public drag attributes are Function objects (Mach-only aliases).
+    assert isinstance(rocket.power_off_drag, Function)
+    assert isinstance(rocket.power_on_drag, Function)
+    assert rocket.power_off_drag(0.5) == pytest.approx(
+        rocket.power_off_drag_7d(0, 0, 0.5, 0, 0, 0, 0)
+    )
+    assert rocket.power_on_drag(0.5) == pytest.approx(
+        rocket.power_on_drag_7d(0, 0, 0.5, 0, 0, 0, 0)
+    )
+
+    # Raw user input is preserved for serialization / round-tripping.
+    assert rocket._power_off_drag_input == off_input
+    assert rocket._power_on_drag_input == on_input
+
+
+def test_evaluate_reduced_mass_without_motor(calisto_motorless):
+    """Test evaluate_reduced_mass returns False when there is no motor
+    associated with the rocket (self.motor is None).
+    """
+    # Arrange
+    rocket = calisto_motorless
+    rocket.motor = None
+
+    # Act
+    result = rocket.evaluate_reduced_mass()
+
+    # Assert
+    assert result is False
+
+
+def test_evaluate_reduced_mass_empty_motor(calisto_motorless):
+    """Test evaluate_reduced_mass returns a Function evaluating to 0 when
+    the motor is an EmptyMotor.
+    """
+    # Arrange
+    rocket = calisto_motorless
+
+    # Act
+    reduced_mass = rocket.evaluate_reduced_mass()
+
+    # Assert
+    assert isinstance(reduced_mass, Function)
+    assert reduced_mass(0) == pytest.approx(0)
+
+
+def test_evaluate_reduced_mass_with_motor(calisto):
+    """Test evaluate_reduced_mass returns the correct Function when a motor
+    is associated with the rocket.
+    """
+    # Arrange
+    rocket = calisto
+    dry_mass = rocket.dry_mass
+    prop_mass = rocket.motor.propellant_mass
+
+    # Act
+    reduced_mass = rocket.evaluate_reduced_mass()
+
+    # Assert
+    assert isinstance(reduced_mass, Function)
+    for t in [0.0, 1.0, 2.0, 3.0]:
+        expected = prop_mass(t) * dry_mass / (prop_mass(t) + dry_mass)
+        assert reduced_mass(t) == pytest.approx(expected)
diff --git a/tests/unit/sensors/test_sensor_seeding.py b/tests/unit/sensors/test_sensor_seeding.py
index 21be6e299..d8474d317 100644
--- a/tests/unit/sensors/test_sensor_seeding.py
+++ b/tests/unit/sensors/test_sensor_seeding.py
@@ -1,27 +1,31 @@
 """Determinism tests for seeded sensor noise (additive, isolated).
 
-Sensor noise is drawn from a per-instance ``numpy.random.Generator`` seeded
-deterministically, instead of the process-global ``numpy.random``. This makes a
-seed-reproducible run (e.g. a judged competition) yield the identical sensor
-noise for a given seed, regardless of the global RNG state or parallel/forked
-execution -- mirroring the seeding the Monte Carlo layer already uses
-(``stochastic_model.py`` / ``monte_carlo.py``).
-
-The tests construct sensors directly and do not touch the existing fixtures or
-inherited tests. Sensor noise is sampled on a fixed time grid (not per adaptive
-solver step), so a fixed number of sequential draws is a faithful stand-in for a
-flight of a given duration.
+Sensor measurement noise is drawn from a per-instance ``numpy.random.Generator``
+created from the new ``seed`` argument, instead of the process-global
+``numpy.random``. A seed makes the noise reproducible for a given input and keeps
+it independent of the global RNG state, so it stays deterministic under parallel
+or forked execution. This addresses #1042.
+
+The tests build sensors directly and do not touch the existing fixtures or the
+inherited tests. Noise is sampled on a fixed grid, so a fixed number of
+sequential draws is a faithful stand-in for a run of a given length.
 """
 
+import json
+from types import SimpleNamespace
+
 import numpy as np
 
+from rocketpy._encoders import RocketPyEncoder
 from rocketpy.mathutils.vector_matrix import Vector
 from rocketpy.sensors.accelerometer import Accelerometer
+from rocketpy.sensors.barometer import Barometer
 from rocketpy.sensors.gnss_receiver import GnssReceiver
+from rocketpy.sensors.gyroscope import Gyroscope
 
 
 def _accelerometer(seed):
-    # Non-zero white noise + random walk so the draws actually exercise the RNG.
+    # Non-zero white noise and random walk so the draws actually exercise the RNG.
     return Accelerometer(
         sampling_rate=10,
         noise_density=1.0,
@@ -45,7 +49,7 @@ def test_different_seeds_decorrelate():
 
 
 def test_noise_independent_of_global_numpy_rng():
-    # Perturbing the process-global RNG must NOT change a seeded sensor's noise.
+    # Perturbing the process-global RNG must not change a seeded sensor's noise.
     # This is the regression guard for the original bug (noise drawn from the
     # global ``np.random``).
     np.random.seed(0)
@@ -58,7 +62,7 @@ def test_noise_independent_of_global_numpy_rng():
 
 def test_seeded_sensor_does_not_consume_global_rng():
     # A seeded sensor draws only from its own generator, leaving the global RNG
-    # position untouched -- so it cannot contaminate other code or parallel envs.
+    # position untouched, so it cannot contaminate other code or a forked worker.
     np.random.seed(0)
     position_before = np.random.get_state()[2]
     _noise_sequence(_accelerometer(7))
@@ -66,34 +70,72 @@ def test_seeded_sensor_does_not_consume_global_rng():
     assert position_before == position_after
 
 
-def test_recreating_generator_from_seed_reproduces_sequence():
-    # Reproducibility comes from the seed: re-creating the generator from the
-    # same seed (which is what a freshly-constructed sensor does) replays the
-    # same sequence. The random walk is cumulative, so zeroing it matters too.
-    sensor = _accelerometer(99)
-    first = _noise_sequence(sensor)
-    sensor._rng = np.random.default_rng(sensor._seed)
-    sensor._random_walk_drift = Vector([0.0, 0.0, 0.0])
-    assert _noise_sequence(sensor) == first
-
-
-def _gnss_sequence(seed, n=8):
+def _gnss_measurements(seed, n=8):
     gnss = GnssReceiver(
         sampling_rate=1,
         position_accuracy=5.0,
         altitude_accuracy=5.0,
-        velocity_accuracy=1.0,
         seed=seed,
     )
-    # Minimal launch-frame state: 100 m up, identity attitude quaternion.
+    # Minimal launch-frame state: 100 m up with an identity attitude quaternion.
     state = np.array([0, 0, 100, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], dtype=float)
+    environment = SimpleNamespace(latitude=0.0, longitude=0.0, earth_radius=6.371e6)
     measurements = []
     for _ in range(n):
-        gnss.measure(0.0, u=state, relative_position=Vector([0.0, 0.0, 0.0]))
+        gnss.measure(
+            0.0,
+            u=state,
+            relative_position=Vector([0.0, 0.0, 0.0]),
+            environment=environment,
+        )
         measurements.append(gnss.measurement)
     return measurements
 
 
-def test_gnss_is_seeded_and_reproducible():
-    assert _gnss_sequence(5) == _gnss_sequence(5)
-    assert _gnss_sequence(5) != _gnss_sequence(6)
+def test_gnss_noise_is_seeded_and_reproducible():
+    assert _gnss_measurements(5) == _gnss_measurements(5)
+    assert _gnss_measurements(5) != _gnss_measurements(6)
+
+
+def test_seed_survives_serialization_round_trip():
+    """to_dict exposes the seed and from_dict restores it, across all sensor types.
+
+    Sensors serialize through the JSON encoder, which turns the inertial sensors'
+    Vector fields into lists, so this exercises the round trip the same way the
+    library actually saves and loads them.
+    """
+    cases = [
+        (
+            Accelerometer(
+                sampling_rate=10, noise_density=1.0, noise_variance=1.0, seed=11
+            ),
+            11,
+        ),
+        (
+            Gyroscope(sampling_rate=10, noise_density=1.0, noise_variance=1.0, seed=22),
+            22,
+        ),
+        (
+            Barometer(sampling_rate=10, noise_density=1.0, noise_variance=1.0, seed=33),
+            33,
+        ),
+        (
+            GnssReceiver(
+                sampling_rate=1, position_accuracy=5.0, altitude_accuracy=5.0, seed=44
+            ),
+            44,
+        ),
+    ]
+    for sensor, seed in cases:
+        assert sensor.to_dict()["seed"] == seed
+        data = json.loads(json.dumps(sensor.to_dict(), cls=RocketPyEncoder))
+        assert type(sensor).from_dict(data).to_dict()["seed"] == seed
+
+
+def test_from_dict_defaults_seed_to_none_when_absent():
+    """Dicts serialized before this change (no seed key) still load, seed None."""
+    data = GnssReceiver(
+        sampling_rate=1, position_accuracy=5.0, altitude_accuracy=5.0, seed=44
+    ).to_dict()
+    del data["seed"]
+    assert GnssReceiver.from_dict(data).to_dict()["seed"] is None
diff --git a/tests/unit/sensors/test_sensor_validation.py b/tests/unit/sensors/test_sensor_validation.py
new file mode 100644
index 000000000..1187a42c0
--- /dev/null
+++ b/tests/unit/sensors/test_sensor_validation.py
@@ -0,0 +1,62 @@
+"""Validation and dunder coverage for the sensor base classes.
+
+These exercise the argument-validation error paths and the small ``__repr__`` /
+``__call__`` helpers on ``Sensor`` / ``InertialSensor`` that the noise-focused
+tests never reach, so the base class is fully covered.
+"""
+
+import pytest
+
+from rocketpy.mathutils.vector_matrix import Vector
+from rocketpy.sensors.accelerometer import Accelerometer
+from rocketpy.sensors.barometer import Barometer
+
+
+def test_measurement_range_wrong_length_raises():
+    with pytest.raises(ValueError, match="measurement range"):
+        Accelerometer(sampling_rate=1, measurement_range=(1, 2, 3))
+
+
+def test_measurement_range_wrong_type_raises():
+    with pytest.raises(ValueError, match="measurement range"):
+        Accelerometer(sampling_rate=1, measurement_range="not-a-range")
+
+
+def test_orientation_matrix_is_accepted():
+    accel = Accelerometer(
+        sampling_rate=1, orientation=[[1, 0, 0], [0, 1, 0], [0, 0, 1]]
+    )
+    assert accel.rotation_sensor_to_body is not None
+
+
+def test_orientation_wrong_length_raises():
+    with pytest.raises(ValueError, match="orientation"):
+        Accelerometer(sampling_rate=1, orientation=(1, 2))
+
+
+def test_vectorize_input_wrong_type_raises():
+    with pytest.raises(ValueError, match="noise_density"):
+        Accelerometer(sampling_rate=1, noise_density="not-a-vector")
+
+
+def test_repr_returns_name():
+    assert repr(Barometer(sampling_rate=1, name="baro")) == "baro"
+
+
+def test_export_measured_data_rejects_bad_format(tmp_path):
+    with pytest.raises(ValueError, match="file_format"):
+        Barometer(sampling_rate=1).export_measured_data(
+            str(tmp_path / "out"), file_format="xml"
+        )
+
+
+def test_call_dispatches_to_measure(example_plain_env):
+    """Calling a sensor forwards to ``measure`` and records one sample."""
+    barometer = Barometer(sampling_rate=1)
+    barometer(
+        3.3,
+        u=[0, 0, 1000, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
+        relative_position=Vector([0, 0, 0]),
+        environment=example_plain_env,
+    )
+    assert len(barometer.measured_data) == 1
diff --git a/tests/unit/simulation/test_flight.py b/tests/unit/simulation/test_flight.py
index cd840811d..d94856613 100644
--- a/tests/unit/simulation/test_flight.py
+++ b/tests/unit/simulation/test_flight.py
@@ -8,6 +8,7 @@
 from scipy import optimize
 
 from rocketpy import Components, Flight, Function, Rocket
+from rocketpy.simulation import FlightDataExporter
 
 plt.rcParams.update({"figure.max_open_warning": 0})
 
@@ -174,7 +175,9 @@ def test_export_sensor_data(flight_calisto_with_sensors):
     flight_calisto_with_sensors : Flight
         Pytest fixture for the flight of the calisto rocket with an ideal accelerometer and a gyroscope.
     """
-    flight_calisto_with_sensors.export_sensor_data("test_sensor_data.json")
+    FlightDataExporter(flight_calisto_with_sensors).export_sensor_data(
+        "test_sensor_data.json"
+    )
     # read the json and parse as dict
     filename = "test_sensor_data.json"
     with open(filename, "r") as f:
diff --git a/tests/unit/simulation/test_flight_time_nodes.py b/tests/unit/simulation/test_flight_time_nodes.py
index 20769b1f8..2c330d30a 100644
--- a/tests/unit/simulation/test_flight_time_nodes.py
+++ b/tests/unit/simulation/test_flight_time_nodes.py
@@ -2,7 +2,7 @@
 TimeNode.
 """
 
-# from rocketpy.rocket import Parachute, _Controller
+from rocketpy.control.controller import _Controller
 
 
 def test_time_nodes_init(flight_calisto):
@@ -49,6 +49,32 @@ def test_time_nodes_add_node(flight_calisto):
 # TODO: implement this test
 
 
+def test_time_nodes_add_controllers_skips_continuous_controllers(flight_calisto):
+    """Ensure only discrete controllers create time nodes."""
+    # Arrange
+    discrete_controller = _Controller(
+        interactive_objects=[],
+        controller_function=lambda t, sr, sv, sh, ov, io: None,
+        sampling_rate=10,
+        name="Discrete",
+    )
+    continuous_controller = _Controller(
+        interactive_objects=[],
+        controller_function=lambda t, sr, sv, sh, ov, io: None,
+        sampling_rate=None,
+        name="Continuous",
+    )
+    time_nodes = flight_calisto.TimeNodes()
+
+    # Act
+    time_nodes.add_controllers([discrete_controller, continuous_controller], 0, 1)
+
+    # Assert
+    assert len(time_nodes) == 11
+    assert all(node._controllers == [discrete_controller] for node in time_nodes)
+    assert all(continuous_controller not in node._controllers for node in time_nodes)
+
+
 def test_time_nodes_sort(flight_calisto):
     time_nodes = flight_calisto.TimeNodes()
     time_nodes.add_node(3.0, [], [], [])
diff --git a/tests/unit/simulation/test_monte_carlo.py b/tests/unit/simulation/test_monte_carlo.py
index 5595e46bb..7e2e68804 100644
--- a/tests/unit/simulation/test_monte_carlo.py
+++ b/tests/unit/simulation/test_monte_carlo.py
@@ -1,3 +1,8 @@
+import csv
+import json
+import pathlib
+from collections import namedtuple
+
 import matplotlib as plt
 import numpy as np
 import pytest
@@ -185,3 +190,326 @@ def test_estimate_confidence_interval_raises_type_error_for_invalid_statistic():
 
     with pytest.raises(TypeError):
         mc.estimate_confidence_interval("apogee", statistic="not_a_function")
+
+
+@pytest.mark.parametrize(
+    "kwargs, match",
+    [
+        ({"batch_size": 0}, "batch_size"),
+        ({"batch_size": -5}, "batch_size"),
+        ({"max_simulations": 0}, "max_simulations"),
+        ({"tolerance": 0}, "tolerance"),
+        ({"tolerance": -1.0}, "tolerance"),
+        ({"target_confidence": 1.5}, "target_confidence"),
+        ({"target_confidence": 0}, "target_confidence"),
+    ],
+)
+def test_simulate_convergence_validates_inputs(kwargs, match):
+    """simulate_convergence must reject invalid inputs up front. In particular a
+    non-positive batch_size would otherwise make the loop run zero simulations
+    per iteration and spin forever."""
+    mc = MockMonteCarlo()
+    with pytest.raises(ValueError, match=match):
+        mc.simulate_convergence(**kwargs)
+
+
+# --- CSV and JSON export/import tests ---
+
+
+class MockMonteCarloWithLogs(MonteCarlo):
+    """Mock class with populated logs for testing export/import methods."""
+
+    def __init__(self):
+        # pylint: disable=super-init-not-called
+        self.outputs_log = [
+            {"apogee": 5742.42, "x_impact": 553.49, "index": 0},
+            {"apogee": 3844.41, "x_impact": 402.31, "index": 1},
+            {"apogee": 4500.00, "x_impact": 480.10, "index": 2},
+        ]
+        self.inputs_log = [
+            {
+                "elevation": 1413.6,
+                "radius": 0.0635,
+                "parachutes": [{"cd_s": 9.84}],
+                "index": 0,
+            },
+            {
+                "elevation": 1400.0,
+                "radius": 0.0640,
+                "parachutes": [{"cd_s": 10.0}],
+                "index": 1,
+            },
+            {
+                "elevation": 1420.0,
+                "radius": 0.0630,
+                "parachutes": [{"cd_s": 9.50}],
+                "index": 2,
+            },
+        ]
+        self.errors_log = []
+        self.results = {}
+        self.processed_results = {}
+        self.num_of_loaded_sims = 3
+
+
+def test_export_outputs_to_csv(tmp_path):
+    """Tests that outputs are correctly exported to CSV."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.csv"
+
+    mc.export_outputs_to_csv(str(filepath))
+
+    with open(filepath, encoding="utf-8") as f:
+        reader = csv.DictReader(f)
+        rows = list(reader)
+
+    assert len(rows) == 3
+    assert float(rows[0]["apogee"]) == pytest.approx(5742.42)
+    assert float(rows[1]["x_impact"]) == pytest.approx(402.31)
+
+
+def test_export_outputs_to_json(tmp_path):
+    """Tests that outputs are correctly exported to JSON."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.json"
+
+    mc.export_outputs_to_json(str(filepath))
+
+    with open(filepath, encoding="utf-8") as f:
+        data = json.load(f)
+
+    assert len(data) == 3
+    assert data[0]["apogee"] == pytest.approx(5742.42)
+    assert data[2]["index"] == 2
+
+
+def test_export_inputs_to_csv_no_flatten(tmp_path):
+    """Tests that inputs with nested values are serialized as JSON in CSV cells."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "inputs.csv"
+
+    mc.export_inputs_to_csv(str(filepath), flatten=False)
+
+    with open(filepath, encoding="utf-8") as f:
+        reader = csv.DictReader(f)
+        rows = list(reader)
+
+    assert len(rows) == 3
+    # The parachutes column should contain a JSON string
+    parachutes_val = json.loads(rows[0]["parachutes"])
+    assert parachutes_val == [{"cd_s": 9.84}]
+
+
+def test_export_inputs_to_csv_flatten(tmp_path):
+    """Tests that flatten=True omits non-scalar columns."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "inputs.csv"
+
+    mc.export_inputs_to_csv(str(filepath), flatten=True)
+
+    with open(filepath, encoding="utf-8") as f:
+        reader = csv.DictReader(f)
+        rows = list(reader)
+
+    assert "parachutes" not in rows[0]
+    assert "elevation" in rows[0]
+    assert "radius" in rows[0]
+
+
+def test_export_inputs_to_json(tmp_path):
+    """Tests that inputs are correctly exported to JSON."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "inputs.json"
+
+    mc.export_inputs_to_json(str(filepath))
+
+    with open(filepath, encoding="utf-8") as f:
+        data = json.load(f)
+
+    assert len(data) == 3
+    assert data[0]["parachutes"] == [{"cd_s": 9.84}]
+
+
+def test_export_empty_log_raises_error(tmp_path):
+    """Tests that exporting an empty log raises ValueError."""
+    mc = MockMonteCarloWithLogs()
+    mc.outputs_log = []
+
+    with pytest.raises(ValueError, match="No data to export"):
+        mc.export_outputs_to_csv(str(tmp_path / "empty.csv"))
+
+    with pytest.raises(ValueError, match="No data to export"):
+        mc.export_outputs_to_json(str(tmp_path / "empty.json"))
+
+
+def test_import_outputs_from_csv(tmp_path):
+    """Tests that outputs can be imported from a CSV file."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.csv"
+
+    # Export first
+    mc.export_outputs_to_csv(str(filepath))
+
+    # Create a fresh mock and import
+    mc2 = MockMonteCarloWithLogs()
+    mc2.output_file = str(filepath)
+
+    assert len(mc2.outputs_log) == 3
+    assert mc2.outputs_log[0]["apogee"] == pytest.approx(5742.42)
+    assert mc2.outputs_log[1]["x_impact"] == pytest.approx(402.31)
+
+
+def test_import_outputs_from_json(tmp_path):
+    """Tests that outputs can be imported from a JSON file."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.json"
+
+    # Export first
+    mc.export_outputs_to_json(str(filepath))
+
+    # Create a fresh mock and import
+    mc2 = MockMonteCarloWithLogs()
+    mc2.output_file = str(filepath)
+
+    assert len(mc2.outputs_log) == 3
+    assert mc2.outputs_log[0]["apogee"] == pytest.approx(5742.42)
+
+
+def test_round_trip_outputs_csv(tmp_path):
+    """Tests that outputs survive a CSV export/import round trip."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.csv"
+
+    mc.export_outputs_to_csv(str(filepath))
+    mc.output_file = str(filepath)
+
+    for i, original in enumerate(MockMonteCarloWithLogs().outputs_log):
+        for key, value in original.items():
+            assert mc.outputs_log[i][key] == pytest.approx(value)
+
+
+def test_round_trip_outputs_json(tmp_path):
+    """Tests that outputs survive a JSON export/import round trip."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.json"
+
+    mc.export_outputs_to_json(str(filepath))
+    mc.output_file = str(filepath)
+
+    for i, original in enumerate(MockMonteCarloWithLogs().outputs_log):
+        for key, value in original.items():
+            assert mc.outputs_log[i][key] == pytest.approx(value)
+
+
+def test_round_trip_inputs_csv(tmp_path):
+    """Tests that inputs with nested values survive a CSV round trip."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "inputs.csv"
+
+    mc.export_inputs_to_csv(str(filepath), flatten=False)
+    mc.input_file = str(filepath)
+
+    assert mc.inputs_log[0]["parachutes"] == [{"cd_s": 9.84}]
+    assert mc.inputs_log[0]["elevation"] == pytest.approx(1413.6)
+
+
+def test_detect_file_format_unsupported():
+    """Tests that unsupported file extensions raise ValueError."""
+    mc = MockMonteCarloWithLogs()
+
+    with pytest.raises(ValueError, match="Unsupported file extension"):
+        mc._detect_file_format("data.xlsx")
+
+    with pytest.raises(ValueError, match="Unsupported file extension"):
+        mc._detect_file_format("data.parquet")
+
+
+def test_set_num_of_loaded_sims_csv(tmp_path):
+    """Tests that set_num_of_loaded_sims works with CSV files."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.csv"
+
+    mc.export_outputs_to_csv(str(filepath))
+    mc._output_file = str(filepath)
+    mc.set_num_of_loaded_sims()
+
+    assert mc.num_of_loaded_sims == 3
+
+
+def test_set_num_of_loaded_sims_json(tmp_path):
+    """Tests that set_num_of_loaded_sims works with JSON files."""
+    mc = MockMonteCarloWithLogs()
+    filepath = tmp_path / "outputs.json"
+
+    mc.export_outputs_to_json(str(filepath))
+    mc._output_file = str(filepath)
+    mc.set_num_of_loaded_sims()
+
+    assert mc.num_of_loaded_sims == 3
+
+
+# --- Adaptive Monte Carlo convergence (PR #922) ---
+
+_CI = namedtuple("_CI", ["low", "high"])
+
+
+class ConvergenceMockMonteCarlo(MonteCarlo):
+    """Mock that fakes batch simulation and a scripted confidence-interval
+    width, so ``simulate_convergence``'s stopping decision can be unit-tested
+    without running any real flight simulation."""
+
+    def __init__(self, width_model):
+        # pylint: disable=super-init-not-called
+        self.num_of_loaded_sims = 0
+        self.filename = pathlib.Path("dummy_mc")
+        self._width_model = width_model
+        self.simulate_calls = 0
+
+    def import_outputs(self, *args, **kwargs):  # no-op, avoids file I/O
+        pass
+
+    def simulate(self, number_of_simulations, append=True, **kwargs):
+        # pylint: disable=arguments-differ
+        self.simulate_calls += 1
+        self.num_of_loaded_sims = number_of_simulations
+
+    def estimate_confidence_interval(self, attribute, confidence_level=0.95, **kwargs):
+        # pylint: disable=arguments-differ,unused-argument
+        width = self._width_model(self.num_of_loaded_sims)
+        return _CI(low=0.0, high=width)
+
+
+def test_simulate_convergence_stops_early_when_tolerance_met():
+    """The convergence loop must stop as soon as the CI width drops below the
+    tolerance, well before reaching max_simulations."""
+    # width = 40 / n  ->  50 sims: 0.8 (> 0.5),  100 sims: 0.4 (<= 0.5) -> stop
+    mc = ConvergenceMockMonteCarlo(width_model=lambda n: 40.0 / n)
+
+    history = mc.simulate_convergence(
+        target_attribute="apogee_time",
+        tolerance=0.5,
+        max_simulations=1000,
+        batch_size=50,
+    )
+
+    assert history[-1] <= 0.5
+    assert len(history) == 2  # stopped after the second batch
+    assert mc.num_of_loaded_sims == 100
+    assert mc.num_of_loaded_sims < 1000  # did not exhaust the simulation budget
+
+
+def test_simulate_convergence_runs_until_max_when_not_converging():
+    """When the CI width never drops below the tolerance, the loop must run
+    until max_simulations and never exceed it."""
+    mc = ConvergenceMockMonteCarlo(width_model=lambda n: 10.0)  # constant, > tol
+
+    history = mc.simulate_convergence(
+        target_attribute="apogee_time",
+        tolerance=0.5,
+        max_simulations=200,
+        batch_size=50,
+    )
+
+    assert mc.num_of_loaded_sims == 200
+    assert all(width > 0.5 for width in history)
+    assert len(history) == 4  # 200 / 50 batches
diff --git a/tests/unit/simulation/test_multivariate_rejection_sampler.py b/tests/unit/simulation/test_multivariate_rejection_sampler.py
index af58b8fef..cf1851f07 100644
--- a/tests/unit/simulation/test_multivariate_rejection_sampler.py
+++ b/tests/unit/simulation/test_multivariate_rejection_sampler.py
@@ -8,7 +8,6 @@
 from rocketpy._encoders import RocketPyEncoder
 
 
-# pylint: disable=too-many-statements
 def test_mrs_initialization():
     """Tests if the MultivariateRejectionSampler initialization opens input and output
     files correctly, and if it raises errors correctly when the files are problematic.
diff --git a/tests/unit/stochastic/test_stochastic_model.py b/tests/unit/stochastic/test_stochastic_model.py
index 0d0a13311..9e35a5330 100644
--- a/tests/unit/stochastic/test_stochastic_model.py
+++ b/tests/unit/stochastic/test_stochastic_model.py
@@ -13,9 +13,11 @@
     ],
 )
 def test_visualize_attributes(request, fixture_name):
-    """Tests the visualize_attributes method of the StochasticModel class. This
-    test verifies if the method returns None, which means that the method is
-    running without breaking.
+    """Tests the visualize_attributes method of the StochasticModel class. It
+    must run without breaking and return the formatted report string (which is
+    also printed), so the report is never silently lost.
     """
     fixture = request.getfixturevalue(fixture_name)
-    assert fixture.visualize_attributes() is None
+    report = fixture.visualize_attributes()
+    assert isinstance(report, str)
+    assert report
diff --git a/tests/unit/test_flight_export_deprecation.py b/tests/unit/test_flight_export_deprecation.py
deleted file mode 100644
index 6fb6952b4..000000000
--- a/tests/unit/test_flight_export_deprecation.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from unittest.mock import patch
-
-import pytest
-
-# TODO: these tests should be deleted after the deprecated methods are removed
-
-
-def test_export_data_deprecated_emits_warning_and_delegates(flight_calisto, tmp_path):
-    """Expect: calling Flight.export_data emits DeprecationWarning and delegates to FlightDataExporter.export_data."""
-    out = tmp_path / "out.csv"
-    with patch(
-        "rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_data"
-    ) as spy:
-        with pytest.warns(DeprecationWarning):
-            flight_calisto.export_data(str(out))
-    spy.assert_called_once()
-
-
-def test_export_pressures_deprecated_emits_warning_and_delegates(
-    flight_calisto_robust, tmp_path
-):
-    """Expect: calling Flight.export_pressures emits DeprecationWarning and delegates to FlightDataExporter.export_pressures."""
-    out = tmp_path / "p.csv"
-    with patch(
-        "rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_pressures"
-    ) as spy:
-        with pytest.warns(DeprecationWarning):
-            flight_calisto_robust.export_pressures(str(out), time_step=0.1)
-    spy.assert_called_once()
-
-
-def test_export_kml_deprecated_emits_warning_and_delegates(flight_calisto, tmp_path):
-    """Expect: calling Flight.export_kml emits DeprecationWarning and delegates to FlightDataExporter.export_kml."""
-    out = tmp_path / "traj.kml"
-    with patch(
-        "rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_kml"
-    ) as spy:
-        with pytest.warns(DeprecationWarning):
-            flight_calisto.export_kml(str(out), time_step=0.5)
-    spy.assert_called_once()
diff --git a/tests/unit/test_parachute_triggers.py b/tests/unit/test_parachute_triggers.py
new file mode 100644
index 000000000..907b16268
--- /dev/null
+++ b/tests/unit/test_parachute_triggers.py
@@ -0,0 +1,110 @@
+import numpy as np
+
+from rocketpy.rocket.parachute import Parachute
+from rocketpy.simulation.flight import Flight
+
+
+def test_trigger_receives_u_dot():
+    def derivative_func(_t, _y):
+        return np.array([0, 0, 0, 1.0, 2.0, 3.0, 0, 0, 0, 0, 0, 0, 0])
+
+    recorded = {}
+
+    def user_trigger(_p, _h, _y, u_dot):
+        recorded["u_dot"] = np.array(u_dot)
+        return True
+
+    parachute = Parachute(
+        name="test",
+        cd_s=1.0,
+        trigger=user_trigger,
+        sampling_rate=100,
+    )
+
+    dummy = type("D", (), {})()
+
+    res = Flight._evaluate_parachute_trigger(
+        dummy,
+        parachute,
+        pressure=0.0,
+        height=10.0,
+        y=np.zeros(13),
+        sensors=[],
+        derivative_func=derivative_func,
+        t=0.0,
+    )
+
+    assert res is True
+    assert "u_dot" in recorded
+    assert np.allclose(recorded["u_dot"][3:6], np.array([1.0, 2.0, 3.0]))
+
+
+def test_trigger_with_u_dot_only():
+    """Test trigger that only expects u_dot (no sensors)."""
+
+    def derivative_func(_t, _y):
+        return np.array([0, 0, 0, -1.0, -2.0, -3.0, 0, 0, 0, 0, 0, 0, 0])
+
+    recorded = {}
+
+    def user_trigger(_p, _h, _y, u_dot):
+        recorded["u_dot"] = np.array(u_dot)
+        return False
+
+    parachute = Parachute(
+        name="test_u_dot_only",
+        cd_s=1.0,
+        trigger=user_trigger,
+        sampling_rate=100,
+    )
+
+    dummy = type("D", (), {})()
+
+    res = Flight._evaluate_parachute_trigger(
+        dummy,
+        parachute,
+        pressure=0.0,
+        height=5.0,
+        y=np.zeros(13),
+        sensors=[],
+        derivative_func=derivative_func,
+        t=1.234,
+    )
+
+    assert res is False
+    assert "u_dot" in recorded
+    assert np.allclose(recorded["u_dot"][3:6], np.array([-1.0, -2.0, -3.0]))
+
+
+def test_basic_trigger_does_not_compute_u_dot():
+    def derivative_func(_t, _y):
+        raise RuntimeError("derivative should not be called for legacy triggers")
+
+    called = {}
+
+    def basic_trigger(_p, _h, _y):
+        called["ok"] = True
+        return True
+
+    parachute = Parachute(
+        name="basic",
+        cd_s=1.0,
+        trigger=basic_trigger,
+        sampling_rate=100,
+    )
+
+    dummy = type("D", (), {})()
+
+    res = Flight._evaluate_parachute_trigger(
+        dummy,
+        parachute,
+        pressure=0.0,
+        height=0.0,
+        y=np.zeros(13),
+        sensors=[],
+        derivative_func=derivative_func,
+        t=0.0,
+    )
+
+    assert res is True
+    assert called.get("ok", False) is True
diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py
index fc412d3b9..e12690d99 100644
--- a/tests/unit/test_plots.py
+++ b/tests/unit/test_plots.py
@@ -143,9 +143,18 @@ def update(frame):
         show_or_save_animation(animation, "test.mp4")
 
 
-def test_animate_propellant_mass(cesaroni_m1670):
+def test_animate_propellant_mass(cesaroni_m1670, monkeypatch):
     """Test that animate_propellant_mass saves a .gif file correctly."""
 
+    def mock_show_or_save(animation, filename=None, fps=30):  # pylint: disable=unused-argument
+        if filename:
+            with open(filename, "a"):
+                pass
+
+    monkeypatch.setattr(
+        "rocketpy.plots.motor_plots.show_or_save_animation", mock_show_or_save
+    )
+
     motor = cesaroni_m1670
     animation = motor.plots.animate_propellant_mass(filename="cesaroni_m1670.gif")
 
@@ -158,9 +167,18 @@ def test_animate_propellant_mass(cesaroni_m1670):
     os.remove("cesaroni_m1670.gif")
 
 
-def test_animate_fluid_volume(example_mass_flow_rate_based_tank_seblm):
+def test_animate_fluid_volume(example_mass_flow_rate_based_tank_seblm, monkeypatch):
     """Test that animate_fluid_volume saves a .gif file correctly."""
 
+    def mock_show_or_save(animation, filename=None, fps=30):  # pylint: disable=unused-argument
+        if filename:
+            with open(filename, "a"):
+                pass
+
+    monkeypatch.setattr(
+        "rocketpy.plots.tank_plots.show_or_save_animation", mock_show_or_save
+    )
+
     tank = example_mass_flow_rate_based_tank_seblm
     animation = tank.plots.animate_fluid_volume(filename="test_fluid_volume.gif")
 
diff --git a/tests/unit/test_rail_buttons_bending_moments.py b/tests/unit/test_rail_buttons_bending_moments.py
index b61720d3d..50336fdbc 100644
--- a/tests/unit/test_rail_buttons_bending_moments.py
+++ b/tests/unit/test_rail_buttons_bending_moments.py
@@ -4,6 +4,7 @@
 of the calculate_rail_button_bending_moments method in isolation.
 """
 
+import logging
 import warnings
 
 import matplotlib.pyplot as plt
@@ -199,24 +200,20 @@ def test_rail_button_bending_moments_plot_with_height(flight_calisto_robust):
     plt.close("all")
 
 
-def test_rail_button_bending_moments_plot_without_height(flight_calisto_robust, capsys):
+def test_rail_button_bending_moments_plot_without_height(flight_calisto_robust, caplog):
     """Test that bending moments plot is skipped when button_height is None.
 
     Parameters
     ----------
     flight_calisto_robust : rocketpy.Flight
         Flight fixture with motor and rail buttons.
-    capsys : pytest fixture
-        Captures stdout/stderr.
+    caplog : pytest fixture
+        Captures log records.
     """
     # Ensure button_height is None (it should be by default now)
     flight_calisto_robust.rocket.rail_buttons[0].component.button_height = None
 
-    # Call plot method
-    flight_calisto_robust.plots.rail_buttons_bending_moments()
-
-    # Capture output
-    captured = capsys.readouterr()
+    with caplog.at_level(logging.WARNING, logger="rocketpy"):
+        flight_calisto_robust.plots.rail_buttons_bending_moments()
 
-    # Should print skip message
-    assert "height not defined" in captured.out
+    assert "height not defined" in caplog.text
diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py
index fcf67ad37..ad79940e0 100644
--- a/tests/unit/test_tools.py
+++ b/tests/unit/test_tools.py
@@ -1,11 +1,13 @@
 import numpy as np
 import pytest
 
+from rocketpy import Environment
 from rocketpy.tools import (
     calculate_cubic_hermite_coefficients,
     euler313_to_quaternions,
     find_roots_cubic_function,
     haversine,
+    inverted_haversine,
     tuple_handler,
 )
 
@@ -100,3 +102,86 @@ def test_tuple_handler(input_value, expected_output):
 def test_tuple_handler_exceptions(input_value, expected_exception):
     with pytest.raises(expected_exception):
         tuple_handler(input_value)
+
+
+@pytest.mark.parametrize("pressure_conversion_factor", ["hPa", "mbar", "Pa", 100])
+def test_valid_pressure_conversion_factor(pressure_conversion_factor):
+    env = Environment(
+        gravity=9.81,
+        latitude=47.213476,
+        longitude=9.003336,
+        date=(2020, 2, 22, 13),
+        elevation=407,
+    )
+    env.set_atmospheric_model(
+        type="Reanalysis",
+        file="data/weather/bella_lui_weather_data_ERA5.nc",
+        dictionary="ECMWF",
+        pressure_conversion_factor=pressure_conversion_factor,
+    )
+
+
+@pytest.mark.parametrize("pressure_conversion_factor", [-1, "mPa"])
+def test_invalid_pressure_conversion_factor(pressure_conversion_factor):
+    env = Environment(
+        gravity=9.81,
+        latitude=47.213476,
+        longitude=9.003336,
+        date=(2020, 2, 22, 13),
+        elevation=407,
+    )
+
+    with pytest.raises(ValueError):
+        env.set_atmospheric_model(
+            type="Reanalysis",
+            file="data/weather/bella_lui_weather_data_ERA5.nc",
+            dictionary="ECMWF",
+            pressure_conversion_factor=pressure_conversion_factor,
+        )
+
+
+def test_inverted_haversine_scalar():
+    """Test inverted_haversine with scalar arguments matches haversine distance."""
+    # Arrange
+    lat0, lon0 = -23.508958, -46.720080
+    lat1, lon1 = -23.522939, -46.558253
+    earth_radius = 6378100.0
+    distance = haversine(lat0, lon0, lat1, lon1, earth_radius)
+    bearing = 90.0
+
+    # Act
+    lat_result, lon_result = inverted_haversine(
+        lat0, lon0, distance, bearing, earth_radius
+    )
+
+    # Assert
+    recalculated_distance = haversine(lat0, lon0, lat_result, lon_result, earth_radius)
+    assert recalculated_distance == pytest.approx(distance, abs=1e-2)
+
+
+def test_inverted_haversine_array():
+    """Test inverted_haversine with NumPy arrays returns correct array results."""
+    # Arrange
+    lat0, lon0 = -23.508958, -46.720080
+    distances = np.array([0.0, 5000.0, 16591.438])
+    bearings = np.array([0.0, 45.0, 90.0])
+    earth_radius = 6378100.0
+
+    # Act
+    lat_results, lon_results = inverted_haversine(
+        lat0, lon0, distances, bearings, earth_radius
+    )
+
+    # Assert
+    assert isinstance(lat_results, np.ndarray)
+    assert isinstance(lon_results, np.ndarray)
+    assert len(lat_results) == 3
+    assert len(lon_results) == 3
+
+    # Check scalar consistency for each element
+    for i, distance in enumerate(distances):
+        lat_scalar, lon_scalar = inverted_haversine(
+            lat0, lon0, distance, bearings[i], earth_radius
+        )
+        assert lat_results[i] == pytest.approx(lat_scalar)
+        assert lon_results[i] == pytest.approx(lon_scalar)
diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py
index ce85f01de..146ff1be1 100644
--- a/tests/unit/test_utilities.py
+++ b/tests/unit/test_utilities.py
@@ -1,3 +1,4 @@
+import logging
 import os
 from unittest.mock import patch
 
@@ -117,6 +118,19 @@ def test_fin_flutter_analysis(flight_calisto_custom_wind):
     assert np.isclose(safety_factor(np.inf), 61.669562809629035, atol=5e-3)
 
 
+def test_calculate_stall_wind_velocity_returns_value(flight_calisto_custom_wind):
+    """Regression: the stall wind velocity must be returned (it was previously
+    only logged at INFO level and the method returned None, losing the value).
+    The Flight method and the utilities function must agree."""
+    with pytest.warns(DeprecationWarning):
+        w_v = flight_calisto_custom_wind.calculate_stall_wind_velocity(5)
+    assert isinstance(w_v, float)
+    assert w_v > 0
+    assert utilities.calculate_stall_wind_velocity(
+        flight_calisto_custom_wind, 5
+    ) == pytest.approx(w_v)
+
+
 def test_fin_flutter_analysis_with_prints(flight_calisto_custom_wind):
     """Test fin_flutter_analysis with see_prints=True to cover print branch.
 
@@ -140,7 +154,7 @@ def test_fin_flutter_analysis_with_prints(flight_calisto_custom_wind):
 
 
 @patch("matplotlib.pyplot.show")
-def test_fin_flutter_analysis_with_graphs(mock_show, flight_calisto_custom_wind):  # pylint: disable=unused-argument
+def test_fin_flutter_analysis_with_graphs(mock_show, flight_calisto_custom_wind):
     """Test fin_flutter_analysis with see_graphs=True to cover plotting branch.
 
     Parameters
@@ -150,21 +164,22 @@ def test_fin_flutter_analysis_with_graphs(mock_show, flight_calisto_custom_wind)
     flight_calisto_custom_wind : Flight
         A Flight object with a rocket with fins.
     """
-    result = utilities.fin_flutter_analysis(
+    flutter_mach, safety_factor = utilities.fin_flutter_analysis(
         fin_thickness=2 / 1000,
         shear_modulus=10e9,
         flight=flight_calisto_custom_wind,
         see_prints=False,
-        see_graphs=True,  # True = returns None!
+        see_graphs=True,
         filename=None,
     )
 
-    assert result is None
+    assert isinstance(flutter_mach, Function)
+    assert isinstance(safety_factor, Function)
     mock_show.assert_called()
 
 
 @patch("matplotlib.pyplot.show")
-def test_fin_flutter_analysis_complete_output(mock_show, flight_calisto_custom_wind):  # pylint: disable=unused-argument
+def test_fin_flutter_analysis_complete_output(mock_show, flight_calisto_custom_wind):
     """Test fin_flutter_analysis with both prints and graphs enabled.
 
     Parameters
@@ -174,16 +189,19 @@ def test_fin_flutter_analysis_complete_output(mock_show, flight_calisto_custom_w
     flight_calisto_custom_wind : Flight
         A Flight object with a rocket with fins.
     """
-    result = utilities.fin_flutter_analysis(
+    flutter_mach, safety_factor = utilities.fin_flutter_analysis(
         fin_thickness=2 / 1000,
         shear_modulus=10e9,
         flight=flight_calisto_custom_wind,
         see_prints=True,
-        see_graphs=True,  # True = returns None!
+        see_graphs=True,
         filename=None,
     )
 
-    assert result is None
+    # The flutter Mach number and safety factor are always returned, regardless
+    # of see_prints/see_graphs, so the safety-critical results are never lost.
+    assert isinstance(flutter_mach, Function)
+    assert isinstance(safety_factor, Function)
     mock_show.assert_called()
 
 
@@ -199,7 +217,7 @@ def test_flutter_prints(flight_calisto_custom_wind):
     flutter_mach = Function("tests/fixtures/utilities/flutter_mach.txt")
     safety_factor = Function("tests/fixtures/utilities/flutter_safety_factor.txt")
     assert (
-        utilities._flutter_prints(  # pylint: disable=protected-access
+        utilities._flutter_prints(
             fin_thickness=2 / 1000,
             shear_modulus=10e9,
             surface_area=0.009899999999999999,
@@ -229,7 +247,7 @@ def test_flutter_plots(mock_show, flight_calisto_custom_wind):  # pylint: disabl
     flutter_mach = Function("tests/fixtures/utilities/flutter_mach.txt")
     safety_factor = Function("tests/fixtures/utilities/flutter_safety_factor.txt")
     assert (
-        utilities._flutter_plots(  # pylint: disable=protected-access
+        utilities._flutter_plots(
             flight_calisto_custom_wind, flutter_mach, safety_factor
         )
         is None
@@ -328,3 +346,79 @@ def test_load_from_rpy(mock_show):  # pylint: disable=unused-argument
     )
     assert loaded_flight.info() is None
     assert loaded_flight.all_info() is None
+
+
+# --- Logging (rocketpy.utilities.enable_logging) ------------------------------
+
+
+@pytest.fixture(autouse=True)
+def reset_rocketpy_logger():
+    """Reset the rocketpy logger to its original state after each test."""
+    logger = logging.getLogger("rocketpy")
+    original_level = logger.level
+    original_handlers = logger.handlers[:]
+    yield
+    logger.handlers = original_handlers
+    logger.setLevel(original_level)
+
+
+def test_enable_logging_adds_stream_handler():
+    """enable_logging() must attach a StreamHandler to the rocketpy logger."""
+    utilities.enable_logging(level="INFO")
+
+    logger = logging.getLogger("rocketpy")
+    stream_handlers = [
+        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
+    ]
+    assert len(stream_handlers) >= 1
+
+
+def test_enable_logging_sets_correct_level():
+    """enable_logging() must set the requested level on the rocketpy logger."""
+    utilities.enable_logging(level="DEBUG")
+    assert logging.getLogger("rocketpy").level == logging.DEBUG
+
+    utilities.enable_logging(level="WARNING")
+    assert logging.getLogger("rocketpy").level == logging.WARNING
+
+
+def test_enable_logging_no_duplicate_handlers():
+    """Calling enable_logging() twice must not duplicate StreamHandlers."""
+    utilities.enable_logging(level="INFO")
+    utilities.enable_logging(level="INFO")
+
+    logger = logging.getLogger("rocketpy")
+    stream_handlers = [
+        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
+    ]
+    assert len(stream_handlers) == 1
+
+
+def test_enable_logging_replaces_handler_on_level_change():
+    """Calling enable_logging() with a new level must replace the old handler."""
+    utilities.enable_logging(level="WARNING")
+    utilities.enable_logging(level="DEBUG")
+
+    logger = logging.getLogger("rocketpy")
+    stream_handlers = [
+        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
+    ]
+    assert len(stream_handlers) == 1
+    assert logger.level == logging.DEBUG
+
+
+def test_enable_logging_invalid_level_raises():
+    """enable_logging() must raise ValueError for an unrecognised level string."""
+    with pytest.raises(ValueError, match="Invalid logging level"):
+        utilities.enable_logging(level="INVALID")
+
+
+def test_enable_logging_messages_are_captured(caplog):
+    """After enable_logging(), internal rocketpy log messages must be visible."""
+    utilities.enable_logging(level="DEBUG")
+
+    with caplog.at_level(logging.DEBUG, logger="rocketpy"):
+        logger = logging.getLogger("rocketpy.simulation.flight")
+        logger.info("test message from flight")
+
+    assert "test message from flight" in caplog.text