pax_global_header00006660000000000000000000000064152442514430014516gustar00rootroot0000000000000052 comment=5c38985d0de5a81167e6e8d8a564874b73f39cb1 DataLab-Platform-SigimaX-0182bf4/000077500000000000000000000000001524425144300164575ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/.coveragerc000066400000000000000000000011051524425144300205750ustar00rootroot00000000000000[run] #--- Ignore this warning because the process isolation feature #--- causes coverage to report 0% coverage when no computation is performed #--- in the isolated process during the session. disable_warnings = no-data-collected parallel = True concurrency = multiprocessing,thread omit = */sigimax/tests/* */guidata/* #--- Workaround for certain builds of python-opencv package: ./config-3.py ./config.py #--- [report] exclude_also = def __repr__ if __name__ == .__main__.: if TYPE_CHECKING: if DEBUG[\s]*: if self.__debug[\s]*: DataLab-Platform-SigimaX-0182bf4/.env.template000066400000000000000000000005131524425144300210610ustar00rootroot00000000000000# Python interpreter (explicit path, e.g. WinPython). Auto-detected if empty. PYTHON= # WinPython base directory (legacy, prefer PYTHON instead) # WINPYDIRBASE= # Virtual environment directory (e.g. .venv39). Auto-discovered if empty. VENV_DIR= # Python path for development (sibling packages) PYTHONPATH=. # Locale (e.g. fr) LANG=DataLab-Platform-SigimaX-0182bf4/.github/000077500000000000000000000000001524425144300200175ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/.github/ISSUE_TEMPLATE/000077500000000000000000000000001524425144300222025ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012631524425144300246760ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Installation information** Describe how you installed SigimaX (e.g., via pip, conda, or from source) and provide the version of SigimaX you are using. **Additional context** Add any other context about the problem here. DataLab-Platform-SigimaX-0182bf4/.github/ISSUE_TEMPLATE/doc_request.md000066400000000000000000000011411524425144300250360ustar00rootroot00000000000000--- name: Documentation request about: Ask for documentation about a specific topic title: '' labels: documentation assignees: '' --- **Is your documentation request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the topic you would like to be covered** A clear and concise description of what you want to be documented. **Describe the nature of the documentation you would like to see** Tutorial, reference, etc. **Additional context** Add any other context or screenshots about the feature request here. DataLab-Platform-SigimaX-0182bf4/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011341524425144300257260ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. DataLab-Platform-SigimaX-0182bf4/.github/ISSUE_TEMPLATE/tutorial_request.md000066400000000000000000000014571524425144300261460ustar00rootroot00000000000000--- name: Tutorial request about: Ask for a tutorial on a specific topic title: '' labels: documentation assignees: '' --- **Describe the context and technical field of the tutorial you would like to see** A clear and concise description of your technical field, and of the specific application. **Describe the topic you would like to be covered** A clear and concise description of what you want to be documented. **Describe the features you would like to see in the tutorial** A clear and concise description of the features you would like to see in the tutorial. **Additional context** Add any other context or screenshots about the feature request here. _Please attach an example data set, if possible. And please confirm that you are willing to share this data set under the terms of DataLab's license._ DataLab-Platform-SigimaX-0182bf4/.github/copilot-instructions.md000066400000000000000000000300001524425144300245450ustar00rootroot00000000000000# SigimaX AI Coding Agent Instructions ## Project Overview **SigimaX** is a reusable GUI application framework extracted from DataLab. It provides the generic "application skeleton" that any scientific computing Qt application can build upon by subclassing its main window and configuration system. ### Position in the Stack ``` End-user apps (DataLab, custom scientific apps) ↓ subclass / configure SigimaX â†� THIS PROJECT (framework layer) ↓ depends on Sigima (computation) + PlotPy + guidata + PythonQwt ↓ NumPy / SciPy / Qt ``` ### Technology Stack - **Python**: 3.9+ (`from __future__ import annotations`) - **Core**: guidata (≥3.13.4), PlotPy (≥2.8.2), Sigima (≥1.1.0), psutil (≥5.7) - **GUI**: Qt via QtPy (PyQt5/PyQt6/PySide6) - **Testing**: pytest - **Linting**: Ruff (preferred), Pylint ### Architecture ``` sigimax/ ├── app.py # Application launcher (create / run) ├── config.py # Configuration system (SigimaXOptions, CONF singleton) ├── env.py # ExecEnv runtime environment (verbosity, unattended) ├── mainwindow.py # SGMXMainWindow (generic main window) ├── widgets/ # Reusable Qt widgets │ ├── plotdock.py # DockablePlotWidget │ ├── splashscreen.py # Configurable splash screen │ ├── h5browser.py # HDF5 file browser │ ├── logviewer.py # Log viewer dialog │ ├── status.py # Status bar widgets (memory, console) │ ├── warningerror.py # Warning/error message box │ ├── wizard.py # Multi-page wizard dialog │ ├── fitdialog.py # Curve fitting dialogs │ ├── filedialog.py # File dialog with multi-selection │ ├── fileviewer.py # Read-only file viewer │ ├── imagebackground.py # Image background selection │ ├── signalbaseline.py # Signal baseline selection │ ├── signalcursor.py # Signal cursor selection │ ├── signaldeltax.py # Signal delta-X measurement │ └── signalpeak.py # Signal peak detection ├── h5/ # HDF5 I/O (read/write/import) ├── adapters_plotpy/ # Converters between PlotPy/guidata and Sigima objects ├── utils/ # Qt helpers, config dir resolution ├── data/ # Icons, resources ├── locale/ # Translations (EN, FR) └── tests/ # pytest suite ``` ## Development Workflows ### Running Commands **ALWAYS use `scripts/run_with_env.py`** to load `.env` before running Python commands: ```powershell # ✅ CORRECT python scripts/run_with_env.py python -m pytest # â�Œ WRONG - Misses local PYTHONPATH python -m pytest ``` ### Testing ```powershell # Run all tests python scripts/run_with_env.py python -m pytest --ff # Run specific test python scripts/run_with_env.py python -m pytest sigimax/tests/derivated_app_test.py # Show Qt windows during tests (default is offscreen) python scripts/run_with_env.py python -m pytest --show-windows ``` **Pytest Configuration** (`conftest.py`): - `execenv.unattended = True` (no GUI interaction by default) - `set_validation_mode(ValidationMode.STRICT)` for tests - `QT_QPA_PLATFORM=offscreen` unless `--show-windows` is passed - Custom marker: `@pytest.mark.validation` ### Linting and Formatting ```powershell # Ruff (preferred) python scripts/run_with_env.py python -m ruff format python scripts/run_with_env.py python -m ruff check --fix # Pylint python scripts/run_with_env.py python -m pylint sigimax \ --disable=duplicate-code,fixme,too-many-arguments, \ too-many-branches,too-many-instance-attributes,too-many-lines, \ too-many-locals,too-many-public-methods,too-many-statements ``` ### Translations ```powershell # Scan and update .po files python scripts/run_with_env.py python -m guidata.utils.translations scan \ --name sigimax --directory . --copyright-holder "DataLab Platform Developers" \ --languages fr # Compile .mo files python scripts/run_with_env.py python -m guidata.utils.translations compile \ --name sigimax --directory . ``` ## Core Patterns ### 1. The Derivation Pattern (Subclassing) SigimaX is designed around **subclassing**. Derived applications follow three steps: **Step 1 — Subclass `SigimaXOptions`** for app-specific configuration: ```python from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField class MyAppOptions(SigimaXOptions): ENV_VAR = "MYAPP_OPTIONS_JSON" APP_NAME = "MyApp" def __init__(self): super().__init__() self.app_name.set("MyApp") self.greeting = TypedOptionField( self, "greeting", default="Hello!", expected_type=str, description="Startup message", ) self.unit_system = EnumOptionField( self, "unit_system", default="metric", choices=["metric", "imperial"], description="Default units", ) ``` **Step 2 — Subclass `SGMXMainWindow`** for custom UI: ```python from sigimax.mainwindow import SGMXMainWindow from sigimax.config import CONF as Conf class MyAppMainWindow(SGMXMainWindow): def __init__(self, console=None, hide_on_close=False): Conf.app_name.set("MyApp") super().__init__(console=console, hide_on_close=hide_on_close) self._add_custom_menus() self._add_custom_docks() ``` **Step 3 — Launch with `sigimax.app.run()`**: ```python from sigimax.app import run from sigimax.widgets.splashscreen import SplashScreenConfig run( window_class=MyAppMainWindow, splash_config=SplashScreenConfig( image_path="myapp/data/splash.png", app_name="MyApp", app_version="1.0.0", ), ) ``` ### 2. Configuration System The configuration system follows Sigima's `OptionField` pattern extended for GUI apps: ```python from sigimax.config import CONF as Conf # Get/set options colormap = Conf.ima_def_colormap.get() Conf.ima_def_colormap.set("gray") # Context manager for temporary overrides with Conf.fft_shift_enabled.context(False): # FFT shift disabled in this block ... # JSON persistence Conf.save() # Save to config file Conf.load() # Load from config file ``` **Custom option field types**: - `TypedOptionField` — type-checked (int, str, bool, float) - `EnumOptionField` — constrained to a set of choices - `TupleOptionField` — fixed-length tuples - `ImageIOOptionField` — image I/O settings (inherited from Sigima) ### 3. Widgets Package Common widgets are re-exported from `sigimax.widgets` for convenience: ```python # Tier 1 — direct import from package from sigimax.widgets import H5Browser, Wizard, LogViewerWindow, SplashScreenConfig # Tier 2 — specialized dialogs via submodule from sigimax.widgets.fitdialog import gaussian_fit from sigimax.widgets.signalpeak import SignalPeakDetectionDialog ``` ### 4. DockablePlotWidget Embeds PlotPy plots in dock widgets: ```python from sigimax.widgets.plotdock import DockablePlotWidget dock = DockablePlotWidget(self, plot_type=PlotType.CURVE, title="My Plot") self.addDockWidget(Qt.RightDockWidgetArea, dock) ``` ### 5. HDF5 Workspace The main window provides built-in HDF5 workspace management: - `open_h5_files()` — open HDF5 files - `save_to_h5_file()` — save workspace - `browse_h5_files()` — browse HDF5 files with `H5BrowserDialog` ### 6. Application Launcher ```python from sigimax.app import create, run # create() — instantiate window with splash, return it (for embedding) window = create(window_class=MyWindow, splash=True, console=True) # run() — create() + enter Qt event loop (for standalone apps) run(window_class=MyWindow, splash_config=config) ``` ## What SigimaX Provides vs What Stays in DataLab | **In SigimaX** | **Stays in DataLab** | |---|---| | Configuration system (`config.py`) | Signal/Image panels, processors | | Generic main window (`mainwindow.py`) | Action handler, plugin system | | Dockable plot widgets (`widgets/plotdock.py`) | Remote control (XML-RPC, Web API) | | HDF5 I/O + browser (`h5/`, `widgets/h5browser.py`) | Macro editor, new-object dialogs | | Scientific dialogs (fit, baseline, peak, cursor…) | DataLab-specific UI and processing | | Log viewer, status bar, splash screen, wizard | Object model, plot handler | | PlotPy adapters (`adapters_plotpy/`) | Processor registration pattern | | Environment/exec utilities (`env.py`) | Tour/tutorial features | ## Coding Conventions ### Type Annotations ```python from __future__ import annotations ``` Always use `from __future__ import annotations` for forward references. ### Qt Imports Use QtPy for Qt binding abstraction: ```python from qtpy.QtCore import Qt, QSize from qtpy.QtGui import QPen, QBrush, QColor from qtpy.QtWidgets import QWidget ``` ### Imports **Order**: Standard library → Third-party → SigimaX ```python from __future__ import annotations import os from typing import TYPE_CHECKING import numpy as np from guidata.qthelpers import create_action from sigimax.config import CONF as Conf from sigimax.config import _ from sigimax.mainwindow import SGMXMainWindow if TYPE_CHECKING: from sigima.objects import SignalObj ``` ### Module Exports **Always define `__all__`** in every module: ```python __all__ = [ "MyClass", "my_function", ] ``` ### Docstrings Google-style with Args/Returns: ```python def my_function(x: np.ndarray, param: int) -> np.ndarray: """One-line summary. Longer description if needed. Args: x: Input array description param: Parameter description, with long description that continues on next line. Returns: Output array description """ ``` For continued lines in enumerations (args, returns), indent subsequent lines by 1 space. ### Internationalization Wrap UI strings with `_()`: ```python from sigimax.config import _ menu_title = _("Processing") action_text = _("Open HDF5 files") ``` ### Naming - **Functions**: `snake_case` (e.g., `get_log_filenames`) - **Classes**: `PascalCase` (e.g., `SGMXMainWindow`) - **Constants**: `UPPER_SNAKE_CASE` (e.g., `MOD_NAME`, `DEBUG`) - **Private methods**: `_snake_case` or `__snake_case` ## Key Files Reference | File | Purpose | |------|---------| | `sigimax/__init__.py` | Package metadata (`__version__`, URLs) | | `sigimax/app.py` | Application launcher (`create()`, `run()`) | | `sigimax/config.py` | Configuration system (`SigimaXOptions`, `CONF`) | | `sigimax/env.py` | Runtime environment (`ExecEnv`, `execenv` singleton) | | `sigimax/mainwindow.py` | `SGMXMainWindow` — generic main window | | `sigimax/widgets/plotdock.py` | `DockablePlotWidget` — embeddable plot docks | | `sigimax/widgets/__init__.py` | Convenience re-exports of common widgets | | `sigimax/widgets/splashscreen.py` | `SplashScreenConfig`, `SigimaXSplashScreen` | | `sigimax/widgets/h5browser.py` | HDF5 browser widget and dialog | | `sigimax/h5/__init__.py` | HDF5 I/O handler | | `sigimax/adapters_plotpy/__init__.py` | PlotPy/Sigima object converters | | `sigimax/tests/derivated_app_test.py` | Reference example of derived application | | `scripts/run_with_env.py` | Environment loader (loads `.env`) | | `.env` | Local PYTHONPATH for development | ## VS Code Tasks `.vscode/tasks.json` provides shortcuts: - **🧽 Ruff Formatter**: Format code - **🔦 Ruff Linter**: Lint with auto-fix - **🧽🔦 Ruff**: Format + lint (sequential) - **🔦 Pylint**: Pylint checks - **🚀 Pytest**: Run tests (`--ff` flag) - **📚 Compile translations**: Build .mo files - **🔎 Scan translations**: Update .po files ## Related Projects - **Sigima**: Headless computation library (sibling, upstream) - **guidata**: Dataset/parameter framework (upstream) - **PlotPy**: Interactive plotting (upstream) - **PythonQwt**: Low-level Qt plotting (upstream) - **DataLab**: Primary downstream application using SigimaX --- **Remember**: Always use `scripts/run_with_env.py` for Python commands, wrap UI strings with `_()`, define `__all__` in every module, and follow the subclassing pattern for derived applications. DataLab-Platform-SigimaX-0182bf4/.github/workflows/000077500000000000000000000000001524425144300220545ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/.github/workflows/_build.yml000066400000000000000000000161641524425144300240450ustar00rootroot00000000000000# ============================================================================ # Reusable build workflow for SigimaX release artifacts. # # Decomposed into modular jobs so each artifact is uploaded independently # and any individual job can be re-run from the GitHub UI on failure: # # preflight - tag/version check, exports `version` output # compile-translations - UI + docs .mo files (ubuntu) # build-python-dists - sdist + wheel (ubuntu) # # Each job uploads its own artifact (see "Artifacts produced" below) so # they can be downloaded individually from the workflow run page even when # the workflow as a whole fails. # # Artifacts produced: # translations-mo compiled .mo files (sigimax/locale/**, doc/locale/**) # python-dists *.whl, *.tar.gz # ============================================================================ name: _build (reusable) on: workflow_call: inputs: ref: description: "Git ref to build (default: github.ref)" type: string required: false default: "" skip-tag-check: description: "Skip the tag vs __version__ consistency check" type: boolean required: false default: false skip-tag-branch-check: description: "Skip the 'tag is on main' check" type: boolean required: false default: false artifact-retention-days: description: "Retention period for uploaded artifacts (days)" type: number required: false default: 14 python-version: description: "Python version used to build the release artifacts" type: string required: false default: "3.11" outputs: version: description: "Version that was built (without leading v)" value: ${{ jobs.preflight.outputs.version }} permissions: contents: read jobs: # --------------------------------------------------------------------------- # 0. Preflight: derive version from tag or __version__, optional checks. # --------------------------------------------------------------------------- preflight: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref || github.ref }} fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} - name: Verify tag is on main branch if: ${{ !inputs.skip-tag-branch-check && startsWith(github.ref, 'refs/tags/') }} run: | git fetch origin main --depth=200 if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then echo "::error::Tag ${GITHUB_REF_NAME} (commit $GITHUB_SHA) is not reachable from origin/main." exit 1 fi echo "Tag commit is on main branch ✔" - name: Verify tag matches sigimax.__version__ and export version id: version run: | if [ "${{ inputs.skip-tag-check }}" = "true" ] || [ "${GITHUB_REF_TYPE}" != "tag" ]; then VERSION=$(python -c "import re; t=open('sigimax/_metadata.py').read(); print(re.search(r'__version__\s*=\s*\"([^\"]+)\"', t).group(1))") echo "Using sigimax.__version__=$VERSION (no tag check)" else python scripts/ci_release_helpers.py check-tag "$GITHUB_REF_NAME" VERSION="${GITHUB_REF_NAME#v}" fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" # --------------------------------------------------------------------------- # 1. Compile UI + documentation translations. # --------------------------------------------------------------------------- compile-translations: needs: preflight runs-on: ubuntu-latest env: DISPLAY: ":99.0" QT_QPA_PLATFORM: offscreen UNATTENDED: "1" steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref || github.ref }} - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} cache: pip # `sphinx -b gettext` parses doc/conf.py which transitively imports # `sigimax.*` -> `qtpy`. Without a Qt binding installed `qtpy` raises # `QtBindingsNotFoundError` and the build aborts. Installing PyQt5 # (no graphical session required thanks to QT_QPA_PLATFORM=offscreen) # is enough to let imports succeed. - name: Install system dependencies (Qt offscreen) run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 - name: Install Python dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade PythonQwt guidata PlotPy Sigima python -m pip install .[doc,qt] - name: Compile docs gettext + sphinx_intl + UI .mo run: | python -m sphinx build doc build/gettext -b gettext -W python -m sphinx_intl build -d doc/locale python -m guidata.utils.translations compile --name sigimax --directory . - name: List produced .mo files run: find sigimax/locale doc/locale -name '*.mo' | sort - name: Upload compiled translations uses: actions/upload-artifact@v4 with: name: translations-mo path: | sigimax/locale/**/*.mo doc/locale/**/*.mo retention-days: ${{ inputs.artifact-retention-days }} # --------------------------------------------------------------------------- # 2. Build sdist + wheel (both embed the compiled .mo files). # --------------------------------------------------------------------------- build-python-dists: needs: [preflight, compile-translations] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref || github.ref }} - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} cache: pip - name: Install build tool run: python -m pip install --upgrade pip build - name: Download compiled translations uses: actions/download-artifact@v4 with: name: translations-mo path: . - name: Build sdist + wheel run: | python -m build --sdist python -m build --wheel ls -lh dist/ - name: Sanity-check that wheel embeds compiled translations run: | WHL=$(ls dist/*.whl | head -n1) echo "Inspecting $WHL" python -m zipfile -l "$WHL" | grep -E 'sigimax/locale/.*\.mo' \ || { echo "::error::Compiled translations missing from wheel"; exit 1; } - name: Upload Python distributions uses: actions/upload-artifact@v4 with: name: python-dists path: dist/* retention-days: ${{ inputs.artifact-retention-days }} DataLab-Platform-SigimaX-0182bf4/.github/workflows/release.yml000066400000000000000000000114301524425144300242160ustar00rootroot00000000000000# ============================================================================ # Build and publish a SigimaX release. # # The build itself is delegated to the reusable `_build.yml` workflow # (modular, one artifact per stage). This file only adds the test gate and # the publication jobs: PyPI Trusted Publishing + draft GitHub Release. # # Documentation is NOT deployed here: SigimaX docs (HTML + PDF) are built # and hosted by Read the Docs, which rebuilds automatically on push/tag # through its own webhook (see `.readthedocs.yaml`). # # Triggers: # * push of a tag matching vX.Y.Z (no pre-release suffix) on `main`. # # Prerequisites (one-time, off-CI): # - Configure PyPI Trusted Publishing for `sigimax` -> environment `pypi`. # ============================================================================ name: Release on: push: tags: - "v[0-9]*.[0-9]*.[0-9]*" - "!v*-*" # exclude pre-release tags permissions: contents: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: # --------------------------------------------------------------------------- # Test gate: the release is only built if both Qt bindings pass. # --------------------------------------------------------------------------- test_pyqt5: uses: ./.github/workflows/test_pyqt5.yml test_pyqt6: uses: ./.github/workflows/test_pyqt6.yml # --------------------------------------------------------------------------- # Modular build (translations / dists) via reusable workflow. # --------------------------------------------------------------------------- build: needs: [test_pyqt5, test_pyqt6] uses: ./.github/workflows/_build.yml with: artifact-retention-days: 30 # --------------------------------------------------------------------------- # Publish sdist + wheel to PyPI via Trusted Publishing (OIDC). # --------------------------------------------------------------------------- publish-pypi: needs: build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/sigimax permissions: id-token: write steps: - name: Download Python distributions uses: actions/download-artifact@v4 with: name: python-dists path: dist - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 # --------------------------------------------------------------------------- # Create a draft GitHub Release with all public artifacts. # --------------------------------------------------------------------------- github-release: needs: build runs-on: ubuntu-latest permissions: contents: write id-token: write # required by attest-build-provenance (OIDC) attestations: write # required by attest-build-provenance steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Download Python distributions uses: actions/download-artifact@v4 with: name: python-dists path: assets/dists - name: Generate SHA256SUMS # Single checksums file covering every artifact published in the # release. Users verify with: `sha256sum -c SHA256SUMS`. run: | cd assets # Published release assets are flat, so SHA256SUMS must reference bare # filenames. Run sha256sum from each file's own directory so the # checksum line contains the basename (not dists/…). find dists -type f \( -name '*.whl' -o -name '*.tar.gz' \) \ -printf '%p\n' | sort | while read -r f; do ( cd "$(dirname "$f")" && sha256sum "$(basename "$f")" ) done > SHA256SUMS echo "--- SHA256SUMS ---" cat SHA256SUMS - name: Attest build provenance # Produces a Sigstore-backed attestation linking each artifact to # this exact workflow run. Verifiable with `gh attestation verify`. uses: actions/attest-build-provenance@v2 with: subject-path: | assets/dists/*.whl assets/dists/*.tar.gz - name: Extract release notes run: | python scripts/ci_release_helpers.py release-notes \ "$GITHUB_REF_NAME" -o release-notes.md echo "--- release notes preview ---" head -n 20 release-notes.md - name: Create draft GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: SigimaX ${{ github.ref_name }} body_path: release-notes.md draft: true fail_on_unmatched_files: true files: | assets/dists/*.whl assets/dists/*.tar.gz assets/SHA256SUMS DataLab-Platform-SigimaX-0182bf4/.github/workflows/test_pyqt5.yml000066400000000000000000000164721524425144300247320ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python # Inspired from https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions name: Install and Test on Ubuntu (latest) with PyQt5 on: push: branches: [ "main", "develop", "release" ] pull_request: branches: [ "main", "develop", "release" ] workflow_call: workflow_dispatch: inputs: job_to_run: description: 'Which job to run' required: true type: choice options: - 'all' - 'build' - 'build_latest' default: 'all' schedule: # Only the "build_latest" job runs on schedule (see execution conditions below) - cron: "0 5 * * 1" jobs: build: if: ${{ (github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build')) }} env: DISPLAY: ':99.0' runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX python -m pip install --upgrade pip python -m pip install ruff pytest pip install PyQt5 if [ "${{ github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git cd SigimaX pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" pip install -r deps.txt # Install SigimaX without dependencies pip install --no-deps . elif [ "${{ github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/PythonQwt.git # Try cloning guidata from release, fallback to main or master git clone --depth 1 --branch release https://github.com/PlotPyStack/guidata.git || git clone --depth 1 https://github.com/PlotPyStack/guidata.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/guidata.git # Try cloning plotpy from release, fallback to main or master git clone --depth 1 --branch release https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/plotpy.git # Try cloning sigima from release, fallback to main git clone --depth 1 --branch release https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git cd SigimaX pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" pip install -r deps.txt # Install SigimaX without dependencies pip install --no-deps . else # Install from PyPI normally for main branch pip install . fi - name: Lint with Ruff run: ruff check --output-format=github sigimax - name: Test with pytest run: pytest -v --tb=long build_latest: if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build_latest')) }} env: DISPLAY: ':99.0' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" - name: Install dependencies (latest) run: | set -euxo pipefail sudo apt-get update sudo apt-get install -y \ libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils /sbin/start-stop-daemon --start --quiet \ --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background \ --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX python -m pip install --upgrade pip python -m pip install ruff pytest python -m pip install PyQt5 # Clone and install Sigima from the same branch as SigimaX cd .. git clone --depth 1 --branch ${{ github.ref_name }} https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git cd SigimaX # Install SigimaX itself, but do NOT install its pinned deps python -m pip install -e . --no-deps # Install Sigima from local clone python -m pip install -e ../sigima # Extract dependency names from pyproject.toml (excluding Sigima) and install latest versions python -m pip install -U --upgrade-strategy eager $(python -c "import tomllib, re; print(' '.join(re.sub(r'[\[\]<>=!~,.\s].*$', '', d).strip() for d in tomllib.loads(open('pyproject.toml', 'rb').read().decode())['project']['dependencies'] if 'Sigima' not in d))") - name: Lint with Ruff (latest) run: ruff check --output-format=github sigimax - name: Test with pytest (latest) run: pytest -v --tb=longDataLab-Platform-SigimaX-0182bf4/.github/workflows/test_pyqt6.yml000066400000000000000000000165121524425144300247260ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python # Inspired from https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions name: Install and Test on Ubuntu (latest) with PyQt6 on: push: branches: [ "main", "develop", "release" ] pull_request: branches: [ "main", "develop", "release" ] workflow_call: workflow_dispatch: inputs: job_to_run: description: 'Which job to run' required: true type: choice options: - 'all' - 'build' - 'build_latest' default: 'all' schedule: # Only the "build_latest" job runs on schedule (see execution conditions below) - cron: "0 5 * * 1" jobs: build: if: ${{ (github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build')) }} env: DISPLAY: ':99.0' runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libegl1 /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX python -m pip install --upgrade pip python -m pip install ruff pytest pip install PyQt6 if [ "${{ github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git cd SigimaX pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" pip install -r deps.txt # Install SigimaX without dependencies pip install --no-deps . elif [ "${{ github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/PythonQwt.git # Try cloning guidata from release, fallback to main or master git clone --depth 1 --branch release https://github.com/PlotPyStack/guidata.git || git clone --depth 1 https://github.com/PlotPyStack/guidata.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/guidata.git # Try cloning plotpy from release, fallback to main or master git clone --depth 1 --branch release https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/plotpy.git # Try cloning sigima from release, fallback to main git clone --depth 1 --branch release https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git cd SigimaX pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" pip install -r deps.txt # Install SigimaX without dependencies pip install --no-deps . else # Install from PyPI normally for main branch pip install . fi - name: Lint with Ruff run: ruff check --output-format=github sigimax - name: Test with pytest run: pytest -v --tb=long build_latest: if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build_latest')) }} env: DISPLAY: ':99.0' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.14 uses: actions/setup-python@v5 with: python-version: "3.14" - name: Install dependencies (latest) run: | set -euxo pipefail sudo apt-get update sudo apt-get install -y \ libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libegl1 /sbin/start-stop-daemon --start --quiet \ --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background \ --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX python -m pip install --upgrade pip python -m pip install ruff pytest python -m pip install PyQt6 # Clone and install Sigima from the same branch as SigimaX cd .. git clone --depth 1 --branch ${{ github.ref_name }} https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git cd SigimaX # Install SigimaX itself, but do NOT install its pinned deps python -m pip install -e . --no-deps # Install Sigima from local clone python -m pip install -e ../sigima # Extract dependency names from pyproject.toml (excluding Sigima) and install latest versions python -m pip install -U --upgrade-strategy eager $(python -c "import tomllib, re; print(' '.join(re.sub(r'[\[\]<>=!~,.\s].*$', '', d).strip() for d in tomllib.loads(open('pyproject.toml', 'rb').read().decode())['project']['dependencies'] if 'Sigima' not in d))") - name: Lint with Ruff (latest) run: ruff check --output-format=github sigimax - name: Test with pytest (latest) run: pytest -v --tb=longDataLab-Platform-SigimaX-0182bf4/.gitignore000066400000000000000000000023661524425144300204560ustar00rootroot00000000000000winpython.env .spyderproject doc.zip Thumbs.db doctmp/ .vs/ *.pyproj *.sln releases/ .doctrees/ doc/install_requires.txt doc/extras_require-dev.txt doc/extras_require-doc.txt *.bak /tutorialnotes*.md doc/locale/pot/_sphinx_design_static/ doc/contributing/changelog.md # Visual Studio Code .venv .env .venv* .*venv* #AI instructions *.ai *.ai* # Created by https://www.gitignore.io/api/python ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ _build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest # *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ cdl/data/doc/ doc/auto_examples/ doc/sg_execution_times.rst # PyBuilder target/ # Files generated by tests scenario_*.h5 # Image resources resources/*.pngDataLab-Platform-SigimaX-0182bf4/.pre-commit-config.yaml000066400000000000000000000003251524425144300227400ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.12.2 hooks: # Run the linter. - id: ruff-check args: [ --fix ] # Run the formatter. - id: ruff-formatDataLab-Platform-SigimaX-0182bf4/.pylintrc000066400000000000000000000010101524425144300203140ustar00rootroot00000000000000[FORMAT] # Essential to be able to compare code side-by-side (`black` default setting) # and best compromise to minimize file size max-line-length=88 [TYPECHECK] ignored-modules=qtpy.QtWidgets,qtpy.QtCore,qtpy.QtGui,plotpy._scaler [MESSAGES CONTROL] disable=wrong-import-order [DESIGN] max-args=10 # default: 5 max-positional-arguments=11 # default: 5 max-attributes=12 # default: 7 max-branches=20 # default: 12 max-locals=20 # default: 15 min-public-methods=0 # default: 2 max-public-methods=25 # default: 20DataLab-Platform-SigimaX-0182bf4/.readthedocs.yaml000066400000000000000000000012741524425144300217120ustar00rootroot00000000000000# Read the Docs configuration file for Sphinx projects # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-22.04 tools: python: "3.11" apt_packages: - xvfb - libxkbcommon-x11-0 - libxcb-icccm4 - libxcb-image0 - libxcb-keysyms1 - libxcb-randr0 - libxcb-render-util0 - libxcb-xinerama0 - libxcb-xfixes0 jobs: pre_build: # Start Xvfb before building docs - "Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &" - "export DISPLAY=:99" sphinx: configuration: doc/conf.py formats: - pdf python: install: - method: pip path: . extra_requirements: - doc DataLab-Platform-SigimaX-0182bf4/.vscode/000077500000000000000000000000001524425144300200205ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/.vscode/launch.json000066400000000000000000000032411524425144300221650ustar00rootroot00000000000000{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Run current file", "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env", "justMyCode": false, }, { "name": "Run current file (unattended)", "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env", "pythonArgs": [ "-W error::DeprecationWarning", "-W error::RuntimeWarning", ], "justMyCode": false, "args": [ "--unattended", ], "env": { // "DEBUG": "1", // ☣ï¸� Debug mode will reset .ini settings // "QT_QPA_PLATFORM": "offscreen", // "SIGIMAX_DATA": "${workspaceFolder}/sigimax/data/tests", // TODO : Use test data folder } }, { "name": "Profile current file", "type": "debugpy", "request": "launch", "module": "cProfile", "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env", "args": [ "-o", "${file}.prof", "${file}" ], }, ] }DataLab-Platform-SigimaX-0182bf4/.vscode/settings.json000066400000000000000000000020141524425144300225500ustar00rootroot00000000000000{ "[bat]": { "files.encoding": "cp850" }, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, "[restructuredtext]": { "editor.wordWrap": "on" }, "editor.codeActionsOnSave": { "source.organizeImports.ruff": "explicit" }, "editor.formatOnSave": true, "editor.rulers": [ 88 ], "files.exclude": { "**/__pycache__": true, "**/*.pyc": true, "**/*.pyo": true }, "files.trimFinalNewlines": true, "files.trimTrailingWhitespace": true, "python.analysis.autoFormatStrings": true, "python.testing.pytestArgs": [], "python.testing.pytestEnabled": true, "python.testing.pytestPath": "pytest", "python.testing.unittestEnabled": false, "terminal.integrated.tabs.description": "${workspaceFolder}", "python-envs.pythonProjects": [ { "path": ".", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip" } ], }DataLab-Platform-SigimaX-0182bf4/.vscode/tasks.json000066400000000000000000000634221524425144300220470ustar00rootroot00000000000000{ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "🧽 Ruff Formatter", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "ruff", "format", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": true, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", }, { "label": "🔦 Ruff Linter", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "ruff", "check", "--fix", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": true, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", }, { "label": "🧽🔦 Ruff", "dependsOrder": "sequence", "dependsOn": [ "🧽 Ruff Formatter", "🔦 Ruff Linter", ], "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", }, { "label": "🔦 Pylint", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "pylint", "sigimax", "--disable=duplicate-code", "--disable=fixme", "--disable=too-many-arguments", "--disable=too-many-branches", "--disable=too-many-instance-attributes", "--disable=too-many-lines", "--disable=too-many-locals", "--disable=too-many-public-methods", "--disable=too-many-statements", ], "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": true, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", }, { "label": "🚀 Pytest", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "pytest", "--ff", // "--gui", // "--show-windows", ], "options": { "cwd": "${workspaceFolder}", "env": { // "DEBUG": "1", // ☣ï¸� Debug mode will reset .ini settings "UNATTENDED": "1", }, }, "group": { "kind": "build", "isDefault": true, }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "dedicated", "showReuseMessage": true, "clear": true, }, "type": "shell", }, { "label": "sphinx-build", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "sphinx", "build", "doc", "build/gettext", "-b", "gettext", "-W", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", }, { "label": "sphinx-intl update", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "sphinx_intl", "update", "-d", "doc/locale", "-p", "build/gettext", "-l", "fr", "--no-obsolete", "-w", "0", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "sphinx-build", ], }, { "label": "cleanup-doc-translations", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "guidata.utils.translations", "cleanup-doc", "--directory", ".", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "sphinx-intl update", ], }, { "label": "sphinx-intl build", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "sphinx_intl", "build", "-d", "doc/locale", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", }, { "label": "🔎 Scan translations", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "guidata.utils.translations", "scan", "--name", "sigimax", "--directory", ".", "--copyright-holder", "DataLab Platform Developers", "--languages", "fr", ], "group": { "kind": "build", "isDefault": false, }, "options": { "cwd": "${workspaceFolder}", }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "cleanup-doc-translations", ], }, { "label": "📚 Compile translations", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "guidata.utils.translations", "compile", "--name", "sigimax", "--directory", ".", ], "group": { "kind": "build", "isDefault": false, }, "options": { "cwd": "${workspaceFolder}", }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "sphinx-intl build", ], }, { "label": "Generate requirements", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "guidata.utils.genreqs", "all", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", }, { "label": "🧪 Coverage tests", "type": "shell", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "coverage", "run", "-m", "pytest", "sigimax", ], "options": { "cwd": "${workspaceFolder}", "env": { "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc", }, "statusbar": { "hide": true, }, }, "group": { "kind": "test", "isDefault": true, }, "presentation": { "panel": "dedicated", }, "problemMatcher": [], }, { "label": "📊 Coverage full", "type": "shell", "windows": { "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine; if ($?) { ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html; if ($?) { start htmlcov\\index.html } }", }, "linux": { "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine && ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html && xdg-open htmlcov/index.html", }, "osx": { "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine && ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html && open htmlcov/index.html", }, "options": { "cwd": "${workspaceFolder}", "env": { "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc", }, }, "presentation": { "panel": "dedicated", }, "problemMatcher": [], "dependsOrder": "sequence", "dependsOn": [ "🧪 Coverage tests", ], }, { "label": "Upgrade PlotPyStack", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "pip", "install", "--upgrade", "pip", "PythonQwt", "guidata", "PlotPy", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "group": { "kind": "build", "isDefault": true, }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": false, }, "type": "shell", }, { "label": "ðŸ”� Reinstall guidata/plotpy/sigima dev", "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "scripts/run_with_env.py", "${config:python.defaultInterpreterPath}", "${workspaceFolder}/scripts/reinstall_dev.py", ], "options": { "cwd": "${workspaceFolder}", "statusbar": { "hide": true, }, }, "presentation": { "panel": "dedicated", "reveal": "always", }, "problemMatcher": [], }, { "label": "🧹 Clean Up", "type": "shell", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "guidata.utils.cleanup" ], "options": { "cwd": "${workspaceFolder}" }, "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": false, }, }, { "label": "📚 Build doc", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "sphinx", "build", "doc", "${workspaceFolder}/build/doc", "-b", "html", "-D", "language=fr", // "-W", ], "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": true, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", "showReuseMessage": true, }, "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "Generate requirements", ], }, { "label": "ðŸŒ� Open HTML doc", "type": "shell", "windows": { "command": "start build/doc/index.html", }, "linux": { "command": "xdg-open build/doc/index.html", }, "osx": { "command": "open build/doc/index.html", }, "options": { "cwd": "${workspaceFolder}", }, "problemMatcher": [], }, { "label": "📦 Build package", "type": "shell", "command": "${command:python.interpreterPath}", "args": [ "scripts/run_with_env.py", "${command:python.interpreterPath}", "-m", "guidata.utils.securebuild", "--prebuild", "python -m guidata.utils.translations compile --name sigimax --directory .", ], "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "panel": "dedicated", }, "problemMatcher": [], "dependsOrder": "sequence", "dependsOn": [ "🧹 Clean Up", ], }, { "label": "â�” Untracked files", "type": "shell", "command": "git ls-files --others | Where-Object { $_ -notmatch '^\\.' -and $_ -notmatch '^(build|dist|releases)/' -and $_ -notmatch '.(pyc|mo)$'}", "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": true, }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "dedicated", "showReuseMessage": true, "clear": true, }, }, { "label": "🔄 Switch to PyQt5", "type": "shell", "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip install --quiet PyQt5>=5.15.6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -c 'import qtpy; print(qtpy.API_NAME, qtpy.QT_VERSION)'", "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", }, }, { "label": "🔄 Switch to PyQt6", "type": "shell", "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip install --quiet PyQt6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -c 'import qtpy; print(qtpy.API_NAME, qtpy.QT_VERSION)'", "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", }, }, { "label": "🔄 Switch to PySide6", "type": "shell", "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip install --quiet PySide6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -c 'import qtpy; print(qtpy.API_NAME, qtpy.QT_VERSION)'", "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", }, }, { "label": "🔄🗑ï¸� Uninstall Qt bindings", "type": "shell", "command": "${command:python.interpreterPath} scripts/run_with_env.py python -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6", "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "build", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", }, }, { "label": "🧪 GitHub Actions local: list jobs (act)", "type": "shell", "command": "act", "args": [ "-l", ], "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "test", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", }, }, { "label": "🧪 GitHub Actions local: test_pyqt5.yml (pull_request)", "type": "shell", "command": "act", "args": [ "pull_request", "-W", ".github/workflows/test_pyqt5.yml", "-P", "ubuntu-latest=ghcr.io/catthehacker/ubuntu:full-latest", "-v", ], "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "test", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", }, }, { "label": "🧪 GitHub Actions local: test_pyqt6.yml (pull_request)", "type": "shell", "command": "act", "args": [ "pull_request", "-W", ".github/workflows/test_pyqt6.yml", "-P", "ubuntu-latest=ghcr.io/catthehacker/ubuntu:full-latest", "-v", ], "options": { "cwd": "${workspaceFolder}", }, "group": { "kind": "test", "isDefault": false, }, "presentation": { "clear": true, "echo": true, "focus": false, "panel": "dedicated", "reveal": "always", }, }, ], }DataLab-Platform-SigimaX-0182bf4/LICENSE000066400000000000000000000030001524425144300174550ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2023, DataLab Platform Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. DataLab-Platform-SigimaX-0182bf4/MANIFEST.in000066400000000000000000000000671524425144300202200ustar00rootroot00000000000000graft doc graft sigimax/locale include requirements.txtDataLab-Platform-SigimaX-0182bf4/README.md000066400000000000000000000214051524425144300177400ustar00rootroot00000000000000# SigimaX - Reusable GUI Framework for Scientific Applications ![SigimaX](https://raw.githubusercontent.com/DataLab-Platform/SigimaX/main/doc/images/SigimaX-Banner.svg) [![license](https://img.shields.io/pypi/l/sigimax.svg)](./LICENSE) [![PyPI pyversions](https://img.shields.io/pypi/pyversions/sigimax.svg)](https://pypi.org/project/sigimax/) **SigimaX** is an **open-source Python framework for building Qt-based scientific desktop applications**. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features. 🔬 Developed by the [DataLab Platform Developers](https://github.com/DataLab-Platform), SigimaX is extracted from [DataLab](https://datalab-platform.com/) and powers its GUI layer. --- ## 🌟 Project & Sponsors | Project/Sponsor | Description | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | DataLab logo | Open-source platform for scientific signal and image processing, built on SigimaX. | | NLnet logo | European non-profit supporting open-source and internet projects. SigimaX has received funding from NLnet for its development, through the DataLab project. | --- ## ✨ Highlights - **Extensible configuration system** — `OptionField`-based settings with `get()`/`set()`/`context()` API and JSON persistence - **Rich widget catalog** — 15 ready-to-use scientific widgets: curve fitting, peak detection, signal baseline, HDF5 browser, log viewer, wizard dialogs, and more - **Complete HDF5 infrastructure** — built-in file browser, importer, and workspace save/load - **Embedded Python console** — `DockableConsole` with error-to-console routing and configurable namespace - **Production-grade status bar** — memory usage monitoring with alarm threshold, console toggle - **PlotPy integration** — `DockablePlotWidget` and adapters for signal/image/ROI objects - **Derivation pattern** — subclass `SigimaXOptions` + `SGMXMainWindow` + call `run()` to build a full app in minutes --- ## 💡 Use Cases SigimaX is meant to be: - A **framework for building scientific desktop apps** with Qt - A **reusable main window** with menus, toolbars, docks, and HDF5 workspace management - A **widget library** for signal/image analysis dialogs (fitting, peak detection, baseline, cursor, delta-X) - A **configuration backbone** for apps that need persistent user preferences --- ## 📖 Design Philosophy SigimaX separates the **generic application skeleton** from **domain-specific logic**. Derived applications follow a three-step pattern: 1. **Subclass `SigimaXOptions`** to add application-specific configuration fields 2. **Subclass `SGMXMainWindow`** to customize menus, toolbars, and dock widgets 3. **Call `sigimax.app.run()`** to launch the application with splash screen support This architecture is proven in production: [DataLab](https://datalab-platform.com/) is built entirely on this derivation pattern. ### Position in the Stack ```text End-user apps (DataLab, custom scientific apps) ↓ subclass / configure SigimaX â†� THIS PROJECT (framework layer) ↓ depends on Sigima (computation) + PlotPy + guidata + PythonQwt ↓ NumPy / SciPy / Qt ``` --- ## 🚀 Quick Start ```python from sigimax.app import run from sigimax.config import CONF as Conf, SigimaXOptions, EnumOptionField, _ from sigimax.mainwindow import SGMXMainWindow from sigimax.widgets.plotdock import DockablePlotWidget from sigimax.config import TypedOptionField from plotpy.constants import PlotType # A missing option may be initialized on first read: color_mode = Conf.color_mode.get("auto") # 1. Define custom options class MyAppOptions(SigimaXOptions): def __init__(self): super().__init__() self.app_name.set("MyApp") self.greeting = TypedOptionField( self, "greeting", default="Hello!", expected_type=str, description="Startup message", ) # 2. Customize the main window class MyAppMainWindow(SGMXMainWindow): def __init__(self, console=None, hide_on_close=False): Conf.app_name.set("MyApp") super().__init__(console=console, hide_on_close=hide_on_close) # Add a dockable curve plot self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) dock, loc = self.curve_dock.create_dockwidget(_("Curve Viewer")) self.addDockWidget(loc, dock) # 3. Launch run(window_class=MyAppMainWindow) ``` --- ## âš™ï¸� Architecture ```text sigimax/ ├── app.py # Application launcher (create / run) ├── config.py # Configuration system (SigimaXOptions, CONF singleton) ├── env.py # Runtime environment (verbosity, unattended mode) ├── mainwindow.py # SGMXMainWindow (generic main window) ├── widgets/ # Reusable Qt widgets │ ├── plotdock.py # DockablePlotWidget │ ├── splashscreen.py # Configurable splash screen │ ├── h5browser.py # HDF5 file browser │ ├── logviewer.py # Log viewer dialog │ ├── status.py # Status bar widgets (memory, console) │ ├── fitdialog.py # Curve fitting dialogs │ ├── signalpeak.py # Signal peak detection │ ├── signalbaseline.py # Signal baseline selection │ ├── signalcursor.py # Signal cursor selection │ ├── signaldeltax.py # Signal delta-X measurement │ ├── wizard.py # Multi-page wizard dialog │ └── ... # File dialogs, warning/error boxes ├── h5/ # HDF5 I/O (read/write/import) ├── adapters_plotpy/ # Converters between PlotPy/guidata and Sigima objects ├── utils/ # Qt helpers, config dir resolution ├── data/ # Icons, resources └── locale/ # Translations (EN, FR) ``` --- ## 📦 Installation ```bash pip install sigimax ``` Or in a development environment: ```bash git clone https://github.com/DataLab-Platform/SigimaX.git cd SigimaX pip install -e . ``` --- ## 📚 Documentation 📖 Full documentation (in progress) is available at: 👉 > Want to use SigimaX as part of the full DataLab platform? > Check out: [DataLab](https://datalab-platform.com/) --- ## 🧪 Testing SigimaX comes with a comprehensive test suite based on `pytest` (155 tests). ### ✅ Validated Environments The test suite has been checked with the following matrix: - **Python**: 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 - **Operating systems**: Windows, Linux - **Qt bindings**: PyQt5, PyQt6, PySide6 (future fix needed) > âš ï¸� Note: PySide6 is currently known to be not fully working in this matrix. ```bash # Run all tests (offscreen, no GUI) python scripts/run_with_env.py python -m pytest # Show Qt windows during tests python scripts/run_with_env.py python -m pytest --show-windows ``` --- ## 🧠 License SigimaX is distributed under the terms of the BSD 3-Clause license. See [LICENSE](./LICENSE) for details. --- ## ðŸ¤� Contributing Bug reports, feature requests and pull requests are welcome! See the [CONTRIBUTING](https://datalab-platform.com/en/contributing) guide to get started. --- ![Python](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/Python.png) ![NumPy](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/NumPy.png) ![SciPy](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/SciPy.png) ![scikit-image](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/scikit-image.png) ![OpenCV](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/OpenCV.png) --- © DataLab Platform Developers DataLab-Platform-SigimaX-0182bf4/babel.cfg000066400000000000000000000001321524425144300202010ustar00rootroot00000000000000# This file is used to configure Babel for the project. [python: **.py] encoding = utf-8 DataLab-Platform-SigimaX-0182bf4/doc/000077500000000000000000000000001524425144300172245ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/_static/000077500000000000000000000000001524425144300206525ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/_static/DataLab-Banner.svg000066400000000000000000001136771524425144300241050ustar00rootroot00000000000000 image/svg+xmlDataLab DataLab-Platform-SigimaX-0182bf4/doc/_static/DataLab-Title.svg000066400000000000000000001127251524425144300237520ustar00rootroot00000000000000 image/svg+xmlDataLab DataLab-Platform-SigimaX-0182bf4/doc/_static/DataLab.svg000066400000000000000000001111001524425144300226550ustar00rootroot00000000000000 image/svg+xml DataLab-Platform-SigimaX-0182bf4/doc/_static/Sigima-Frontpage.png000066400000000000000000001676371524425144300245400ustar00rootroot00000000000000‰PNG  IHDRè¯fr° pHYsaa•ø¶tEXtSoftwarewww.inkscape.org›î< IDATxœìÝK“\çyØñ§{®¸ A $%Y’m‰²c[R¹*©¤²Ë&•UªRY¤²Ï*•|}…l²É2‹¤*—J줢8)ÛRÙE†âE$ w æ>=3}?·,zf0€äŠÀLÏü~E�éÓ§ßî {þýž÷´š¦‰ˆ˜‰ˆÿ,"ÞˆˆùκqD|ÿ&"þuDT­¦if"â?EÄ?<Α'Ú‰ˆÜjšæ_FÄ¿:îÑ'Þ¿h5MónDüÁq�8ñ~ÑjšfÖL>ߨÕì�•àó´�{Àô€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH€4AH›=îÇg\T1•1•Q”u”UUÕDU7ÑjEÌδc¦ÝŠÙÙ™˜Ÿ›‰Åù™8¿8ív븇Ξ¦i¢(ë¨ê&ªªŽ¦‰¨›&šfrY»ÝŠv«ívkòœÎÎÄl»å9€§Ðì¿¶>òû½/ì}m¢õÈuÑÚÿú“÷tã']ÿðíìßÒã×ÝÿÚã¯õMÓùòn'3žÖÑíZ�ìöÉû|ìKOϧ_ÿ �q4�ÝË'ÝÎãû¼¬>r›{ïê'í÷H„ÛÛ¶~dÛ‡ñïè}i"Žì÷É!ìI1ëðLÐúPT;¸�'DÅG¶=ˆ�OˆxÛî‡ÊÃÛ}ö/Ûûóþý9´íAX>²íÁãt(è}¾ÆwdÛO{n>Ž�Žoßuýè~ã±€ýè÷ÁãÏÁ§Ÿ¾ß÷¿y8¦Éc1 ‘3{GÊÌÌ´cn¶=y/:7ÎÍÅ•ÏÅ·¿þRüÎo¾ßúêå¸zåBÓà E8ªªŽî ˆw¯­Ä/>^�÷o¬Çƒµntã(‹j'ÿíÛÚ{Ó\GQNba·]ÄÆö0î·"V6{±´Þ�›KÛñ{¿ýJ|ë«—ãÒ……㽓gÀ¸¬c{wÖ»±ÙÄNƒQMÑnM5jµZG©´Û­É¿½CØËª£¢ŠÁ¨½~ýA½A/½°—_XŒù¹™Ð†8«ªº™Ì>¬ê(ÊIH,ëf/¢þuèJ­‡xhÆjQíϨ-‰â­VĹ…¹¸³´üj-¾öê¥øæë—â�o\‰¯^}!._ô³œt‚"œrƒQ«›½xïújüÅÛwãƒë±µ3<OOÕ�š&êˆXۚ̌»³¼ËëÝøÃß}-¾ó�—âÕ+ ýŒtûãØÜÆÒÚn,oôb0,ž¼öSVÀI(nbk·Šá¸Šþ Œá¸Š—.-ĹŹ˜õp†ì�Q”uŒ‹j2ë?‚Ä£3¿€/G]7ÑŒ£7Ç­¥íX¼¶¯½|aï(¨¯Ä·¿ñR|õ+canÆÏpBÍüð‡?üáqx6Fã2n-mÇOÞºÿö�ß‹ëw¶¢?*™É¶ÿû8ôûÖÁl·88œùè×ꦉþ ˆ[KÛ±¼Þ�ˆV¼öÊ…˜Ÿ›�'üøÒÔuÃq·lÇÇ·7âÁz7Š²Šˆ#ÏÍf(>ñ¹<üœGD]G †etºÃh·'‡¡ÌͶ�´€3¡ª›í�¤nTT�œØ,íS6}ª]öŸzŸÑXžf¿_Æ.žnÛ/0¸'\åÓöò¼§§¹Í§ù^ÿBqýI�Ó¾VTuln⣛ñÉÝ­ØÜÄË—ÎÅüÜìäD‘¢"œ8‚"œbù‹ûñÇ?¹?ú¿7b§7Ú[ˤõ¥ÅÃÛ÷E¬ltãþJ7¾ñÚ¥8n.fÚ^ô]UÝD¯?Žw¯­Æ�Û±Û\v8 þ:Añ ,6MôEŒË*Zí–“¶pªÕu㲎Á¨˜¬“x¤pŠ_þXÅ/¾ß/bÚ‚âá±ô‡EÜ[Ù�Ÿ½÷ zƒq\8?¯Z_NAN¡î ˆ�nmÆýøZ¼õËåèì'ëìµ[Ï (6ÍäÓýÝþ8zƒ"._\Œ+—Ï9Y˯isï,ÜÖ{Ñ“3æí?ñåÅýí›&Nγ°ÿi°™Šœ2eUǸ¬c\ÔQ”õÁ %¿â Š_þXÅ/¾ß/bšƒb]OÞ“vE¬oõc£Ó�ªnâê• 1?7ó< ÖP„Sf8*ãöÒvü·_‹_|¼ë�þS¯±÷E”e�Ýa¼õჸtq!Î-ÎÆ7_¿üÌo÷´êtG±´Ú�»+»1—Q7ÍÓ­wùe�ÝQÌÍtãõ¯\ŒKDEN�²š¬“8ùÍâˆpRíŸ=úþênô‡EìôÆ1Wñý7®Æë¯\´<œ‚"œ"uÝĽ•Ýø«wïÇ�~z#Fãò¹ßþFgo¸ós3qåÒ¹½ÃŸ½àg5ÍdQø{+;qgy'zƒñÁÌÂç¥(ªXÙèÅÂüLÌδãâù9³M˜zUÝÄh<9éJ-&ÂÔØÜÄ›<ˆ�Î0†£2þ¾ìh8n‚"œMÓÄ`XÄ�ߺÿá}ƒÑ$&GË»y¿­V+^{ùb|ï�«qé‚õø²Š²Ž¥Õݸ};Ö¶úÇòüELÖT|°Ö�V«ç/Ǭ£K˜beUÇXL„©5WñáÍõ(«*ºýqüÓð{13Óò¡7#gM€S¢(ëø³Ÿß‰·?œ¬™ñ\'µ=fe£ÿý/>‰¥µÝ(ÊúG2=꺉ÝÞ8>ºµ�îèXŸ¿ˆÉÙö¶w‡±ºÙuXS«¬&³ÿ˪Þ[�ø¸G<­ÉzàwWvã'o߉ÿø¿?Œ�íáq Î4AN�qQÅêf?~ú‹{qýÎf”Õñ¼þ°ˆ{Ë;ñÞõµXZÛ=îáL…�Þ8î­îÄFgã¢:îáDÓ4ѱ±=ŒÁ¨ˆª>þï+xU]GYUQíÇÄãðkŽÊ¸q¯òÓ›ñÿ>|ë�Áq Î,AN�­�a¼ýÑr|xs#6wN΋ꨨâ��VâÆ½Îs_ÏqÚ”U�~Ü[Þ�qyü1q_QÖÑí�£³;Šq!(0=꺉²¬£¬š0ÑN�î ˆ�n­ÇŸ½y'>º¹~">ˆ‡³HP„)WÕMÜZÚŽÿúg×b£ÓŸœí„hê&>¹³×noÆêf?š4¶“¦7(bm«+›½õ8íŸ$fe£ýAÍI|Цi¢ª›(ªÆkœBEYÇŸÿüvüõ{K±²Ñó÷Ž� SîÁún|pc->øÕzô‡'o`7îuâç¿|à�ÙÏpçÁÎ^Lº¹ï~¼ã¢<±ošW6ºqýöftvGNÐrD]71–±¶Ù‹íÝ“¹°t«µ˜QtûÅq>ÓþìÄê¬) <+MD4qwy'þ߇ËqýÎVôÞ§Âó$(”ªë&ºƒq|xc=>ºµqÜÃùLÃq+›½¸v{#ºƒñqçD“Ǧ³;<ñkvEtûãqÒø4UÝD³7[ÉIXàt닸ý`'~òÖ�ØÜ>9kÉÃY (”*Ê*n/mǯîubu³ÜÃùLMÓÄNwo~°›�Á‰�Iyúƒ"nÞïÄ`TFs’À|‚¢¨¢?,¢?tÆgN®ªª£vø#œ[;ƒøÓ7oÅýµ]'h�çHP„)Õ–ñÞ¾wWv¦"Ðuãxç£åXÛêGY�üñ>U]Go8Ž;Ë;S³6ápTÆæö0*Ï!'PÝLÎè< ï�€/GXÄõÛ›ñ«»[±a–"<7‚"L¡ºnb§;ŠŸ½?VÖ»Ç=œ”¦™Þ{ó~'î¯î÷pN„�î8Ö6û1.ª>7ñ¡Ñ¸Œ­�AŒËÊkœ(MÓDµVç–ƒ�á¬hµZÑjµâýOÖâÆ½ÎqÎ A¦P§;Š÷;ñ`­ýát,>Ü4MÔu·–¶ciu:"è³ÖÙÅÚÖ`R[§$ÎUu£qƒa…µ8Ašf2CÑʉp6}|k#nÜëøÐžA¦ÐÚf?Þ½¶Ý~Ó¶DнÕÝXZïFQÖgúžªëØîccûd¯yT«Õšœñ¹?ŽÑØ5œMìê|vß_ÀY¶´Ö�û«;±ÛžA¦LÓ4±¼Þ�w>^‰Ñ.:¼µ3Œåõ^ln¦.†~Yš¦‰Á°ˆíÝaôÓ1Ãô°ºib·7ŽÑx:Ö}àôkšfï×q�8.£që[ƒ¸»²¥#ià™aÊ ÇU,­w㣛ëSy³¦ibs{×noNåø¿ UÝÄòF?vû㩜¥Ù4½aÃqålÏœMÑœÕO*�[»Ã¸ý@P„çAP„)sk©·–:ÑÑLé!=Ûƒ¸~g3Šòl¾Ð×u+½Øí��{(_L+¢®ëŽÊ ÍRàø5���ˆ�Þ(î­ìDUùž5A¦Ì�»�¸½ÔÙ[(è¸GóÅìtGqwy'ºýñ™ûô°Þ;©Éæö zSrB�'kÅpTMå!ÛœB­©9¿ð õE¬löÎÜÏpE˜"MqãÞVÜ[Ù=î¡üZFã26·‡±¶Ù‹áèlÍp+«:vûãèŠ(§|†æp\NÍYÆ8åšFQb0,b£3˜Êe…`ÚŠ0%꺉QQÆÍÛ±´Þ=îáüÚÆE7îwb»;:¹›ÛƒS£Š²Ž›g1(ËXÙèŸ<'¤)ª:ºƒ³wè:'MËE ªj²¼�Šðì Š0%zƒ"Þ½¾[;ÃSñY”UÜYÞ9SŸ 6MÄ`TÄÚVÿÔÜ粬b·?Žq–›ä IDATJPà˜•U㲚֥æaªŠ0%úƒ"Þ½¶;§dF_YÕ±¹=ˆÍíá™9±Ç¨(£ÛGoPD}J\U7±ÛGéLzǦi&K"ÔM3ùÿÞïa4ÍÞ÷î¡_MÓøA€/¤itÃs2{Ü>_ÓDìöÇñËk±Ó;A±ÕšD�õN?Ö¶úqéÂüqé™ÛíŽc»;Šª®£Õj�Š¥žÊªŽÞ ˆªöÆíyiš&Z­V41ù;4.ª(Ê:êºÙ»,bnv&æfÛ1?7óÈuà¤hšÉLõ¢¬c\<\b¦Ýй¹™˜Ÿ›‰v+|ßðxí€çAP„)Ðí�bu³ë�AŒÆUœ¦Ÿ¯V7{±²Ñ�ßþú‹Ç=”gn§?:53L÷Õu³ê(«:fgL|ªº‰º®£³;Šëw6ãÞÊntvGQVuœ_œ‹¯_½¿ùµã›_½íVëTý»ÁôÚŸIÛ4wìÄÍû�¸·ºƒQs³3ñâ ñõ«—â;ß¼—/.D»ÕÄŒWNA¦ÀZg·–¶£(Nß,°õ­~¬nöŽ{ÏÅvw;½ñqãKÕjMf¿ GeŒç¾” Ø4“}îÿÿ‘™›ˆØ›µtÖf/íÏ2U¼w}5Þþh%>¼¹k›½è Š�˨›ˆÙ™v\87W.Ÿ‹¯]}!þÖï¿ðí«ñÚ+ÎÔãÅÉ´¼Þ‹÷?Y‹¿~ï~,­vccgýÁdÙ„v+ba~6.œ›�«WÎÇïüæËñ‡¿ûZ|ï;Wcn¶íûàa ¬möãÆ½Î©\­³;Œµ­~ŒÆeÌÏÍžÚYTUÝD·_Dwpº‚â¾Á¨ŒqQÅùÅüËÊ~4ÜΫº‰þ ˆ�Þd�ÉqYEQTQVõÁ™;ggZ1»w(ïÂül¼p~>^8?‹ ³ûŒ8¥¡±ÕŠ•�^¼óñrüä­»ñÎÇ+qgyç‘ÃE›™iÅå‹‹qgy;–V»ñ7ÿõø½ßþÊs4<ôËëñ³÷—â§ïÜ‹w¯¯ÆNoÕ§¬¿:?ÛŽ�omÄí�XßêǾûj\½r!ÚíSøw` Š0V7{qëþö© ŠÃq�ÝQlí ãÊåsk¾�&uÝDoPDo0Žñ¸:•˺ Gŧ†­ÃögÙeÃQ»½q ÇU ÇeôE¬w±¼Ñ�Í�a †EŒÆUŒÆe”UíV+fcq~&Î-ÌŹÅÙxíå‹qõÊùxñ…ÅX\˜�ù¹vœ_œ‹Ëcv¦½7ƒ2¦>T7M�ÝQ¼ùÁRüûý2>øÕZô‡åg^§ªšØÜÄ�~'î-ïÄúö ^¼´¯¼x>Náß3N®qQÅÆö þÇ_ü*þôÍ[ñÉ�­Ï¿NYÇ'w·bi­7îmGU7ñ·ÿà«ñò‹çŸÃˆø<‚"L�õ­AÜZêD]7§±EEXÄí;ñÂ……S˪Ž�Î †£Ï@Ól0*cT|öýkšÉ,Ä™ödfê'w·âgï/Å{××âÆ½­XÛìGY×û‡<ƾÚû…°59Ä÷üâ\|óõKñÝo½?øî«ñw¾ÿµxùÅó17ÓŠº‰˜™iOýß�Ÿ¼u;þèÇŸÄ›£‘ð4E8Á𦉵ÍɃÝþxêƒÈ§é Џ³¼ßùÆKqáÜÜqçKWVu¬wú1þ ¾iU7ME£q ó_ZöA®ª&–7ºñÖ/—ãí—ãÖƒíØèô£;(¢ÛÇpTFQæ×=˜­ÛDŒë*ªªžœœdu7~öþRü»ý2~ãÕâ»ßz9¾ÿÝWão¼óó3ÑjŠ‘S iš�«XÝìÇ_¾{?Þ½¶ò…÷µ¼Þ�ÿùÓñý7^� çæã¥K‹ád-<;uÓÄnwÝÚˆ?þóë¿Öz¹ï²¯½r1¾÷�¯Ä×®^ŠÅù™Ó¹´À”ákšˆûkÝXïLfnµZ­SÃ"î¯ì¦™�FUÕÄÖî0F§4(¶""šI8uÌÏ=<¡Êp\ÅŸ¬ÅµÛ›qýÎä×�å�èìŸ* ~žªn¢?,£?,c£3ˆˆˆÛK�øäîV¼w}5þê÷ãÿ³÷fAr]i~ßïÜ5÷ÌÚw °ƒ÷�lv“½÷´¦gÚöhɶüè°zð³Ÿüè=ز¤°B’F3šÑ¸Çšé•Ýìn® A$ˆ¨}ß²rϼ»nf¡@¨¬-+ž_DP•w9yïÉ“çüïÿû¾ÇŽtqj´›Ã©°° C®EA¹êðî¥nMewUÔÇr<–ÖÊ|øÙ=Q2©2�d¿hhþó9Î_]`~µ„çíü3_ªØÜš\ã½K³üèõyS%‰D"‘H$ƒœ�I$mŒÌ,XËWº)ûJÕrYÊ–©Ù¾�equ÷ÕÐ]×çâõE?ÞÃÓ§û÷ …É}@ ›]ç“‹»,¬–øàòß~é�2j_"‘H$‰ä@‘‚¢DÒÆø~ÀìR�l¾vÐMÙWlÇc½P£T±q\ïž�هϰl—RÙÆqüG0¼4|Cª*0tU ÅÄ«cËüæ£)~þîòE Ï?¸‚B¾P(Û¼si†ó×êMò'ß9ÍKO 2:˜Ù¨LÝ®nEËq¹=•%_²v},?˜œÏ±²^� xD]Ï’ƒ'tÃj¶ÂÔüöò&Þ�|Ébl&GÍv‰bK‰D"‘H$3�Ϊ]"yD™]*²^x´E…Ž•õ2݉GJP´l—BÙÆ{+tC¸ ×4…þÎ8ɸA¶PãÏ~•�®Ì3½X RuÚª:¹e¹L/ø×ÿùï:Ç·^<Ìó�ӍЂª´Ÿ@áz>5Ë¥\up÷Àá¡#¸Zsq¼]�dÕqI{àP®¹+;Õ¿çx~€å¸TjŽë=’E¼$‰D"‘H�U»Dòˆáû•šÃÒZ™|éÑ=Ïge½B±bÓ•‰tsöŒªå’+Zø~ðÈ 7Š"ˆEtz;c(BpáÚ"¿ùp’ó×X^+S³Û/Ä;×#Wô¸|k‰|©ÆÄ\Ž?úÆ ŽŽt�hˆ6+TâÁžŠ²A¾ï‡ÿi£÷)yô|ß÷÷Ô¡¾þH$‰D"‘H)(J$mJÍvYZ+‡Å+ÿ‘_ø,×ÅG‰ªå’/Õ6ª? ˆGuR ]Uyï“~ûñç.Ï‘+ÖxÞn¾dq}|•ùåžð�çFxêTé¸Ù6áÏÐTMU‚=¹®šª ë*Š"Ã�%û‹¢tMÅÐÕ=ÉŸ*UQеð3!‘H$‰D"98¤ (‘´)åªÃØìz˜+ŠVª3U-wÀJ¶BiUlÛ«áPl±ÀÖÄ‚ ØSq,TE!bh¤“&øôöñËk\[¡ö�U²v\ŸÕ\…Ÿ¼u“l¡JÀÇ{H%̶§TUÓÐèLE(”j{r}Ó “DÌ�‚Œdÿ‚dÜ 3Ù“¢Bº®’J˜Ä":ª&û¯D"‘H$ÉA"gcI›RªØÜ™Y§jµ6lT�h-^¬ù~ÀÊz…BùÑ ïŠVËZE ‹¤(ÊÞÞG!šª0Ø“ 5¸9™åÿ×ïrulõ¡7S©9üúÜ$ÿü?|ȇWæYËW[.ß�ˆ¡qæh7™dd×ÇRÁ±‘ ½�ñ‡ÂE*yx ‚€þîG‡:ö$�@&áäáN"¦&ݵ‰D"‘H$Œ%’6¥T±Ãj–-ãQƒÎÐÛoiØgÔl—bÅ¡øˆ¸-Û¥j¹à2ÓPéëJ ï±055z»âDM�·>œäßýíg¬ä*ØNûåKÜ.Žë1³Xä_üÅ~þîsëa®ÁÄ÷’1ƒï¾r„áþÔ®�§© ¯>5‰‘ŽG+ _ÒvBQñÔh'¯<5„º7¤øÎËGˆEt™CQ"‘H$‰ä€‘!ÏI›Rª:Œ€C1jjœ>ÒM©bS,[”«Î¾ž/ÂʺB„âI¡d±–¯�ŒûzÞVPª:”ª6AcáÛ"}VSâ�îL”l¾Š³¹ËtM!3HÆu.\[ä�K3\[Áqw'º)B�étgbôuÆéëŠÓ‘Ž54LCE×Â|AŠ~–íQªØ¬å«¬æ*,g+,®–Báv—C¥æ0>»ÎÏÞ½ƒãúüðµcôvÆ1tõ@ µ(Š Õxòd/OŸêcy­ÌôbaGÇJ'LN�vñôé>{“mUxFòh"€þ®Ožèå…³\_%_²vt¬¡Þ$OžìãéS}ÄL­mòœJ$‰D"‘|U‘‚¢D҆؎G¾h1¿RÜ“DöÍ"„À0TFS¬dË,eËû.(~ž|©ÆÊz…Ãé‡~ÁX(YTj­½~f=ÏX:i¢k Õ]¯!úÆ£шF¡dóæ¹ >½µ´ãþ!D(^÷tÄîK1Ø“`¸/É`O’‘þ$=qâQ�¨©¡kJ=t;Àv|ª–K¡d±°Zbq­ÄÜr‘©…Kk%–ÖÊaµð]¸\àê�ÓPùæ ‡éëŒcP˜¥®©tgb¼öÌ•ªÃ¯>g½XÃóšWO“qƒ3G»ùÃ×OpâP'ñ¨¾�-–HB„+ÁÎðão�àêØ …m|>5U�NDxí™^{f˜þ®ø~5W"‘H$‰D² ¤ (‘´!¹b�•õ2•ªƒ¢eN"!QSc ;ÁH’±Ù(3;tCí”bÙf-WåQ(k],ÛTª­ŽD4’q3Ì…©*uavçÖ=!š"èHE°l�óWçùð³¹YÐ5…TÂäPН==·_åØHC×ð|EåËî(ð™†J&i2Ô›B× ¸9µÊŸÎqîòׯW©Z.¶íáïÀ¶è¡ð±š«�Œ›¼xv€�žÄ�2yé‰! M¥X±ùøêk¹ öîPED �ÇŽtó�—�ð_}ëÔ}®­D²?½�qþø›')Wm‚ àòíj¶»eز¡)tuÄxæt??þÖIž:Ù×¢VK$‰D"‘H¶B ŠI²²^a~¥Ôòó&b:½�q¢�¾®8Ý™hËÛ�/Y¬äª{^�ø (”-ÊÕÖ烌:ñˆŽª¢�RÅÆqwîtUUAg:J¹±ÄÂjiÛ…[\ÏçÆÄ*†®0Ð�àù³t¦"&nhšÂHŠ¡Þg�w3·\$[¨Q®:~€i¨t¤"ôÖ Ü!P¤#iÒ “TÜäÄáNή•YZ+“ÍW±]UÄ£:]é(ƒ½IQýÀÁ‰D"‘H$’#E‰¤ YÍUXZk½ ˜Ž‡EEÄ �LÒ$3(”­]WÏmš,Ûci­L,¢?”‚¢ëzäKŽãµ4t[�¦…9ót-tÐÅ":æ.®aÄÐHÄ ª–Ë•;Ëܘ\ÛÖþŠ"èLExãùCüðµã’!ÑyïÓYWJl‘ºí T-—ëküÙÏ®’Œ›¹¢…³�*¼{�" 31´»aÄñ˜�iì\P4M UU¸tc‘Éùü¶«:Ç":?øÚQ¾ûòQ;ÚÝr§œ¨·áñc½$c&ý] þÍß|BÍr·-’J¯/òÓ·ï xáñAÔ.p"„@U¥h(y8QAóYS%‰D"‘H$í„%’6Âõ|VsUŠe ç>‰ê÷ SWI'#t¦#Î�xÌ`¤/ÅÔB~ßÅ/Ó—×ge½Š½‹B"‰ãúäŠ5œmæÜ-Š"HÄtŒM¢©«º†ª(Û®v¬© Q3ÌÅxñÆ+Û¹3I“'Nôò‡ß8Á‰C�v«ª ƒ=I¾÷ÊQ:3QþÝùŒ™¥Â} BÜ×õøíùÉ0GcGŒÃi4Uæv“H$‰D"‘H$_-¤ (‘´žç³¸Z¢X±ÃPÙž»3¡#!bÜQƒá¾º¦­+.b;+ë•åêk2ÔÁ¿~œ£CÄ"÷uÓû"¦ÊÈ@ŠHD£T¶ùÕ¹q®Ž­âo'þY„ÕgãQ�hD'hÄhJ$‰D"‘H$ÉW)(J$m„ë,¬”(–wV@c7ôw%èLGîù[<ª1Ü—ÄÐZ›ËÊq}–³åmWn\ϧX²[ZÌê!Ïqó y'ØvÅXDÖ³¦ ”«Í÷ËŽd„ÓGºøÚÓÃÄ¢z[8ø„DM�þî8ðÚ1\ÏÇvlšÙlÞ·þÿ�SÝsÜÏí+€`S£6í‹õݯ…ÿýÒã Qí‹ÇݘƒÕ÷ýâq?wÎM÷BlÚ¶1ï6¿—zkî7¸ç­J$É~!E‰¤�p]�¹å"Å{:b¤æ=Ó´0„6�0YÉVZæôýÛ (”mª–K,rpÕt·‹íúT,Ûõùs‹ô&µÚlh_(bè*шÛtU°V±™].âm³ÀÌ©Ñ.Ž g¨ÙQC pñ’Ï£(‚‘þ¯?ËöøÉoo2³XøâvB�Œüñ'ùþ«G99ÚE,¢m,Ö$‰D"i„ó7„9!” �1îŠL›G߯‹xв!@5„¬Æ¶¥¾­ÿÅãÒ8fc[�@l·‘¼' PÅ=Çm¤ ÙhÃÆ„Ilˆ•÷l[ƒ@à×'X�s7„ËÆab£ï׿«7ö½+\†»5^£þþ+Ú_Òî ÷¹+à5ŽþˆÆµÜ´íÝ߃ðÕϵ§q=6 ªwÅÛ»bnc߯ý S·ˆM¯8®�ãúr®"‘Hö)(J$mÄA†ÿý¿YÄÛx­±ßç¦ ÷87NºÙ%÷eÇÿâ¶âK¶Ýì”|P7~_<¾àÞ¶ÜÛæMÇx@›ï9÷&AQÜóºøÂöŸŸUÝo»pÛûc³ ôómáÞè’ÓK—®/rþê5»u9Ð%ÉW)(J$m‚ïX¶Ër¶¼í\w»B4U¡3%3¾ð²¦*ôvĉGó¬çk{~úM‚¿@¾X£Tµé%¶ççÝ/ª5÷@BÖ55 wþ²JÊš*êÎE…  aPÐ5]S©Ö\–ÖÊM.Ñ4…®t”¾®8QS#_´H%LP·Þ÷ ˆš£C¾ÿêQÇ£Xš P¶îOòô©>^yrˆ¯==Bg:ŠªÈjΉD"i?„èšBg*Òv’ƒa%[æÒ�Ńn†D"yÄ‘‚¢DÒ&ØŽG¾d‘/[8n늑hª 3HÆM"æ‡EôtƈG[ïÌ—,J•Š«{@ÍrDPÔ5•XDÿÒ…„ª*hš‚®)x�O°…8 РЀJÍe½PkZPÔ5…�ž‰¨N@¥æà¸>šª´í"Gxñì •ªC¾d1µ�çÕ§‡ùöKGxáìÀ† BЉ‰D"‘H$‰D"E‰¤M(Umæ–‹8-® 14†z“DŒ/·�©Š §ã`ÅõB•RÙjùywCÍö(W�{ÂrZ�¡)Ä#÷¯¦¬*‚XDÃõ|ÿÁ‚µ@ ª B\ϧf5_�EUÒq]Sð}Çó¨Ù.º®`(mjS^~rˆ“£]Ì/92œ¡;ÝHp.‘H$‰D"‘H$’»HAQ"iJ‡ù•Žãµ´2›a¨ ö$¾Ô�¡C±·3N⠊Շ͡èRª€CQWÃ�çûTVÕ0¿R¹æné€B *E\×£f{MW‡VUA2a"�ë>8®ß´Ãñ B`*]éɘQ/¼rЭ’H$‰D"‘H$’öä>KO‰DÒjJ›ù•"®×Z‡¢©‡‚¢¡ßÏ¡¨Ð�‰‘ˆ-ù,–mJ»íŨAPµÊ-Ó" ]�EŒ/Í¡ ( ñ¨�ÚĨO ²ìæŠBˆ°K£ê!`ÛnËûõNP•0×d*a ëª q–H$‰D"‘H$’û E‰¤M(UCAñ Bž{“DŒû;1ƒDÜ zã~Q³ÂŠÉ•m„Ü$ŽëcÙŽëµÔݦ(CW1 ‚©Š ÕQ›Q¡^j;I·-èl”= ‚à¡p(B(†*ug¦”%‰D"‘H$‰äþHAQ"iJ•0‡¢ëµVx1M�Ážæ}r(B¨-¥â&™T¤…- 5©JÍ!W¨á·¹¢á=¬Ù.¾´T5uÓxpˆ®Rϡج ØÈ©(aðfñý€ŠåàA½‹À~HE‰D"‘H$‰D"‘4‡%’6¡\qXX)HÈóPoó>Å©„Ig‹E€ªå’-Ôðüö™õ}ŸbŦf·®Bwƒˆ©cêÎUE�èh÷K²¸‰ ð¼°´ª*†Ú´ãÒõ|VsU×GU!pï¡y–H$‰D"‘H$IsÈ¢,IP®8䊥Š�×BáÅ44RqƒTÜDU¬¥âû (n%TUjÙ|ß ÚzÄ  ëX–ÛòsG ÓxpÑ!š¦ kUtPA¾Ô��Š›Tª^¶KÇñXX)Q©9hŠÀ0lÛÃóžžï?©W¾j,ÁçnP£¯7Æn‰D"‘ì=m¼<—H¾:¬kd Õ–»¸Q�ŽtMÛÚµ–J˜t¦£-hÕ½Tk.ë…êCò\,‡!Ï­&bê Yo „ b꺆õ€vØŽ‡çùÄ":©ËÙ2^aË¡C±B®XÃv<"††ãø¸^ø£ª[·S"Ù/‚AcmÙÈñY­¹T-‡šíQ³\,Ç…üº0£*uA^UÑuMDM�XD'jê÷/„P¤è(Ù_…ÁF.Ûñ°l—JÍÁr|lÇö]/Á ª š*ˆFtâQƒxT h)ᦀà‘^R€%@ B‚Z[Æ IDATÏò<ÊU‡ªå`Ù®ëoˆ‹BMSÑU]W1u•ˆ©1µ�±ÀóüðÇ÷ñ¼×¾ bIö–P÷zó¨Ösl[އëyx~€c{4¦)Š]WÑ5MUÐ5ÓÇìˆöûð˜ÔÇlù H"‘Hv‚%’6`e½B¶PkùyS “îL¬©mÓ s_Š[QµÖ µ¶ÏÁÅŠu ‚bÔÔî[Tç󘆆¡©ý ÀuÃSÔÔéîˆr{º¹™¶„aêó+%Ö‹5Ò‰媃ëù8Ž×”ð)y0›]4ÛØ xøÅ‚íÐX$6¡ŽãQª:Tk¶ãc»–åRªÚäK6ë…*¥ŠC±lQ±?Ì ê ) ¦æ*�¦¡’IFèLGÉ$M ]E×Ôzq$…dÌ bjÛÊ?ºû÷»ã=[Ú/vÚÛ¿�õZVÁÞ·uskjv½XY}\­YÅŠM®h‘-T)W]*U›šåRµÜ�|¶†öÍTܤ+£+%7B�ÅÔˆ˜‰¨N4¢¡*ʆPö°ŒAÐp¤�çá>ËÁq}j¶G©b“+ÖXËUÉk+6–í†)>üM §ˆ©3u1�Ît”îLŒXTÇÐà ш†®©hj€p|?Ìì ÚþÁg»Ó¸‡Ð(rçRª:X¶‹Ó³m7ìï…«¹*UËÅvÂbx5Ë݈òQUÓÔ6rLG •DÌÇíT„hDG×t-»1�XDGSEーï}?h¼émŽ5ÉGR"‘|E�‚¢DҬ嫬ª-?o:iZPLÅM:Ó­+5—µ‡¢(K@¹â`Û­Ï1µ¦…:SW0¶È·Hà.\£�¡Þª²4Ÿrlf�S‡;yññŠ"ð¼GæQÜ5�0F?Ø^ø�"Šòðˆ{Aøž�¢lÛcq­Äµñ5nN®16³Îä|ž•l™RÕ¹;¾ÔÝ*÷»´÷8¸êátš¢Ð×c 'ÁH_Š“‡»xúT/£ƒÒIÏk3Ô íÃ{­»¬Â¾Ñ|Ç„®¬Váûwûo³qýW„ydùbå÷¡�±×­lÒͳ§û8q¨ƒXDÇ÷ƒ}ë¯{Möê£( ®F5\¸¶ÈÕ;+Üœ\ãöLŽBÙÂq½Æ³•û~N6_#�ÀÐU{Ls|¤ƒÎpr´“žŽ®`;µºÃù¾‡dKßmŠ¢ùR�Éù<×ÇW¹5µÎÄ\޹åë…¶ãl\ïíŒÙº¦Ò�‰2Ð�`¸?Åáþ$OžìãÄ¡Nz:cõœ°Ÿ2cK8&6?n ¿Jß牤}‘‚¢DÒä UòE«åçM% º2͉„QS#�ˆ‹X¶Û2�Ïq=ªUËòð"홃/\ϧb9Øn늲Â|ˆ wT3„âcsC¿íú$b&GÒM…ÅoæêØ 'wòõgGˆE4‚Y˜eB`».|:¿Ùâš*B1Tž:ÙË‘¡L‹Zy04ri æ¥�]*påÎ 7§²L/äYÍU(W]ª5‡ší†®×o*”ãl˜ê NßóY\+³–¯r{:Ë—çøÏ¿Sôw'86ÜÁ“'{9q¨“îLÏ ö\\l„ë] ÛÝú³14Fú’¼pvpÏÚ±B¦ó|vg…JÍÙò{DSº2Q¾þÌ0¦ÚÚ)«íxüîÂ4ëÅ­£4Ea¸/É‘¡4}�qØå÷Tãªø^@¡dq}r�Ë·–¸6¶ÊÂj‰|É¢²Ñ�CwV3_Éý·¾m¹ê05Ÿg9[æü•yþæ-“¾®8'wòìcýœíb°'±!.¶›€ÑÐk¶Ëå›Ë|rk‰›“kÌ.)Uì�ðæšåmÄ[qï5 ¨Ù>³KVÖ+\[á7NÒÓct0ÃÙcÝ»½Â­é,KkeJ‹ªåmŒÙv½ÀÛv®íÇl°m—ål™\±ÆØì:SãïÞ#�4ëcv†S£]œ<ÔÉp_  Þÿ÷ö½ 7'טZÈ75ΘºÊéÑ.?Þ³· ‘H$’] E‰¤ X/ÔÈ51™ØkÒq“îtsEED �žLŒåõrËB{ƒ tÊɸ�ÚâEe3¸^†õÖó1µjÉ¥ˆP 4´æóµE CoN|¬Y.‰˜ÎÑáb¦F©l7-$/­•¹3³ÎÌR‘xTÇq})(î–íñÞ'3ÜšZ§ºE U¤“aº‚£Ã-jak ‹¨„œnOeŸË15Ÿgv¹ÀÂJ™ÅÕ0ü¾¶O“BñÉvÂülŽåº¦�ˆ\_å“›KêOqd(Ã±á ‡ÒtwD±§Ââ�‰U~úÎX½&�0yéñA^|¼•‚"L/æyóƒqVÖ+[醮ròp'/=>ˆi´¨‘„}Êr<~{~Љ¹Ü–Û›†Ê‹gHÆtz;›ûNýâ9¡¡x”*6S ¡ >6»ÎÌb�Ù¥ «%*5wÏR€ø~(ÆÕl—u€•Ó y¦æóܘXãÈP†S£�œ=ÖÃHŠd̸',õ h¸ÑÆf×¹>¾Ê�É5&çóÌ,XZ+Q(Û{x>°«>Y¢ÌìR�ñÙ7'ÃÏöé#]œ9Úñ‘ Õ]‹¶‹ëHÛâiäA\Y¯p}b5¯— ̯”˜[.²´V¦\sö-ÕM@Rí¸þ=c¥ª î̬skr� ×îM2:”áÄ¡NNî¢+­§±Øçbý­ÝšZãýOg™˜Ëo¹K2n ¤ (‘HÚŠö[™K$_AÖ µ–:[R “®Ló…V"¦JWœ\±ÖÒ\�ž�+Öè ÒÛ ×ó7ò¶E$¢ê6ò´F˜çmc�ó€9q¥æ�NšôuÇéïNP¬;>š¡j¹ŒÏ®óþ'3|ï•£÷žS²+\Ïg|6ÇÕ±•-º21Ö Gë~Ò("áy> «eVŠÌ,¹|k™+wV˜^ÌSÜCQa'8®Ïz¡Æz¡Æ­©,±ˆÎp_’'NôòØ‘.Ž�t0Ø›¤·#NÄTÙ\,f§,­U¸zg…beë÷Þ™Ž2Ô›ÜÝ wÀz¡Æ�É5æ—‹[ Ц¡¢©o!�{ACp¸=•åúÄê–ÛGL�þ®8¥&ÇÇ/œ¯îà.–mæ–‹LÌ­sùÖ2]]`bnÛiÝû¯Z.S y¦ò¡>�âų<}ªŸ‡CÇ–® T¥uùAán^ËRÅa)[fn©È…ë ||u�«c«XŽÛ2W åx,gË,gË|v{…Oo¥yþÌ/œàèp�©ñ¨Nµæâ¸ÁFÚƒ¯:®çS©¹Ì¯™[.r{*Ë…ë ܚ̒-„…ܯî.”,î̬cè Ý™OœèáéSýî` 'Áp_ÓÐPö búÊz…;ÓY®�¯m¹m&á¥'Z÷H"‘Hš¡ýVæÉWŒ €õ¢µ§OÔ›AÓR ƒT¢yÛGÄÐèïI0>·¾gíhÆíàyáÂܲv²y?Ç£X¶Z^8FÓT’qc[áȆ¦ « Š"¶t†‰ý"¦ÆãÇ{Èæ«M ŠSóy~õÁ/œ¤§#ŽªŠ‡.Ñ¿¤½hˆ ¶ãQªXäJ¿ùhŠw.LsùÖ–Ó\èçAP©9ÜšÊÖÅE�c#�üÁ׎ñÊSC õ&‰†¡¶Ìá,9Xö‚  f¹d 5nN®ñÓwÇ8ežåµò�?ƒ)Ul®�­rml•c#³|ë…Ãüè�“ôwƈnj–òý ,ºa{ÜšÊòî¥~þî×Ê>/p=Ÿ;ÓëLÌæxóƒqþÞ7Nðí—F9s´ET«‚ªï´í¸´ŸÜÓÇm�õB-œœçÜå9¦æóÛJ9ÑjlÇg~¥ÄüJ‰·>šâÈ`†Wž懯c¸7E<ª�èÀþc’H$’‡)(J$ˆç”+vË««ªBW:ºÛ¶á20t•¾®8†ÞÚ¡Ãõ}r% Ûi}åfp=ŸBÙnùÄXˆFt´m†LjšBÄÔ¶ —õü€jÍ%bh¼úÔ07'ט^,4}žrÍa|6Ç/Þç;/�rj´ ?]sÉv «ˆ»®Çå[Küöãi~÷ñ$ë‹rÅÆv¼`š¥j¹Üœ\cf±À/?çµ§‡ùî+G86Ü�®©²ŠçW„F…áóWøÅûã¼÷É,ŲEÍrÛ®/OÍçù«7oðÁå9þá÷ã•§FîMî{_ ê9WÖ+üùϯòágóÌ.©X^¹B[…ç¬,~òÖM®�¯ò½W�òýW�‘IEЫ ùRë:4�àÕûø[ç§øÍ‡“||u�JÕ¦j¹m-&~ÇõŸÏ±”-óö…i¾ùÂa¾õÂ(ÏŸ�b¢D"ùJ#E‰äq�åõ2Õš»­J’»EБŠ«?YmÃPéÎDзY c·ø~@®�Š®O©êàµ8äYU1SÛVÈ3€¦*˜ºŠØ[t»šíá¸>g�÷0ÒŸæêø*•&]ŠAù’ůÞ§»#F_WœŽTEHÁDÒ<~}±V(YÜžÎòîÅ®Œ­01—cqµüPææ ‚»y­I—|±Æõ‰5ž9ÕÇןáø¡NTeoó+JÚƒ ^…Ùu}&òüöü]™çöt–ål¹m�l®ç“/Ö°l—?ÿù5¦ |ã¹C¼pv`ÏŔ͑ ã³9>º:ÏÛ¦¹=µÎÊz™Z›Îü  X¶¹>±J¹ê0»Tä[/ŒòØ‘.2IA±l……´Úôï5«¹ ×'Öxçâ ׯV˜Zȳš«t³vŒëúäKåªÃ›L0>›ãÜgs¼ñüa¤HÄŒ�Y‰DòUA ŠÉâ¸>‹«å-�b{�‚®t”xl{Yî M¥;k¹ è¶{ȳëS,[-Ú®* ±¨¾í°3MU0 Q¶¶\×X¶K©jÓ×çô‘.nM­qåÎJÓçr=Ÿ‰ùï\œ&7ùöK£!PÛ°b¨¤½häIt\Ÿ±™0§ÜÅ‹\¸¶Èr¶|àù¶öŠªå29Ÿg~¥¸Q�ú¥'‡xòD/™dMSdô#B€ïûT-—Ë·–y÷Ò ¿=?ÅüJ±mE²Í„ýõÚø*•šC¡lgŽö�ŒïMÕœ†àjÙ>ŸÝYæýOføàò—o-?4޶RÅáÆäÙB•šV'~þL?žÞ{Ëñ¨tS÷”ƽóü€«c+\º±ÄÅë ||m‘BÉz(þ|®ç3³T`e½ÌØlŽ•\…—Ÿâ‰ã= õ&åÜF"‘|¥�‚¢Dr€ØŽÇâZyߪ�ÞEº2Qb‘í †®Ò�‰†bÔ>µíËð<Ÿ\±VŸ„·ŽëQ,Û­ g¡{)fêÛv(ªª bj(ŠÀÛâ’:®G¥êR®:<~¬›¹¥7'׊ÖMž/àü•y`¤?ÉÑá’qCŠ$’â¸ù’ÅìR�_¼7ÎÛ¦o¢âî^!¡q×)ØX,ïÇgÝv|î̬³¸.RK¯;œ9ÖÍ@wœÄ6þHÚ� ®_ª8ÜšZã'oÝä÷§÷¼ ›áw<„îþýúVšœÏS®:Ë6ÿègxìh7Éz?Ý© ¡Ë/_¬1>—ç¯Þ¼ÎGWæYX)íeÓï½FA°/®PßX\-óësËÝQz2QbÏ™"›q\�BÉff¹Èÿû뜻<·­4)»E ( ŠöÁF ü`_îsÍö˜YÌó×o˜šÏ³ž?Êמ¦¯+NÄP¥°(‘H¾HAQ"9@×cqµÔr‡¢ª :ÓQâÛ yÖ5…TÂ$bjhšŠë¶fBìù„•¥[|�šÅõ|JÕÖæP"Ì…iÚ¶sêšJÄÐê“Ý·9Â~šÍW96ÒÁ3�õó‹÷ÇYËU·å6¨Ô\>½µÌ?ÿ÷ñ¿þw/sæX7(wuÉfü `-_åüÕþíO>ar¾@ÕÚYÝ�¢i ¦¡3u¢=tM9•ªCµæàîÓç½T±yÿÓY.Ý\â�ß8É~ãÏžî—‹Ó‡œ (UC1ñÿøwç¸3“£RÛû>mè*†®¢A¥ælëáÏvYY¯ð«&PU…ó$¯>5¼«0}ß°— ×øWu‰ñÙ½¿F0u ]ÄY¶‹ãúûj¾–¯òþ§sdó5þÙû"'w"A6Wݲ0ÚÄ߿<Ç¿üË‹,®–ö¥ß!À45â�ˆ©¡k ®`Ù.5Û­§˜ð÷åa�ë|øÙ<ã³9nMgùÓœáÄáN™3Z"‘<òHAQ"9@×géŠB:SQâÑí»^„t$Mb�B©5‚b¸T-‡jÍ%ºMgå~b;ÞÆdZ'�…¢àÎrªŠÀ0šw5º^@¶P#Õ9<�æ|ï ÿé—×XΖ·uÞbÙâúÄÿöÿû”¿q’¯?;‚_wÈ)·îVÊ<už·>œä÷ϰ”-S³÷vaª*‚Tܤ¿;^ÿI2Ч¿;AG*BÔÔÐêÕЕzˆ~@(°{~¸(u½€ªå’+Ö˜_)2¿\bi­ÄâZ™…•RXù}‡kWߨV~õþ+Ù23/ùÃ×O ùyyØ‚?€µ\•·/Ló¿¼ÆÄ\~Çy"f0Ðg¤?ÅH_ŠÑ¡ Ý™ðûÜÔÕ0D^€@àû>®V‘.”mf Ì.˜^*pg:K¾dáì²°‰ëù¼{i†tÒ¤3åÄHÚ6 5œ¿–íñ׿¾ÁÏßclfkÅê4U!3ÂëÓŸd¤?Å‘Á ©xø04b¨hjx�O×õ©Ù¹b�…zUßéÅ<“óyVJ»Õ-–-®ÜYæÏq•¿÷õã¼pv€šåR­¹}pX¶Ç�ÉU~ñî8ï\œfn©€½Çœ£¦FOgœ�®pÌèN2Ø› +%304UUê.EQw(Ö�‰õ´•ÕšC±b“ÍWYËU™_)2»\dv©ÀòZeWQ0A�ÍWyóÜ8ËÙ2ßõ(?xõ(BWe.\‰DòHÓ>«r‰ä+ˆíx,®`ÈstûC€‚t2B<¢S(ím¨Ö}ÏI8Y«Ô\*5§ýÅZXܤ•"CSˆšÚÎEUÔŠÍmaxV¡l“ˆ|ã¹>½¹„e»ä·Ñ±Š» OèëŠ3:˜áÄ¡N†ztwÄèHEH'Lº;b$c†®¢k*poÅÙPüs÷esUÖ‹5Ö 5Vr¦ ŒÏ®sg:ËâZyÇââz¡Æ¹ËsÄ#:½?z‚t¬·¥9«r‰ä+ˆãú,eË- åèºB&i1v0é„I,º½pé½ T±(Umº2Ñ–Ÿû~T-—Ê„boVÙÁ US ]EU±õy!ÂW±lc*#})ÞxþåªÍ'·–ð¼í-VsUÞÿd–BÙÂu}Î羚3†éÉ'ù_I‚ `a¥ÄÅë‹üõonp}|•Ü.sË ™d„¾®8ƒ=Iú»ÊpâPG‡3t¥£(ŠBPÏ­µÝŸ"ÀÔUL]%�0ïþ]8®ÏJ¶Âí™,c³ëLÎå™]*°°Z ª5·épÇl¾ÊÅ달æ*ØŽÏ«O 3:˜FUëyå*µÍ¸{?»½ÌÄ|ž•lyGN­‰¹o_œá‰½<}ª�îŽXSNÚ XZ+óágóüõ¯o0µ�£ZÛú;Uèéˆ1Гd¸7ÉÉÃ�œ=ÞÃñ‘ú:ãøõ8›S‘A€ãz„öÄ þ7¯š¡«ôvÆè팅ãPª:Ü™Îrel•Ë·–™œÏ±¸Z¢P¶·}�×Ê||u�žŽ?zý8SÃó|,§=Ó¹lÅr¶Ì'7—xóÜ8ï\œ%W¬íêx†®Ò™Ž0Ø“d°'ÁHšã#œíd°'I<ª‡÷¬~O…²=·¶®)èšB<ªÓã@z#õ‹ëúÌ.¹3³Î�éu&ærÌ.X\+³œ-ãzÍ ¿ù’ÅÅë‹Ê6¦¡óÜcý t'¤¨(‘HI¤ (‘¾P³]Vs» ³Ø.š¦�êÄ£:†Þ¼ƒ �‚LÒ$±ƒpéÝR®:”«­Í£¶5Ë=�ÜŽº^w(î`ß°Êr8±¶cAµ–íR(YDM�¼vŒÕ|•™¥â¶CŸ!Ì+õÁ§³ÜžÎò?üøi¾þÌ}�qLS•y¿BAèÊk¼}aš¿üÕunN®í*©¦*DL�xTç©S}¼þÜ!^zb�áÞ䯂°ÑÅ„ØAîóB¸®) ôÄéíŠñús‡°l�Éùo_˜æ÷g˜^ÈS(YX¶ÛTXt¥æpcb�ÿ·Ÿ‘+Züý;A×å4®ýo¨_w¥]Ÿ\å_þÕEf X[Tr„¹;1ƒÓGºøæó‡ùÎËGè鈡©JXŒkSWÓ¶Y�ën? ÿê Ëoÿá§Wyë£InOgw%& àèp†WŸæ;/Ž2Ô—"71t%Ì=¸©c5+¤ï¸-BÐÐ{ ]åÈP†¾Î8?|í8ŸÞ^æ÷OñöÅi %»é5LW’Áž8êDÏ øÝÇSüòýqnOm-&ôvÄxö±~þä»�qx Mg*B4¢ax¹ïù÷Š„;Ä÷}|7ü,kªB «E~õÁ±ˆŽ à±£Ý-z’"À¶=Vs~óá_[xàw–ªbQ�gëçëÏŒðÒCé¨;¥‚–:µ•¡“qƒ'Oöb*f]ܹ>±º-±ÿúÄWî,óÊ“C¤“æ=ó� Ãe§ üä­›|rs‰µ8!uM¡¿;Á‹gøæ‹£^||�—$�Œ k‚Í6Ü6²7>ìIÒ•‰rt¤ƒÞÎ8ç¯Îóé­enMe·œÇW-—;ÓY~úN@_gŒçÎ Ð�‰²ÉÏ,‘H$-RP”HˆRÕf5Wi*Ö^:ã»p(B2f‹j¨ªÒRA´\µ)Wm‚ }Ü8–íµ4ä¹QW¬‘IFxìH?øÚQ„¼{i†l¡º£'Ë—çYX)³¸Zæ�ü€¡¾$©¸‰®)msÏ%;' �‹e›÷.ÍðÓ·o36³¾í㨊 7éOñ�gGøú³‡xòd/„.±6ï+B„ïAUT^<;À±¡ �ŸèågïÜáòíeæWJÌÍê¸>sËE~ùþ8~�IEé鈵ðH¾ŒÀXY¯ðö…iÞÿtŽ™Åû»NuM¡§#Æé#]üéÎòøñ^2Ió yœ<܉¡©DL�|Ùbqµ´eÈÙ|•ÛÓë\[áÙÇú‰G�M‘`)[áüÕyþöíÛTu`*‡Ò¼üäÿøÎÒÛ' £#ª~—ïÔ<ÍSˆEu¾õâ(¦¡R©:\¾½L¥ê4U:.\[`°'ÉãÇ»1 5¬ ÜêIá4*•×ì0×å›Lðþ'³Û®t­*‚ŽT”#CÞxáßxv„ã#�÷æÚlÓ±[0 �¾®?zýÇu0Ü—BUÇ™],PªØL×Q¬Ø\_åï�c/?9TðÕÂ7!‘H$û€%’¢X¶ΡØCßE^]W‰EtbQ�rÅ�mO-wF¥†<»ž�¦¼ÀäûUË¡f·Ö¡¨)M;v™B89Žšú®òÕ,—õ †"à•§†I'L"¦ÊßüöVÓ Ï/cr!Ïò[>º2Ïÿøžãù3ýt¦·Nð/yª–Ë{—føõ‡“|zkgù·’qƒçÏ ð¿ü“çìN¯ç™{˜Vg›Ç¯Ît”WŸæÅ³üÇŸ_å¿üî67&×¶<Æ�™u‚ ô¹üã?8»ŸÍ•4AÅr¹6¾Ê¿øË‹Ê‡ìÎÄøæ £üÏú±ˆ�®)mUå^‚#Ci¢æQJ‡¿{û6s¹¦÷Ÿ˜Ëñæ¹ Îë&ÛÑðñµþËïîÔ«�ùþª"êIò§?8ËßûÆqâUm·ºãúË6‰˜Á«O ÓÛçû¿Ïôb¡i7ç�éu®�­°^¨‰h8®�í¶WF„sçVJüù/®ñÉÍ¥Íø1ƒ7ž?Ä?ýñS õ&w•²å ÜÕ;Oîb°'ÉKO ñýÅÇ|rcq˰wÛñøÕãt¤£ õ$95ÚÉÃô�%‘H$_†%’¢áP<ˆŠÝ™è®Ä(€XD§# ŸÆ·è-!°Ÿ|Ñ"“ŠìÈ]·Wø~@¥æb;aQ�VNŒ#¦¶£ Ý›QDX½r'!Ï›q\�µ|èHìMò÷¿÷™d„7ÏM0>ÛüâóóÔj3‹þÏ¿ø˜¯?Vò|îL?ŠÛû°-D$ÿ?{÷õ$Yvøý{®M_YÞ´÷~z¼ŸÁ3$fA$µ"WÚàj—!)¡}Ó°¡=H!…B¢´±’°äR aI‚0„Ç`¼ëž™žžö]Ý]ÞW¥Ï¼îèáf5ƒ6™U™7ËœOÄp2óæÍ[×üÎÏÔ§Þ–]F'—ùöO/rat¡é÷vexõ¹¼úì~v¤±-cCbÖ"ú Ð4ÁW^<Èpš¿ûÅeÎ^š¡ð€‡Ô‰¹Éßüü2Ÿ]Ÿoèõ˜Z(òö'|áñ=˜ë¨i)Ãï8:™ãýp}|¹©žË–©Ñ×�äO^=Á �îb¸/…mê›þ®i‚DÌdߎ.þåŸ>Á÷_¿Ê�ÞºÎÌBé¾W?�¼qf �ä¿ùOÇq£o™£(ŠÒJ* ¨(e©DP´�0 ¸Î×dÜ$›Ž1=_Ä�ð;Ô\�•b�tÒZwPt=))Vœ0!Âc!qÛÄ2×wú,K_wPVJ‰ëäŠ5À¦/›àÕç`[¯Ÿã“+³k 82ì;4:>À,¬T™˜-ðäɆz“hºP“m7™@JF'—ùÛŸ_æÊÍ%òÅ7´¿S:a±oG7¯>·�gNïäà®î ÷¾V«‡²&ƒ½)ž8¡· úº¼~ê¾¥³5Çgr®Èß¼F¾X[S_3e}p�yx·m}Ý�!¦®¡×³¢ÖSz/€šã‘+HüÀf¸/ÅËOî%•°Bpm|‰bÙYÓ±.%ܚα°Rfl:ÇJ¡ÊãLJٻ£‹Ôí2We3˜ž/òÁgÓüô½äŠÕ¦^Û•²9¶¯—ß{f?/?µ—�ž$ˆ�1l¥•V¿Mw‚ÝE"fb:¯}x‹é…â=_çùŸ]_ >ËC‰X8É5‡ç÷ì£&ìHó£»ùê‹Ù1�ÆØ@%¼÷T�fv±ÄÇ—gXÌUðýh¥ŠÃ»ŸN²oG–dÜâÊÍEÞ=7ÉØTîîŸ$àÈÞ^^yz?_~áÎ2ç�½“\×G†z“vf(”ÊÕ—Áú�äÍ�Æ1t�Bɹk.V2nrpw/_}ñ0ûwf×=,¬¤”8®�ïôdâ=?OÓvvóåòÌéôvÅ·m¿L!©„ÅCGù½…"5Çãõ3c?*�éÎÄøòóyøÈ }ÙĦ>7Ù–N6c³g–éùBCE€ÉÙ0“ü^G쾑,¯<µ—Ã{ºÉ$­M»�d)ëJÙ<|dˆ_¾‹E|�óƒ€É¹ŲúÐ5¤ô#]t–R‚B¹Æ¥›‹üä�QsÍ <²·——ŸÚÇ‹�î"7o/òm'B vexñ±]LÌæyýì8·¦ïÞ7TQe³SEE‰œ`¥PíLÉsʦ+e·ä½lK'�°°-�ªã"#Z|u\�BÉé|E 媋eEº&0ÍõOg^e™­Ëvü¼p´ {^’¾lœ?zåÙtŒLÒæì…VÊëZ¹ŸY(±’ãÚØÿøU‡ÇŽ ±{(C¦Eǹ²v�”ŒÍäxû“ ÆfrMe¥t'yúÔ¾ùÅ#¤jø4/>º›•B�kãËLÏUÖË—ˆìéâk/b¨/‰DÖ @7')%¦®sdO—o.25ߨ"ÁýBRɸɩüúìþ-‘…ìùɸő½=¤“º&ü÷}ÉÔ|‘BÉAËÐ é}Ž‚ ‡ }|y– £ ¿V�NÚ¼øèn~ÿÙýd’Ö¦+Yo¥°?µÆP_Šo¼|”BÙa9_!¯†¯(вmß³½¢tˆ”’å\gŠé„I:Ùš@K¸ +èNÇÚ’áv/ž/)–]<À—ž;@<¦Ö„7º]Cž<9B_wÛÔ#oÒ–¥sdo/½]ñ–¼ßÁÝ=ßßÇP_Cß|½%?Ïó$B@oW‚Á¾TCAR?�Ì-•È—)±-½#agMÓøàü4ïž›jêu–eðĉa;>Ìžá.µhB�ˆ™Û×ËßÍ#G;½IŠ¢(m¡îF¥V UJ•èKžÓ ›L²5%Ï–Ëvglrejkì‰×,)e½¡‹ãØVtÁÌU®P­…½’Â"¡hhZxƒÚÊþR¦¡cFÔÞq}–rÇ£'ã«/äОn¾óÓK||y†byí�”Tk¿úpŒéù"¿óø/?µ—‘�4B°%ä7 )ÃßãÊÍE.Ž.0·Tj8Ó&fìÊðü#»8y ½E™¸[…‚ý;²|ñɽ||yŽ‹7æš¶«DOÁÑ}}¼üÔ>b–¹%FÜ !°L�;»é銷¤Ùɳ§wðÐá�úµ4Ê+j{øA€çئÎp_ŠLÊn(3-$+ù ¹B- >k"*€�2œR¾°\æÒÍEÆš(Ï5 �áþøÊ1Žè«æ›û7l%!à©S;(–].Ž.²”¯ªÌrEQ¶PT”ˆI)Yî@ɳ�J˜¤­ (ºN6Ç2ò-{ÏFRR,;Ô¯#EϨT]‚ ÚÛf]Ä,£¥«ÿº.0Û˜¡x')%®ëS”’dÜ$“²yò䆮±k0Ã[�3µŽ2N)a)WáÓ«sä‹5r^xd§ `[ú¶ißya†Î‡føìú|ÓЅ€�žøòNè'™Øüå�­&$â&{Fºøê‹©T]>mrr¶�½#]Ý×Ëîá º¶™ �“¦i·ÛVÄlcͶlS§¿'Á‰ýýìÌ[c Öj©r kôeMÝs-å«Ì-—Ø;’�xLRª8¼ùÑ£ËáPµíÊð{Ïìçè¾^ºZ¸`½UVòßßÇï>»Ÿ¿5ÊÂJ¹Ó›¥(ŠÒ2* ¨(Q‚å|•R%šŒ¾ð3Á2u’ “x¬uéº.èÎD[ò á {¡ìPëÀ„e'2–*Nä%׆®‘ˆ´²šÈÐ5t] ë"²2a× 3L5Mc°7Á‹�íf 'A&eñÎ'“ܚΑ+ÖÖüþ¥ŠË…Ñ–rWÊ+'ô“MÛØ¦ØÒn~ )W\>¹2Ë�É•†_ו²9²¯——ŸÞÇ`OrS÷šk·TÜä¥Çw‡ûxj¥#-4”{BðÈÑAŽíë#no­sŽ ¼ö§“&餵æ€âê‚Ò®á ‰˜±%‚‰ýƒz–v_6ÞT_È•B…¥\…;»#]­\ÍŽ|íÃ1¦æ ¿.38º·—WŸ=@w&VÒ§ÜÍÎÁ ¯>»ŸK7)Uœ5ÿÝ(Š¢l4ê̯(óý€|©F¹Ý ®i¤qÛDoqv[6mGP”ÐÑ€¢ãú”«.AÄSV5M�™-¿i×DtYŠ«ü@â¸^ý³5:<ÈŸóaþäÕ㵃Þl¼€�¦�B–�É0»@@J¦Š\ŸXnøoS×é¤}»ßR¢žN'�þl‚§N�pöÒ¬ (né¤ÅC‡H%Ì-zÎ�hZ¸¸•Xã�·Áž${GºÂÅ"�?‰Õ–qÛÄ4¿^WªŲƒÐº®¡i¿�URÂìb‰‰ÙK¹Æz' ]i›WžÞËHŠ-ù¶ÙÁ]Ý�ãò­¥†_“Œ›aùW_jÍÁ‰íL‚�ž�!;ßéÍQ€dÌäðÞ’±­™¹µzî³,cÍ‹”ƒ½IììÆ2·æcˆ¬gÚ¦†©7P¬¹”*.ÈðZÓÎ5>)%ž/¹6¾Ìäl¾á×z¸@ýèÑ!z³[³Gh;iš`ç`š#{{HÄL|?ÀWYŠŠ¢lbj(‹¢DÈñ|æ–Š8gÖéºFo6ÙòŠ«ÒI‹d|m¥Ok!¥¤êxT/òòñ°L7 êx‘–<[¦ŽÝ¦Òr]a–iÄÏR†Ùž� IéÎÄxùɽüË?y‚o|ñHK¦@ߘXáo\ãø¿ßal:‡Üxóë`â¯ÿ»Ê¸?!sËnL,7üšl&ÆS§FHÆM5,g�LC£;m³oG–îÌÚ§î*ë³t2I›dÜDo"�´Å,�Dlm×ýt¢·+Þâ-Ú8)ÃLNËÔî3]­ya@‘z_á6_”uM0>“gf¡Øðk†úRß߇e騋âÚMЕ¶yìø™56REÙ(¶æÒ ¢lP5Çcv1úŠF½‡b»Š©¸E2f�+´åí‹a`¯æøTj.¦Ý ™ãùÔ?ò¾7–¡µ|J÷ªp(K¦x°˜çaK�ÚyJ\í<1[`v©ñsöHŠÓ‡1�h{go5™”Í“'G¸1©ÎÙŠ¢ln[ûŽGQ6˜še‰6C9é#{ IDATQ`èÙL¬m©T"Ú ÅUÕšG¹í`–JÍëHïÆ0C±=`º¦a:š®eÆ�|_6”é– 2I›Ó‡ùæËGøò 8¶¿o̓ ,žœ-ðã·¯óó÷nr}<Ì®Ó4¡zù5AJXÊUYÌWê6· {ÙÓK*n©Ý½F’0 ýðžú»“�Þœm­;£¿;Qô´µ3nmSo:(¨ AOWœtÂÚÒ 7¿þý%¦®5ÜGÑd¸XY?tÚYN’JÍcv±ÈJ¡±Þ«š&Ø1�áÄ�>L]Sçìµ’a–î£GU†¢¢(›Ÿ (*J„ouÊst©Õ ¾™¤ÝTsðf¤ÉxôýÏ*5—bùÁ«þ­T­ùTט·¦¡5\6Õ,!Â, =â)ïßCñnVÈìHóçø_ÿ�#œ8ÐOÌ6Öµí·¦óüèíQ¾ýÓK,¬”ñ½Méà�ÍJJpý€[Ó9–lìЗ�³k(C*amùòÐvZb´w¤‹UòÜQÝ™ý=‰mÑÍ2uì&«„'¬b-}}5M0Ð'3¶IÏTWÑ?ÂV ~xüˆöì#)¡êøŒÍäšZ êMÒ—Mlùc; š®�ô÷$éRçmEQ6±­ÛÀDQ6˜rÅ%_rp=?Ò‡ç˜eÐ�‰µu47IÄÖv:YÏ}i¥æQªD›¡X©yÔ:�¡h™z½ zû˜†Ž® ¢úv«?}àK‚ô&¾ž]¤’6§ö“° †û’¼ùÑ8×'VÖ´=¾/™[*ñ‹nb[•šËcdžÃò5àvšò›ê™œ³K%ò¥&Š]q{“„ûU= ®‡�ˆ[d’a¶x£eçJkeÓ1ú²‰m±! é¾»Bú{’õÅŸ­½�Âë†D49\eµ·0²}Š’°eÌÔ|©©Ò¡¾$ÙŒMhZû©·…°"ž‘þ}Ýq– ª�¢¢(›“ (*JDŠ—\±y&NÄŸ>2ˆ®k¤’oœçüõ‚ ùL`× ˜Y(ñÚ‡·êÃŒâŒô§°L]…ïA«�Á^Ì5Þ? ¯;ÎpZ=”¶ˆ® 2)›îLL;$7ɤ¬mqLkšhz¡RÓ½Ù8©„µ¥Ã‰ámž@Ô3›¹ÿ $ø~Èöµ ‘²>$°ØT†âH_šžL8�{;ãíHIw’îLXîôæ(Š¢¬‰ (*JD åZGV WKžÛ™¡˜NÚ$Ñ÷P¬Ö¼Èœ« (¶·ä)1 c A½õòý ü[{0ÓÐ5N¤+£;£Tu™^(R^ãñqcr…7ÎŽ‘NX|åÅôw'1 MåÒ݃”’•|•J­ñýÝ›M0Ô›Tý£ZDJNîŠ31[èôælKqÛèÈâZ'hš'7A èJÚ$cæÿ»û J)ÑêÙôM¼×oo[!ÂþųKå¦îg†ûSôvÅÑÚ¸@½�H)ééŠÑ¥³(≩€¢¢D¤XvÉ58I¯•bvXòÜÎÅäd=C1êëJÍ‹|(KÅq·ÜP³ ,SëP†bÐ’!Ø;ÜEú¹ƒ ô¦ø‹ïœåÂõ…5g_¾µÈJ¡Êž‘.=¦ÓÓC*¨x7RB¡ìPs{8@6eÓÛ¥†ˆ´‚$ü â¶A*±=ZQÌ2:2 ¬„M—v ö\nv˜Ëf´Ú(£ÙLN)%¾/ÛÚcR�ëÌ-•È02�¦¯'¡¦;·€ ëÝ™˜:g+в©©%&E‰H¡ä�+FŸ¡˜° ºÓ±¶–<ºFÌÒÃ2¦o4k5?òŠÕª×Ô øzIY&z[¦!0Ûü÷rMåÉw#t¥m?>Äþ•“¼ðÈÎ5“RÂJ±Æÿó½s||i–ÕMTÏR¿MJI±Á€¢ \è0 ]•͵ˆ ,Ÿ3ôö¶FPîÍÐÃiÛ�8‡v‚&šïð'$¶½õŠ«4A“ÅÕ’çö Iµæ6µ˜—Œ[Ä·A08*šôeã*CQQ”MM]%"ÅŠ³e3!ÌÌÈ$mª5Ï�¦Ûœëù8ŽO¥æa›z[K¨V³j®é”nM«?¤í~H•a†bÊÐ<¿5Š«L#\õú¡�@˜ðî¹I*kÈ,­9—n,ðÆGãt¥c€µ;ë%,yÖ;P $M=Ô4B‚ÁÞO?´ƒ˜¥Ss|.ÝX`)ßÜß ”P®z¼~ŠLÒæØþ>â¶Ži¨‡ªÏóêÃuD"9¦·�Õlf•¡9MR kîû殺.°-}M¸¶›vï¡@JªŽ×еWˆpè“a4?ˆG¹?ÛÔ±T�VQ”MLÝÍ+JDJåÎe(fÓ1Ú}h›z}št´7›žç“+Tq½ö1}? Xqð"(EºSXjF�¡f7éºBD:ѸÕŠ«Â b’gÞÅŸ}í!Nè_s9èÍ©g/ÍpéæåŠù´öÍÀ÷%�ì–0Hnbnñ,¥È‰Õ Eõp5!©¸¹­ö½¡Ñð5_×¶¡cz[[°(�‘�Äqü†Ú�hš 3Ñ…¦Z~´˜®7ß‹TQe#QWtE‰H±ê’+EP´-ƒDÌ$3Ú^Zh›]Éö—VžHòe¯ÍeÈ~ )”œ¶Îç –E‘Í¥iÃÐ0ôhŠí ÐiB�NZ{z®ç³˜«pcr¥á‰Ä«r…*Ÿ\žãÜÕ9R ‹Áž¤ ÞPÏTað¶Q’Ʋ•Ƭîzßð[4àHiŽ©kê|p†®‘Œ›jm«Sº?m«v;hš†&¶×B„¢([‹:ƒ)J›�¼LlwYîçu¥l’q3’Ï Kžch—2¹~P/yn†b±ìàG4pf•Û2"[Á6 =úÌ1fK´+(¼ƒ½)ž<9Â×^:ÌÎÁLÓ剮°°Ráͳ\¹µÔ–mݬ„hõDÊp! PÅ–Z�ëº* 5A8ü©™ úv£i‚˜¥«}´AA8D¨�ß#$5ÇG¶yáv;òu-TesSEEi³@J–óU*U7òŒœtÒ"™°"ù¬˜m’MÇ:ÐC1 WjoC¿žeÕëUQ–<ºèÈÀ‘  igoB‰dç`š¯½x�çNïd¸?Õtv¬ç¼{n‚ רÖ<ÕKñfÃSÂ%®ëGš»Õ…‡¡Äó‚H§Ð+uB„E]Sç„{ЄÛi¨x↠Õ+É I¹êâ«c»åuÎVe“SEEi³ �,ç+”kÑ–;d’V„Š]©èû#ù�$_¬µ=8±š¡yÉ3Ñ e�°WR'zÛù~€ëmº‡™Š©¤Íñ�ÓC1�´IÄ¢™½¤iÓÔHÆ­H3Üü  PªáºíÍ[¬ÓîÀå�4M`[:z„“[ ]ÃìÀ¤ÒÕŠí ×�Ä÷Ã~Eš&xèð_xl7=]±¦3@'fóœ»2G±â¶} Ðf 4AWÊ&f=ø|#%Tª•š‹£‚Š-!DøO¹â’+¨€bäêÙwáÚ‚:Ü�P%φ$<^³éXS÷kŲC¥¦2 [©RópTV¹¢(›˜ (*J›ÉÕŠ¸ ‹r( „å²])ÛŽn€|T�šãᵩ�H\/ˆ¼)¹®ká@–ÝèšÀê@ÉsH¼v÷P”«ÿ„�Å�ž§òøñaº’vSï•+Ö¸5�ãúÄ2…Rm[—�IþŸîL¬áŒÕ}© -[•‚bÙa¥¨ŠQ€¡ièšê¡x/šËÂUFwçÉ@bè}ÙxS óË%V UuŒ·€$\HÍ«ê:¨(ʦ¦ŠŠÒf�„åBgŠé¤YÉ3„uÙt¬éé¹ë#A¹êRmÓ*¯ãxTª.A›3è>Ï44â–éX§Jž¥”m ßþŒ @Êð3ü@’�Þ$ô»ÇìK6�9³R¨òÖÙqæ—ËÛ|JcøÝû²qRM �*”VòÕvmÔ¶ãÅŠK^;@Ôû†ÿ]ùmªäyã�H ]0Ø›lê~mr®^ï©‚Šë.lJf—J,©ë ¢(›˜ (*J›ù~ÀR®B¹+�aÅè25]i›X„ŠÞ˜+.U§=哎t$ ¬k±˜i‰˜®…Sž5-ÚL’°ä¹½CYê‰t·hý@’JXR†Çâ�©õ`ªlXBpûܰÝ`6‚¸m°s0CÂn¼ bf±ÄÂJ/Âát[š„ùe•¡¨(Êæ}‘¢l31ÛàÙ‡w²g¤‹•B•\Ñ!W¬’/Ö(•]\ÏÇó%Žçãy®çãû/ðýpx„ëøAã7p–¡ÑÛ�tÚ2„™‘�NzmµRÅmÛ$íšP©z‘?éšF»¾ÐÐë–òUÞühœ—ŸÜK¬?½]wߺË.·¦óÌ-—;2ðßw½€Å• ç®Î26�kªwåv'%Ô\ŸñÙ<~6M¹¢Ê�Eiœ&»3ìÊ�NZ eJ W¬òËn25_ÄWŸ›#³Ke®O,“+ÔÔÄlEQ6=PT¥å’q“TâÁÁ„V«¹>ÅÕ^v- ¥¤Tq¨9ÑOx¶L-ò¡:«t- jšFÌ6èIpdO½É¶Nð–¼ hSÉóƒƒ}†ff6Ý*SDÝ]sãòƒ€tÒâÅÇvÓ�i,s%�’7ÏŽsñÆbG2r7#I¸ßÎ_›çÜ•YÊU·å7Š¢l}BÀPoŠú±ŒÆî9rÅ?}7 (ª«_ó.ÞXàÝs“Èm¿©(ÊV ŠŠ¢´\2f’NÜ¿,»jŽO±ì¶ðMP©y8Nô™?¶i4|sßzÓÐêK²g8Ã`o )!�°Èfbm}|p½Ö<0(ìù5×o:ÍÐÔu4M¨€aÆKo6ÎK�ïa 'ÙP€0$£“+\º±È­é®ë«LÅ©ïŸO®ÌñÑ¥Yµ¿EY³áþ�jx žçÌ.–øäò,WÇ–TïÖ�$—¯rùÆ"ç¯Í«ý¦(Ê– ŠŠ¢´\¢cŠÅŠÓ²›4)%劋q¹3¬f(væ–ÊKt]0½Pä—Üâû¯_c)_%�´IÄLÚ‘H&¥$dËo²¥”õ’ç{ÿ;B„½2ËU·é¬ÓÔ0 =ÜîmÙB�ˆ™ìîâÐîú» ½®TqùôÚ Ap¯áËbõ_8ŽO¾Xkúø‰YFX*}çncBLSçÑcCÚÓÓðë®ÜZä—Übj¡Hµ}›�ÍÂ÷–óU~üö(—o-RiÓt{EQ¶>dR{GºØ·£‹t÷n—o.qîÊKùJä}¦7)%¥ªÃëgǸ:¶ÔéÍQEiPT¥å1“d'Š®O©ÜÂI§BP©¹ɘ² ­áò£V�‚j5�щþÍß|¿ú‹7ù«œçÆä ÓóEÞþd‚K7èI³Œ¶…Î_â­}8¹ç”g) MP(;Ì.–šfv¥ìzC{ÕLÂ]  Á3§wrò`ÃÂK—«cK|ÿõ«LÌæQ;ó·RRó¦JüÃ[ןVÙ‰Š¢¬_*añò“ûØ9˜iø5ŲÅÑ~üÖ(+ÅêvOп')ìòùÅ2~6Ãø¬:o+вuD÷´ª(ʶ‘ˆ›¤âÑ—¾<ƒÛ@Ër¾Â÷~u•ºÒ6½Ùx¸_‚�/ÍòíŸ\`9WkúXUEù…rk‡²”*N„Š’0c†Ñž5Ÿ0k/|8{q†_�ã�³cÌ.–î›E’›“+üðÍëìîb¨7I&i±”¯ÖâÖìw?Z÷rŸ9)„&nL®01Wh(‹n•¦ b–AOW¬#Cˆ6:!;Ò<~b˜#ôráÆù⃦ g.N“Œ› õ¥8y°ŸD2ž7а™¿O®XãµoñöÇãÌ/«†þŠ¢´’ÄÐd|¶ÀèÄ2ž<ðª.eXúüË÷o1Ò—f¸/IwB-®Þ39®ÏÙK³¼ýñsK¥No’¢(JË© EEQZN�e O l×nQñ×™á&%x^@¥ê5dZ/MX¦Ž¡km«œ]�и°Ræï_»Êw~z‘‰ÙÆ‚iË…*_žåïßd!W!›‰…ý[¸­�”x- ßëXB i|zežÍeWX¦ÎŽ�4ÝéXät6 ÓÐêKò•²w¤ ­Á¥TqyÿüßúÞ§ÜšÎQ«yÛ²LLÊ0À¾°Rá½s“üìÝ\º±ØéÍRe‹õö§ ðÔÉûR˜ ^×|_21[à�s“¼÷éTxÖâ>È›�”× ˜Y,ñæGã¼~ªÓ›¤(ŠÒ* ¨(J[ئNO&Ž©Gwš‘Râyù¢³î  ë*.®D:mV×ɸ…ÞÆý¦i‚˜m°k(ñý½ìêjêõ•ªÇß¿v•?›Æ÷$#ýéz@­5QÅÖöP �‹»ý†š&ßç“«³ÜœÎ5õÞ‰˜Éñ}¤“Ñ Ú,„€îtŒ/=·ŸSèÎÄ~ír¾Ê‡¦ù?þãG|tyv[Îh‘R²¸RáýóSüë¿|ŸÑ‰Uþ­(JÛèºÆÁÝ=üáËG›:_|øÙ4ßûÕU®Œ-S*»Ûq¨.$sK%þ÷oŸá̅鎴ÎQE‰‚ (*ŠÒ–©Ó›�·­l÷^ùb5Ìn‹0�!„ 3۾߄jÏœÞÉ‹�ín*“Ô–ò!Aíë IDAT~õá¿xÿ&©„IOWœD¬5]4‚@¶lØD åo=ÔHêß_„ƒX^?3ÆØLžr¥¹žté„ÅLJéÎÄÙ–Ñ®iZxL¿üÔ^¾ðøž†³)É—j¼~Šï¾v…×>¸öSÜ©«ÒóËe~öÞ þÃ�/0»T¢êx�Þ4EQ¶0MÀHŠ—ßáÝ=M-˜•«.Ÿ�.ðßùˆË·–¨T¶kf¹äÒÍ%þúGŸñÁ…i–Ë�Þ$EQ”¶Q=Ei ÓÔèÍÄ0ÛRQ%J*CQQ”¶° �Þl#Â’g/Kžw�%ÏžO¡T[w/ÆféšF2fDÒ{RˆÛveùÒóèïI6uÓŸ/9|zmŽ¿úá§ÜšÊaÙt ÃÐ×_kU–âjÉó�Û#„ÀÔ5– U.ÞXä��'É50,äNÙtŒ=Ã]ìÊ�ˆ™*žx«½*3)‹‡ð�¾pˆé¦úNÎ,„}¨þ¯ïžãýóSÌ,q]Kf+Ë5®�/ó‹nñ×?úŒ3¦qî ÔuA6ãùGvqxO±'¯ T\Q”»³M�—ŸÜË'†èM5üº >Ìî‡o^ã§ïŽr}r…j½¯õV<_¯’2 85_ä§ïÞàçïÝàÖtþ�ÁD]Û*ت(ÊÖ£ŠŠ¢´…méôuÇ1�ho”|?`¥PYw†¢ë†SU[5¤QBƒDÜÂУÉìÔ4Ážá.þà¥Ãìé"knšîr¾ÊkŽñÝ_]atb™þîéÄúK¶¥”x¾\w¹”$|ÀY}!†®¡ë‚wÏMòÝ_^¦Xqš^îéâÑcCw|Šò š�µ¯¾xˆgÚÁ`o²©×çKg/Mó¯ÿÝ{|ÿõk¬jø-8F6‚pòzXÎ}at‘oÿø"ÿÓ·ÞåæTî�Çf&isêP?ÿô«§xøÈ`ß)mü¤(Êæ%„@×=I¾ðØ^~joÓï±”«ò½×¯ò¿}û,‹¹ðž¬U½‘7)%ÉÌb‘o}ÿû‹Ë\mlxV:a5}ߥ(в‘¨’gEQÚÂ2uz»â‘—<¯–*¯¿äYR(9�ÉPŒ›˜öžb¶Î7^>‚/%ožoú=^?3†eèØ¦Á¡Ý=XÅ*ù’ƒã®­ç›”àù>¦.Ö< &øÜpM ƒ‰xÿ³iÞúx‚+·–š~_ÛÔ9q ŸçÙ…méë/ËÞfR ‹ò•“hšà‡o^ca¹Òðk¥„™ÅÿßO/ratž¯¾pˆGŽÒ×� �aÿ¯Í&$ž°°Ræ{¯_ã�O&¹zk©¡ w&iñìC;øg_?ÍÈ@ ½‰ iZ[‡?)вy…SŸáÈÞʵ=\]àÊØ…’Óð{Š5Î^˜æ_ýÅ›üÉ—NðèÑ!2I ¡‰-‘| ï;ß=7É?¼y�·ÏM²”{ð5m°7ÉŸØÃR¾Âµ±eŠåÆ÷«¢(ÊF¢ŠŠ¢´…eêôtÅ›*klß—,ç×ßCÑñ|r¥0**«¥¡‰˜郾‚˜eòرa&f Ì,¹6¶ÜÔ{,媼{nßøG/f×`†ž®¹BÜm61!¨Oì–ë |gf¢¦ C£Ru›Îów¿¸ÌÇ—g)59ˆEÓÂ^€ÇöõÑ—�«R¥50t�]ƒi~ï™}|÷µ+”Ê^ƒkŽë39›§XvÈj\º¹È£Ç†8q �TмÍÂZ¬–þ !›ÉóÙõyÎ\œáÃϦ™˜Í?ð¸LCç �ïáKÏàðž,Óh*åPÓDSHEQ¶ŸdÜäèž^þ³¯œâ/ð)Fîµìù’Å\…/Lcè Ëežd'ý݉È›[-$¾/)VjüêÃq^ûð&g.Î0·ôà,;R�a¹ý;ç&›Éss*ÇôB‘ý;³ìÌ�MÛõÀ¢ØP¿�$ì[X®z,®”™š/ññ•YÞÿt’O.ÏRªº ó©„Å©CüÞ3ûxüø0–i4ý=u Wå�º»b¼ôĦ ø�ä³ëó ~ I¹âòÖÇãJ5*U—ÓGÙ;ÒEW*´`øZ„Vƒ– UÆgò\¹µÄwy…+·É? {Sˆ°ÿò“'wðêsxèð ï�ŸŠb³EQÚFEi]ÓȤl1“\1š€"€ëTkŽç7< à7^ïÔÇño—ýDÁ4tâ1³cYCBÀÉ}œ»2ËÕñer…憕,æ*üÍÏ/³”¯ðåçð…Çö k‚|Ù¡Rõ _ƒQBÇõ‘ëèO(ƒ Òšžïséæ"?zk”¿ûåe¼5LÁMÆMöïÌòÂ#»8¸»GcÖAÛÔ9º·‡ÿöOŸàþ÷ðî¹If—JM÷Ùšœ+05_àWgÆxêÔ0¯<µ�Ó‡ngH[¦~;k±%“ÛpgÀÙó*5ÇñŸ-pæÂ4?xã7§rMeÊ&ãwõðÏþà!Nìï'�°ÖtŽÒ4Éð'EQ6/!º¶úøæËG1u�¥|…©ùbS×ÑšãóágÓ\º±Èï<±‡/¿p�‡� �Œ[††¶ ‚Š~½=E¹âòÑÅ~öÞM~üö(ÕšGð€û! ³xìø0_zþ/>º«ÞÇvãoEQ”ûQEEQÚ&\�µ‰Û:¹b´Ÿ]ª¸”Ê•ªG¹º¶Þëa™:1ÛøÿÙ»³ØÈÎôLÐïÿŸ=öƒû’dî©L)•Ú%«T‹Êv•ËîvÛ…éFc`tÐh /§s;˜ËÁ\ f.g€¾q³ô=n/e»ÊUªRÉ%•¶Ò’‹rgr_c�³üsq"‚d&Sd’±ñ}UI䉈dðœ÷|ÿ÷utôªÓ#Iüû?yÿóx¿þb¡åª±�Þýx+ëeÜ_*àŸ|ó ré(JùF˯?ønNy~Š E)¤”ØÌWð׿¸‰¿üùWøüæÊ�ÂD˜Má¿ù§Ïa4몪·^%¥€eêHJüÛ?~£¹þ¯}‰¥µâ¾+S• §#¿óëûøõ‹ËÅñìéÞza gO ›r�0±=¡bcЊ¦Ix¾Âürï¾€Ÿýú.®ÝYÃÒZ媷¯êmËÔðÆåqüð»çpñTQÇ8p°-…„&»y8už”aÏÖo¾4 ÛÒñ¿üéûXÛ,ïkƒP(×ð£_Þµ»kxý¹qüóß¹€l:ö#òUoŸR©z¸¿°…?ý«ÏðÁ ¸¿”G¹ÚZUy"jáì‰ü«?xggšWšúyò5 ‰è ¤â"Ž  ØÖG.–k(U\ ÀÙ÷m+UÅrûdºDÄÒÑÉvåBD³ãiüà§ iï~<·ïû©Ö|ܼ¿�rõîÌoâÍç'péô†³a°X®x¨¹þc‡ÞJx‚¯ª—…mkøøê"~þÑ}üú‹…æ’샘OáÕKc¸|vñ¨Ù å»�”†.1œ�á·_�A*nãÿù»«¸;¿¿ª= +5•š‡RÅÅêfŸß\ÁP&ŠÑÁ¦GS89™ÆØPI§¾d=ü ©‰}WÉ(¥Â^�ª`×o_ª¸XZ+áîünέãîüæW X\-bqµˆ­Bµ}Ûšˆ8þà­3xë…I\˜Dl�01¨ïOK÷ù�ˆèøh\€Ñ4�\:‚—.ŒâO~ÿþâ�¸z{u_½¦•ÊU·æ6P,»¸=·‰gÏ áÊùaœ™Î ê4þ¾v6\l}~ P,»øèê">ürŸÝXÆõ{kXÛ,£ZkíbP2fá¥gFñÃß>‡Ù‰4"–QŒ#Û}"¢¶a HDGF ·µÛÿQS,»(V•«�©P4t ÇÞ/´ÃÔèou ¼úìXXU¸UÁõ»ëû®T,U\ܼ¿Ž¥µ"V7J¸¿˜Ç¥Ó9ŒÆu DµšÏWðƒà‘J?h„5O®(kôˬÖ|¬m–qëÁ>¿¹‚_±€O®-au£Ürв“”©¸�ן›À7_œB&ià2¥Ã$¥€iHÌŒ§‹š0u ?ûð>½¾„ÅÕƒ]ˆ¨T=úr!¢¶�ûªÖ|Ü[ØÂürwæ7qãÞÎÏd1;‘ÆìxÙTXµÔÿþ·ã“ªQ‰(…@©âáÁr7ï¯ãæý |öÕ ®ÞYÅýÅ|ËŸãšH'l¼~yß~y/^�©kÍc<æ‰DÔ(Ñ‘HFÃôv+–]”X�V®z¾íÓ0 ÛèxXÕèë3žKàµKc¨Ö|ÿöK,¬ö==[) _¬á�ïãêí5\þjo<7ŽSSdÓ˜¦Kø~ÏÂ0D ¾ôI4+Èv€«p´…á @Õ P,ÕP,×°´VÄÕ;kx÷ãøðêV7J®�B µðÂù|óÅ)¾¶ˆ_~2‡Oo,Ãuý}€¦¡a0Á Fð‡ß:ƒËç†ah²y|ÇÃ"ê ‰èÈ„Õ]‘NŠ­P¬ü¶OÃÐ%Ëèšå´B§§�ˆÛØ*Tñ£÷ná΃Íßßòz óîMüí/oâÔd¯=;Žo¿2�Ù‰4ÓQ@)”w47Wõþs;_� öÁÓ4×SX\)⣫ x÷“9|øÅîÌoÊ•ÛÒqb4‰óG—1;ž ÷I©®yúMs¹±¾õÒ4NOepñÔ þÃþ÷¶ž: kPjn€Ë~ Ra¥o«�¢¦ èšh©˜ˆ¨¡qÑ1—‰àíWg05–Âÿôü×R=Ø>¥€õ­ ~úÁ]üôƒ»È¦œ™ÀËÏŒâ•gÇqr"�ˆ­# ¨Ì;ØçVØæ"¨?…­b ~¹€÷>�Ã?~6�;6Ÿê80—‰àW¦ðoø<2IB 'ÏíE":2�¡,§ý�b©âxÙr¥æx¹ôÓ0 Ç躦äIÿü{`™:~ôË›¸vgí©î/PÀ�…-¬ç¿Â;ÝC2fc$ÃÔh£ƒq$¢&âQ ±ˆ‰ˆ­BÀõT*.6 U¬n”±´^ÂòZ sK[XZ/!_ + ¥Ú¡„‰QÇÀ—'ð/~÷ƇâÐuVrµ“À`:‚·®LáÄXûË[øù‡÷psn£Ó»väKǹ™,¾ÿÆI0n§F#öï¼2 ËÒð—?»�[s¨<Åw¥ê¡Rõ°´V„¦ ܸk⳯¾v¶eÀ65X¦Ã�P*ì§èº~ö–ÝæAÿf¡‚rõàËa&¥€céøöKÓxûÕ\˜Í¶ôfeAw½3ýͶtä2¤6bŽ�“i|tu|1�Å•"ªû\~ßílKÇÌX WÎ ãÊùa\<•Ã`: ËÔžx[!üÀ‡ÚG¢h[:KG $²‰è ]"µðÌì ôzÛŠŸ|p_Ý]Gá)ZÇxõ%ÈùR óËÜ~°�kwV‘ˆYHÄ,$cÒqŽ­Ã¶tº„©k0 C× i57€ëú¨y>\O¡êz(W\J.6 UlÂv[…*òÅÚ¾&V?L×r™(^¾8†W/�áÒé&‡O½4›ˆ¨Û1P$¢#¥ëÛ@Ô6°Y¨¶íqk®�JÕCÕõaê²å�Îõ|”+.ªn{‡²º„ihл´§™ëù˜Iàrºøû¼�÷Ö±‘ú÷Ô÷6 Ul´ñçc/†.1˜ŽâòÙ!üà§ñì™l+¬­/ÂîèîãHÓ$lM♓ƒ˜IàÌt£ƒ1|~s·l`~9ìëùç�—ŒYŒaf<�çÎ áågF1=–„Q Ó ¥Âߣý¼¶©íhGÁŸk":M°-Ï�ÂÐ@™”ƒŸýú¾¸¹‚ÅÕ⾺í¥RóQY+aq­ôÈ÷t-<~²ÌðÓРK‰ªë£ZóQu=TkaïÛ£è]�š˜Ká…ó#øî«3˜O!³0L$¢þÇ@‘ˆŽœcëHÄ,lÛ)¥P­y(”jHƬK/ÊU? (‚ööÊsl–Ù¥É €ØÊW�Š[øã·Ï!“tð_Þù ~±phËŒ;ÉÐ%†³1¼üÌ(þÍ=�l:C—Í,†Û9�—>±péÔ.�Êá‹[+øÙ¯ïáoß»�åµò¥*5ï@S”;A×dýäWÇÅ“ƒxëÅ)|ë¥id’6 ]Ð×Ðõøû8qwl#àqK©‰ˆZј:?–‹ã�ß>‡S“üõ/nâ'¿ºƒ•�2*U÷©*¿ŽWFÕÎÞ×áp/ QÇÀù™,~ðæ)|ë¥)D“îˆèXéÒ³W"ê'Û@2fan)Ð.Õª‡�­*âxòªAø�Âf¡úT½sʱt˜F ;ÙR ؆†¹b ÅŠ‡�¤ƒo¼0‰‘Áþn$‰ÿóG_ Za{ÕìD?øÆ)|ÿ�“H'lH)›¦%ÓÄ® êCINNd0–Kà÷ß:�÷?›Ç;Ý 'B¯—Žì¤õ°hR`r$�çÏ ãW¦pz*�l*ËÔÃ^� \uáî#P´M±ˆ MÓXECD‡F“çO `<Ç[/LâÏr ¿üd +Oß[±[X–Ž3Süö«3ø­+ŒÃ±ô®]eBDtT(Ñ‘slɘ…p6oûT]ë[eŒ F[ ë‚ ÀæV¹#½ÙK‡ÙŃ?¤R Zó°¼^‚8Œão�ÆÉÉ4þòç_á‹›«ØÈW:½«-ˆâ•‹cxóÊ$.Ì"“tªþ“Ê,±»4‚/Û Š$c^¹4Š™ñ¾óò ÜœÛÀµÛk¸z{wæ7áûAWTÏÆ£ƒqœŸÍâüL¸|{h Š¡L‰˜C—ÛÄ�î©ØÈW÷ÕûÕ± ĺpõ>»~‘Ô±t$¢®œÁŸÏãWŸÍÚ2èv"¼@~ñT/_Å¥S9L ‡ŸåÝØÿšˆ¨(Ñ‘‹ÚRq+<Øjc…b¥æam³Üò2À@ªP´-­¥á �"„€®…½(ËUÁfØûm åà­Âe>]]Äon,áúÝu”*n×V,J!�NÚ8=™Áå³ÃxåÒ(NO 1áAØD½¾u/!ŸËÁLƒ™(ÎNgq~¶ˆ;'7pûÁ&î-laq­ˆ¥Õ"–×KXÝ,£Rõ޼‚QJ�TÌB&é ›Ž —‰`(ÃØP'F“85™ Û0èrWÃþ§9õ‚k›e”«­/ùs¬pª<ÑaB@Ó1 f1<ÅÔp§¦2¸vg 7î®cniëPú0%�°—ïp6Š©‘$NMepñd—Nå0š‹A ÁU Dt¬1P$¢#q $cvÛ+a*Uëù Z½ ›ù *ÕödÂj!£K—<7z84¦æù¨Ö<¬nø(—udÓ|ãÊ$ÎLgpz2ƒ¿ÿÕmÜ[Ìce½ˆ­b­k‚EC—ˆELdS\˜Ä[/NâÊùdâ6ü@íªfc¥Aïh¿ÒŒa<ÇkÏŽ£êú¸y?œ zãî:îÌobu³ŒB©†jͯÿ‡�ú½zÿA/P�ýym˜�H]“ÍaJ¦!a:l3œœ<1’ÀôH3iœ™Ê —‰"bë©mÏT>Œ3¥|_a}«‚Ò>¦ª:V¸ä™ˆè( d’2IÏŸÁ­¹ ¼÷é>øb_Ý[ÇV±†|±Šrµ{úàºDÌ1‘JØÈ¦? Ç6 Åö…%"" u÷Ù+õ…ˆm —ø¹^ûºšçc«PEÍõw--|/°Ylÿ’g!Â*N³‹—<€eè0õ‡÷1œ[,¹pÝ6óÄ£.žÄìD ?üî9||u ^]À—·V°¸ZjÛþ  ¦"8=�ÁKÏŒáüÌÆrq$¢¤”á4o¥%ê½ëÚ¶kO%³ñßýÉk(–kðž– †®cr8Þž�ë"Bˆú”î0TJ!jë˜Ib0A¥:Ïàû žïÃóÃ*Õ@…a¹çðƒR˜†Y¿?)Ã*E]“Ðu M°Œ°§cØ7LB@ÐÅÑ÷Em|®m*¨Ôž(JÄ£bŽ£}[ëòfÆR¨Ô¼'ö·”B 1Û^I)�Š›øÿÝ›(¶Põ©I�tÜF6�lÃ{Þ-.ŸÆÿþ?ü€'÷G¶Œ°çéP:z¬†Šû¥xf6‡R‹a"^œÛ*Õ€}ôDíB4>³"Ž�Ùñ4ré(Þ~å–×K¸~w×ï¬áæÜî/laeãhm ¦¡ab8�Ùñ4NMepz*ƒálɘ�xÔDÄ1Â>Ò‡þè ¿÷æ)¼öì8 å'©†¦a$=ô½ "z ‰èȺ„cHDMx^¯M¡bà+Tk ¥j ÿk{Ö\ŲöXó´ëœPJQŸð,»~:`cyçÃÂJ,…ªëÃó¸~ÛÔ± œœH#�°q~&‹ù•<æWЏ;¿‰Ë,­±²Q‚ë‡ÒZÓ25¤â6r™(FêýŽÆ‡âÉÆ16 'éüv,o–Ýý²?ÂÐ%NOe(ÕÚ’«c>©zg5‰áR_ÛÒ¡‚íiÊ¿ŽJ¡YÙV7î½¼­1 üq÷ÓÎ5qk›å––¸�‹@õ^·Í0±Þç61‘ŒÛHÇmd’Ra�˜N8Ð4Ñ|_´6…v�¿ÝþyHDôu(Q[˜†ŽÜ@ó«Û·êJëõA+ɘõØíª®��|åÈ'À>ÌÐ%’q«ë«Ô‡PhÛkÛžp \­y¨¹6 X¦ÛÒs \˜ÍâùsC0t ¥Ö·ªXZ+b~%¬XÜ*T±U¬¢Prá\7€R ¶¥Ã0´zõ£ŽtÂF6ÁP&Šál™¤]—áp /\ºêùª®‚RªyðÎCwê'*P¨Ô|Rgí¿ï—@c!;ƒàýÛ,Ôpõö**µ'ÿFZ††LÒÁ`&Ç6Ú°wDDDDÔ¯(Q[èšD"j!ê0 ®ÛžIÏ�°°\ñPs}Ø{THº¾B¹ê¡Rq´/ÔhTÞE³-�w¤°Û aEïkú[†Ë©T›+Li[8P@aÿ™®j.s§ýQ°²Q§חŸØ'’q³ã)Ø&g„ÑÓa HDm•ŒYˆGL¬m–Ûú¸¥Š‹­buÏ@±P¬¢XªµuÀ± DìÞ �°çc'Â¥× ¾vIzÀ ÅŽB`=_ÁüróËù–o£iÏžB:añö�p:¬Âõ{k¸~g •š×R�›M9¸t&ËÔ(É—ˆˆˆˆˆ�"µU*f!µÚ(Ë.òÅr™è#ß+”\Ën[÷,SCÄé­e‡š°LZ³¢¬=^„Š_rÉsg)„Š «üé_}V+>á6�IÝ›… ®œÆøP‚•Š-hL;ÿÍ�e|r}©¥0Q`h †çÏñt†‰DDDDôT(Q[¥6âÑöWåË5l«{~/_ª¢Pn…¢méˆÚ½õ1,¥€®…RD XÞz0B4–ÔnOwÞI©0PTª}!'í&Ø–)n?ØÄÊF Õúú5úšº†Át–©1T|×õ±¶UÁo®/áú�µ–n“ŒÙ˜JàÄX ¶Ù[Ÿ;Dôõ”Rp½k[åæÅõÐ÷CbWŸÛ�_o~ ªþ_�ÛvÇ÷Ä×|o×ã‡!¾fÛ]û²çýns<Ò«w÷]Ö¿/ž¸o;/{íùZ(Õ÷è¾ìñbïã¢�_Û½­ÚsÎŽ+�¶_V»÷é¡ÇÜu¿;¾×Ø¿Ýw¥ù÷½nÛøïíïá‘ï5¾Ü¨&ÄCÛn€?¦ÚnK=zÛ]ß{è1Zñøÿnl¨‡Þ#"¢#Æ@‘ˆÚʱ4Ä"FÛ{x¹^€b¹†|±Ö\¦ë …²‹BÉEÍ}r¯·C£]°- –¡µïq‰`: ½ýB\߇ÿPUdc` ã§Î"ì±µ ¼qyÏ�‚ÙâÏx¥æáÖÜþß_Å?~ú «EÙÙA¸;¿‰Ÿtÿ�·±U¨¶ôúÄ#&^¿<Ž™ñ4"ŽÁ“L"""":\òLDm¥I‰ˆ¥#·à®ûm ª®�¥õl+ Ã\×Çòz •ª×\"ÔBŽm„}µÞ»®#êŠíß÷½*•Rõe^mßz !.ŸÆÜb¿¹±‚[6àyO^_,»xÿó$¢64]â­¦uÂA/Ç9s=ùR ÿðÁ]üäý»¸qo½¥ÛéšÄÐ@¿óÚ,Fsñ#ÞK"""":NzïL–ˆz^Ä6�M:Íé®íR«ùX\-4«=?ÀòZÕvV'�B ztÒª¢†¶?á ‚Gû…Šþ£Ë™¨£tM♓9üî'÷]‘üã÷ïà/~v_Þ^A¥êá8·”…õ­ ÞûäþÓß_ÅŸÏ·|ÛTÜ婃 ž;›C6å4§¿=-ŠDÔvŽ­#›Ž<2©÷¨U]Kk%¸õJ)×°²QFµÖÞ ÏR D#FO.wnÐ5C“ (ü@ÁÝ1]:P ¾Ï¡,ÝFÓ&Gxý¹1\˜É"±�éîJ)||uÿÛŸ}€w?™ÃòzùX¾¿�R˜[ÎãÇ¿ºƒÿõÏÞÇ�ùÍ–o+pùìþÙ·ÏÀ6�cùúÑÑa HDmuLä2‘¶W(VªW‹Í E× °´^BµÖÞ E!1»í}$“†![î�w˜‚@ÁuÃeê~½bÍFòÔ=lSÇäpôö9œšÌ@ßGßÍ�|¿¹±ŒÿøWŸáG¿¼‰÷ÖŽpO»G£7¿ëùøøê"þüÇ×ðŸþþ*nÍmîkxÔéÉ ®œŹ™l½ß)E"""":<½{6KD=+10œ�¶}¨‡ëùX߬ Rõàzª®�µÍ ªnû+“Q ¶ÕÛÁ¦¡Ã¶ôö´ÁŽ!, ðý 9d‡ºS"fá­&qoqùR ×î´ *ä‹5¼óá=Š5ljð}…É‘œúïN¿M,V*¬J\ß*ãú�5üÍ»·ðËOæpsn£åûФ@4bâÍ+“¸r~ɘÕw¯u^oŸÍQOŠ:&†b°LBÔvõÃ;JA P®yÈ—j(”ª(”ª(–Ã�¢mçÛB@“ñˆ ËìÝ%Ï`è²#˶ƒ@Á¯g˜~ ™úLÝCˆpS4bà;/Ÿ@µæãþâÊU¯å%¸J^]ÄâZ‹kEüá·ÏàÄh¶©C×µ¾Ö¾ 媇­b}¹ˆÿøWŸãÓK(”Z¯J€xÔÂéé ¾óÊ \˜d˜HDDDDG‚�"µ�®I8–�TÜÂV¡ŠJ{*¥°¶Y†ehXÛ*·íqtM â˜pl½í=$›ihY¶(×ó `Á÷ƒpÉ3u-!MHœKáõç&p!�Ÿ~xùbm_÷³¸ZÄÿ÷Óë¸~w ß{ã$Þ~å2Iš=šJA@ \õðá—‹ø‹wnàW¿™ÇâJ5oÕ¿º&qj2ƒÿö¿~Ó#)v """¢#Ã@‘ˆ:Â0$†³1¬n–Û(®oU I‰­bµía”¡kHÅ-hmîy ]v¤Ê2ê½ý¾.¦îgÎLà‡¿sU×ÃGW±²Ñz¨ï ùb _Ü\A±TÃg_-ãÅgFñÂùaŒdc�B@ˆÞXVe …’‹Ï¿ZÆÏ?º�O®-áÖƒ ¬n”÷ýs-„ÀË—Æð½×g0;ž�möGõ&u'ŠDÔºˆâÖÜVÑÎJA�õ|¾./lwm›ejHÅmh=^�„½Út]ƒ®Kø¾BÛ†>ˆ0T¬¹>\ÏÃ'=!3qéTðÖi††÷>�ÃÚfe_÷Q,»øòö*,pwa _Ý[ÇéÉ &G81–BÔ1 I Õ‘)ä�£¨@AJ�jÍÇêf·æ6pãÞ:>¹º„�®-bqµp ž ¦¡áâ©Þ~ù^}v[oûÐ+"""":^(QGèºÄH6†¨c´õqŠ•ªÏW‚öV·Y¦ŽTÌêùåÎ@X¥k¶e \®¡�Åž @¥æÕ‡³¨ž¨J£ðgƱt|ûåi(„CuÞýx¥Š ?@J›…*~õ›øäÚ"¦F’¸rn/_ÃøP™¤ƒxÔBdGk�vÿœ4ªE=¯º>òÅ* %KëE\¿³†ŸtŸ^_ÂêFùÀq|,bbr8�ú­ÓxåÒ8Fc]¤Qb HD¡k†³1D³í�½™¯ P¬zm.Q4u‰T†¦õÇ ¿”[Gµæ!Øg¿·§¡…jÍoÛ@:ûj›…ê�î¯ZóqíήÝYßý͸0›ÅkÏŽã�Ë8=5€DÔlVµ†ýù =†R€Ð5‰ªëan©€¿XÀ/>žÃ'×q1ÿÔ�!pz*ƒù{ñÚ³cHÅmëQ[0P$¢ŽÐ5��T1Ç€®Iøm¬ô…@õSïvóX¦†d¬?z(á²gÇÒ±%Ú× 3ÔèÉb¬Þ#…êaØÿ¯_ßÿø~úÁ\¿»þT÷ 7î­ci­„¿{ï6⣹8¦G“˜Kav"�ál‰¨Õl; ”B¨æµ)4ùh±RªY‰«ê·‘B@ÖƒJ¥×õ±ºUƽ…-ܸ»†÷Öqoa Kë%Ë.ò¥Jåý ¤y˜@Xåý;¯Ï⻯žÀ•ó#ˆGL†‰DDDDÔ6 ‰¨#„° ‰TÜFjÜú¿(@‰ö.�´M Û€¡Ë¾Y¢«iŽ¥w$ UèL˜Ø/ï]§ )s œšLã÷Þ<‰�¤ƒŸ}x}¹ˆJÍÅA‹OË劇ÅÕ"¤¸9·�Ïo® ›r�MG�ˆZˆ9bQ ±ˆ�xÄDÊUùb [Å* ¥òÅŠeëù –׋X^/a3_E¹z8‘»ehŒá­+Sxãò8ÎÏf‘I8 Ö‰ˆˆˆ¨­(QÇ!�Œ[HÇí¶ŠšËdÛ E±ˆÙW�”Û¨W{õÏó¢£'@„uÂçg²ˆGL ¦#HÇ-\½½Šù•J•§ á‚@a³PÅf¡Š[sͯëšD½±ŒjÍGÐ祰²>ìèòÙa¼taOâÄX ™¤«ÞÓ‘ˆˆˆˆ¨“(QGE©˜ËÔáz>Іœ@ÑœðÜB@—[‡mõçÇn£B8ºþq§ÂA"t´¯p,bbf<�ãiŒçâ˜OáÂÉnÞ_Ç�ùM,­•PªôÏÏ›`F²1œKâädÏÌâÂì Fs1H)ÂéØDDDDD] ?Ïl‰¨gº†XÄÄ@ÒÆêF®×Ë¥ƒS§>á¹Y¦³OŸu†€¦…ÚìD'ÆRøÖKÓøàó|tu_ÜZŃå6òä‹5”«Ÿ ÝI¶©!±�ŒYÌDðìé!¼xaWÎ�ÀÔ%4MôÕ '""""ê ‰¨ãb“ÃIäKn_Šš&1�r`›ý»TÑ6u˜}¾“¡NçH) ¥@L7ñÆåq¼qy¥ª‡«·Vñã÷ïà½OàÚíUÔ\¿E·jl(ŽçÏ�àõgÇñê³cÍIð«a‰ˆˆˆ¨‹1P$¢Ž‹8¦FS¸~oÅ>\2kèƒé(,³?r¥0 ¶¥£Rõ:½;ÔÇ4MB) bé83=€ÑÁ8~ðæ)lä+¸5·�«·×píÎ*¾º¿�|© ßQ×$&†˜KáÌô.œD.A*f!ZﱪINn&"""¢î׿g·DÔ3¢¶�©‘dßÐdX¡èôiÿÄ]“°MÕ³ÐÑ —CKÄ"b#ƒ1¸ž�É‘$ÎÏbi­ˆÕÍ2Ö·ÊXÙ(cm£Œõ­ V6ÊÍ%ÒÕšw$ÕŒB„“™ãQ©˜…�”ƒl*‚l:‚ÁLÙ¤Sÿšƒ±\¶¥×CRʼnDDDDÔ3úûì–ˆz‚méˆ"1¡ëeø~ÿ,{–RÀ¶4¤âV_W(á¤gÇÒ±U¬vzWŸ«ÆºÐÎe覡a8Ãp6 �…bÙÅÒz‹«%,­±¼QÂêF[…* e5×oþSu}xž?Pÿ(¥Ï E} 5ž†š& kF}¹©k°L ¶©!µ�ŠÛÈ$l f¢ˆbd0†¡Lz=}*ük¼Q±ˆ‰¨cbz$…@©ærb¥ü@¡Pªa#_ÅÚFk[”*.*5žÔÆ® RuaèZ=DÔ ± DlQÇ@"f#·�I8HÅ-躄 @à+ˆzÙÀð�ˆˆˆˆzE"ê R ŒÆ0·”?ò@±�"¶��¤Möÿd]°M Rø½8n÷kÁI»½JˆðtˆúÅ„ð½Ô5�DÔB,bbt0ÏaШTãŸ�÷#š# )$¤ /ˆhR†ƒc wô@ØžTMDDDDÔO(QW�R`|(�÷Öqq«Ó»sh¢Ž�lÊÙU�Ô¯Ë@Û@¹ê"ú#Td�Øûš5‹½—R H@ «‰•Ú®`~äÆ jûëáòç£Øc""":¸þ8%êv ‰¨+H!0š‹#�°!¥|ô¤¾GEëK¹�˰)%¢®ï£ÖÃY¥ ŽA…)…ÕŒO ��ǯ2QO /pó¸�¨ø›FD]AJ��¤��d{¦!·£êÌ2uÄ"&1ëXT(á�‰˜cÀè“9Ô×ÌöGÀMDDDÔÏt-”&x�èÈõÇõ]“HE0’�éã´k k:a#³ú~ËNšˆ:&t]ëô®)Ù?‘ˆ¨û)¶ "hš€ejü< jŠDÔU²)ãCñ¾p2qɨÕéÝh+! ]Â44ý*öÉò{"¢~&„`19Á±t¤âö±º OÔ) ‰¨« $Œ%zþª¢©±ã(ás·-¦Ñûb8Ý™ˆ¨G„MP;½DÔaŽm` åðø�¨ zÿl�ˆúJ"j"—q�ŒYÐ{´Ÿ”Ž¥#�°±�çì+ÇÒa[F§wãé›a:DD½N@›~ÅDôxQÛÀP&Ú³çD½„¿eDÔU CC*î`z4çˆÂ8¥Ô‘N‘6 ƒé¢ŽÙ?Ë~÷É6uØ–@ôìŠa!8Ü™ˆ¨W(Ž` "$cƇâÐ4~"5ž*Q׉E œŸÉ"éÍå¶©ar$Ù–iÕÝÊÐ%,Cƒeh=[1"ÀåÎDD½B€‰"¥â6¦F’¬P$jþ–Q׉ØÎÍ 3!{¬DL°-à Øv�/ù}J¦¡!1Ñ‹y¢[qõ’FÏ[~v_¶©c0ÁÄP‚�"Q𷌈ºŽ¡K ¦"Èe¢ˆGÌC¿ÿ£´a™:Rq ɸã˜/µ0 ‰XÄ€ÖƒtB ºõ˜ðbK‰Ž«ÑÁ8ÆrqÄ£fÏ®�!ê%½w–GD}O]—˜I —‰tzwö%·14ƒ©Ëc¿\V×$"ŽC—=U¥(Dx*ÊQ"¢Þ"šÃ´z´y/=•ÓÓL�&�ý18Q»0P$¢®53žÆX.ÞéÝØ—�¤ƒÑÁ8dÆ®õäp¾}DD½GMcÿ[¢ãêÂl³éNïѱÁ@‘ˆºÖð@ãC ¤“N÷W‹ �¨c ›r�I:�Þ›®!…@ƒñ\‰˜Õé]"¢=𬘈z–”±ˆ‰©Ñ¾cLãÆ½ |y{÷¶%”R`0ÁìD§&3ÎFa:'2)lSƒ&mØ–†RÅCµê#8„R!†݆.a'|Ñ.º&!êÿïzj®�Á"QÛ9–Ž¡L—Î áüL''ÒË%` GÔ­(QO“2\þ|b,…XÔB2na é`a¥€�|ußËh…ídÌÂ`&Šñ\S£I ¦#àq„4-OÕ¼_QÌíÿÞ~LõÐs ·m<àî×¾þ.íñ\vnÛx“v¯þœó<ÃÇܽíÃ?ÆöýbÇcîÞ{lÛx�Ñ|ßw=ÏÝï¯hnûèól>nó~±ë1ÅžÏe÷¶*ÂûܱïR†+D"–ŽxÌB&a#7ÅÈ@ Ó£IL�$0œ�ñ¢/Qc HD}K)Àó|Tj~sùRP?hßêš +ëKc-S‡Á0ª«(V†4bãÛú¥ïð@7<U¤”|ÿˆˆèP5”í¿GjG ÕHÔvoß$ðämêîujÖü»×¸³ð‹{o·s›�_ÛõÐ_ó8;wz�ç!ö~è'?±×v{<Æ®û{è�¹ØcÛƒìËž÷Õ⾈]ßxòã<|ûænïµÝ#_Úû¹ìy[UÿîïýãöeÏ÷¤Õ}�¼†âáíê}¬e£ �0 ¦®Á2ƒò¸ ˆ¨—1P$""""""""¢–qµŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"µŒ�"ÑÿߎÓ4`ý(*@H ˆaÂJIš2`à¶¿MÞSpó™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(™¡d†"�Š@f(ÙiÛïÑÀ]8Ÿ¶ý]Ü…ŸÓ¶�£+€»ðþp½^·}n{:º¸Y_Û^NÛ.Ûž·½mûÞv>² ¸çý?Ã×m/Û.­Bíßü—òIEND®B`‚DataLab-Platform-SigimaX-0182bf4/doc/_static/Sigima-Title.svg000066400000000000000000000457311524425144300236750ustar00rootroot00000000000000 image/svg+xmlSigima DataLab-Platform-SigimaX-0182bf4/doc/_static/codra.png000066400000000000000000000053131524425144300224520ustar00rootroot00000000000000‰PNG  IHDR22?ˆ±sRGB®ÎégAMA±� üa pHYs.!.![üÿ `IDAThCÅš pÕÇÏ>î3÷¬ÀPK­"Å*EKUÚbµÀ0NEÔªÐÃ$„€"thk;ètÚ±´SµSi)EÌ’Bˆ|@[[f:RSy( B¡@ �@$¹¹÷î£ÿo÷ìí½»woÂoæÌ~ß¹»gÏÿ|çœ=g÷ ¬t]q��At#s�Hº·Š{kFæ•€‚dJܽf� UU3ÖÃ5"¸˜ZBïWKظsæš!]1Õ‡›Z‘DDÙç•Zö׫Uëª=ñ¸2ùÉ‘b±Øå…%ÅQØ꣘٩PèR€Ƀ úNÓ «FÆmDüîa+ ;”å¿Dç(Šú[Ž[¿!}‚tÒçó£.$Nå ì E‰àfZÕ}¡¼r­¿´¼ÒÃ]ÖÒ+T4ýV˜×! £„š]ï‘„3õ5E‡*×®½÷Ÿ‹ü°õ;Òp$?Ò$Fb¨�qž#ö C-WßkvïÚ#”WT�+«¨š�Ê—"ýSÓ´]¢(ÞH¿?þìú!Ѹ:ß^¨„fËòɯ’­ªúÓ8P¥íü©¨ · ¨§Ø#“#ü­/"*ªÖ�@…gãÚ§àN@JDÔÍ+œû]2&Î.-i늯N.˜lŸ,=(Þ8¾æ± Ù‘¨F݈¢�êó-y�» pOª{bÌá"(l½!´þó˜IàÚÈšˆ”,‚¦êåd,Z¾Í‰©ÏØÕÂïW³7ŒuÇô"¸NŒ½—NÁ뚈ŒƒÄ ¿w]èAø×ýî3×A-*°ŸŒ�7>Sµñ‰ÐªÆÆYÕ¨ÜöÎ:?Ê*6±!¿ãVZ¬:“�0ý”fQxM° æX3'-q¤—M“±¨¢-Ñlq&QA¯Tµ·:§¥é‚–w´ñC*eY~‹Û® î*" !##5ëk}Qå/ð,WPð›F4¾™S>5¦è÷§‰F§ßï]møº1È œ?äç>ãnO(�ŒãbÓæ-rww÷FÜpÏÊfªßp“ut+K0WrÏ„Dy$qÃŽªü“hœGáÒ$açS”³žÛ=BìÓ¯ƒÖÖ¶•ñw3BÑ(ÈÏ­'{r~ÅEÕf$߀$I¢ |òJÃ×õÅt´ƒrÊQN w{EF!h±¸Y wÝèBÚŽ›ÿé{fc¡€üßì,Ïäaaï¼°_ª€€"Â�)÷Ïÿª)܇Iã^œ6Õ<;…v”SÊmvnÙâûs^œ»®$wßðd¾�j0CfŽƒ H«ÐÖÎÍ£eEFZP�u©½û œøýêÂÝh¤Íh¤ïðŸ“Yƒ1f4^þ�Þ²¯áâšcÇŒôݱyUÁEãŒ4¸ )+¯|]awíT¢BË  ‰û}¢¢ªz˜ª*Áü¼™“@E¹w£\ãI>î±×JâªfL !Ÿ¸â£º’DÄí¤‚%Æ4þfz)Äö…xF”sÿŠáßó3Ço¡lšØÔ¢uÁ¦‹�{UM7¦ztËHÈ/NØS[B‹Nncäü˜L7š9"´úa´þ˜»ÍƒÄ°¥=’«qžEUei'Â!}÷Ë88!D̃ˆ·¹; @Ì%IŸ€yi7Êÿ;åÏþ~m]j©}©UôY•ÓŠØ�C€Éë&±7©æî€R07ï4Ê/Bz…g±Óg/çÆU}¬½ß£› oéPàn !(ð!nZœ’$é'ܾ* ‘¶"m${é/¶»¢êR-ÍÊ�žC8ÝôRI‚øYDÄØ­Y@دææåôéáÔ»”‰kc±st@B°9»§pY�cß’""nÁ�öÌMXÃÔrûª3âÑ×�-±gì‹L Ò¦jlTÙË7˜9ÿÇÞµnâG‹¿¢»>„š™· ãB^6^ub€Ù ‚Œ�g2v!)JÑ­ŒYd°˜5-Ø0q´Ô˰3Ò ¤;¦~†» ì]+‹›m{iû9hL™6§uÌ(¹aTH`nQ¡î x»I{D’iƒ°+Z‚ô‡p–pbôp‘E2ì’0†1K‚\æ&A»Æ~¿ê+²$DG XÔ¥{Ñö¦­3æ·öˆ|Ê�×”pP¤µUZÐØÌï•zò~$ü¸(ÀíA-žíźBvº<²x†» ì]‹„XjÃ#öeöU=çfn¦‘:}ÃðP#w¤Á"î<Äâ.ñU~°`‰�Œ�cd*.³v˜®e½ÐNÁÞµh N¼‚�ýmn“�®ëˆ`ZJ#„–-^Y¤×QBÒmzl Ö_c¸=äѬÔܦ9Æé’EáÂõÙþwÍœTB° =å–ê vq ¸}UA·ºÕ�C·:Õ¬±@Ê+j40RÀ#Õm]�Cï 8„˜¿æ±Q¹�ÛW tãÑ€žKí;Þª3�­f’(DdY¤ï'iI+Q¡wSÖF*ˆ›ü~ë¶¿¸ˆî?ˆÆ<&ûØiÅxÚgß�OZóÁ‹Òî× ×Êa?ý<ÍdCÈä³çÎ%Þ $ˆö}(½¼mA4vžTYØöµcãø��÷gÜM‹«Lŧ0ðq—Ä,ÆM_âòîE¹4¹ êŒí=ª°‹˜X­§: pt)64Ëóôö²üŒ›;ú^Hï]Îûrœ³Œ»4«U!Z‹ òs;xÖ�rŸD¹¯Á KØ èRåõqB4¬n…б�_úážÚâÄ~>(G²>|Ò§WpÓ2œWÈ]â0®ycéî÷”uÊ¢n’G>}Ø8ߢ±šQÖ£nD¹&~�ôòÇ›æ?Ç]WPž—„P¸~öµ@^ŹK¸kñ\WŠmG„Îò<•k«ý˜ÆïB¢OtôV?›ò)[5¶©>Æ;tæ‡ot'Ô(ä—‰H$z‚¨MÔæWÜ„ÓÃW+ˆY„sijöš9 h}FßD ŒS8‡¶4diÇI_r¿‚ôE$zBÓ8h<¯²7wÇÙÙ.ì_¹�$DÂ>yñÎ ó/²ÝÀ}(~Ƈ*C�Lº1}[§ò\Á½ç®@úÏê5–€î˜Î>9¡°mcMåCuè7¯$î=ÏÖWŠ2�ûSÝI„±K!ð#u±¿`mØX'vvv`¦ù1®CS'”®¨‚Öo‘¨ÎΜSÙNÌNû›u6ËuzðÉ’x4è•^*ž3©¢ø‘Û3ö Â.‚HBऌ³X2k*ë­m‘™N'A×½¯ÿÿÿÿ¥fDÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ÷òðýãÐŹ—O%n—M&q˜M(t–M'w–M&z–N%|™N&—N%‚–M&…–M'ˆ–O'‹˜N&�—N%�—N&“—M%––N&™—O&›–M%ž˜N'�—O%n•K%)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿªU˜N&�—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–M&zªU+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ‡ÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’I$•P+0–N'.˜O$*˜L)%“M'!›R$•J+”Q(™M ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€€�P0›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%YÍ«˜‹ÿÿÿÿ©lKÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿóìçýàɽЗN%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã˜N&£’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L$T—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�šO(Gÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ�N''–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"ªUUÿÿÿÿÿÿÿÿÿÿÿÿ€€–K-•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y׺«˜ÿÿÿÿ ]9ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ÷ñîýÜÅ·Í—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—O&Õ˜O$*ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&Ÿ—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�˜O&~€@@ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŸ@ ™O%7–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"’I$ÿÿÿÿÿÿÿÿÿÿÿÿ�N' ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%YßȽ¦þþýþ˜P)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿûøöþÙ¾¯È—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'Ò’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•M&5—M&µ—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�˜O&ˆ™O(-ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—P( ™O%7–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"™M3 ÿÿÿÿÿÿ€U+ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%YëÞÖÁóêæü—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžY4ÿÿÿÿÿЭš½—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—M'„ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N'u—M&µ—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�˜O&ˆ–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•M&5™O%7–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"™M3 UUÿÿÿÿÿÿ‹F. ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Yùôòí×¼¬ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ«oOÿÿÿÿÿÁ•}°—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ã€U+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&©—M&µ—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�˜O&ˆ–M&~ªUÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ‹F. •M&<™O%7–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"™M3 €@@ÿÿÿÿ‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U¸„ipÿÿÿÿ½�uÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ½�vÿÿÿÿÿ©nMž—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò—L&6ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L"%—N&º—M&µ—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�˜O&ˆ—M'„“M'!ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•O,•M&<™O%7–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"™M3 €U+ÿÿÿ€€‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'UåÔɰüûúþ ]8ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÓ³¢ÿùõò÷˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò˜O'Tÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜M%Y—N&º—M&µ—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�˜O&ˆ—M'„˜P&Cÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N'.•M&<™O%7–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"™M3 €U+ÿÿÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q _>[ûù÷ô×¼­ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿîáÛýéÚÒܘO&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M%„—N&º—M&µ—N%±—O'¬–M&¨˜N&£—O&Ÿ—N&š—N%–—M'‘–N&�˜O&ˆ—M'„•M%`ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™Q&<•M&<™O%7–P(3–N'.˜O$*˜L)%“M'!›R$•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&QÝÆºœÿÿÿÿ«pPÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¢a=ÿþþþÿЯ�¼˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&®—N&º—M&µ—N%±—O'¬–M&¨˜N&£—O'˜—N&š—N%–—M'‘–N&�˜O&ˆ—M'„–M&z€€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•U+ —P(@•M&<™O%7–P(3–N'.˜O$*˜L)%”R)›R$•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N¬sS_üúøôàʽþ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÚƒÿÿþþþ¨oNœ˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ“N'˜N&¾—N&º—M&µ—N%±—O'¬–M&¨˜N&£˜N'\—N&š—N%–—M'‘–N&�˜O&ˆ—M'„—N&•J ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—L&—P(@•M&<™O%7–P(3–N'.˜O$*˜L)%ŒM&›R$•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'Nïãܽüúøý¤eBÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿêÛÓþîãÜã–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–O&D˜N&¾—N&º—M&µ—N%±—O'¬–M&¨˜N&£•J+—M'˜—N%–—M'‘–N&�˜O&ˆ—M'„—N&–P#3ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ›Q%)—P(@•M&<™O%7–P(3–N'.˜O$*œJ)™M ›R$•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&JÒ²¢�þþþÿÈ¡Œÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ«oOÿÿÿÿÿΫ™·–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N%h˜N&¾—N&º—M&µ—N%±—O'¬–M&¨˜N%�ÿÿÿ—N&—N%–—M'‘–N&�˜O&ˆ—M'„—N&–P&Iÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—P&6—P(@•M&<™O%7–P(3–N'.˜O$*›N,UU›R$•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F¶‚g^üúøóéÙÑý™R+ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿؾ¯ÿúöô÷�Y4�–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–O'‹˜N&¾—N&º—M&µ—N%±—O'¬–M&¨™M$pÿÿÿ—M%`—N%–—M'‘–N&�˜O&ˆ—M'„—N&—P%`ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M'B—P(@•M&<™O%7–P(3–N'.˜O$*�P0ÿÿÿ™R)•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B¢cBM÷ñîÛ÷òïý¢a>ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¤dAÿýüûþܵĖM'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•N&®˜N&¾—N&º—M&µ—N%±—O'¬–M&¨˜N%Rÿÿÿ˜N%E—N%–—M'‘–N&�˜O&ˆ—M'„—N&–N%uÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ‹F. ˜N%E—P(@•M&<™O%7–P(3–N'.˜O$*ªU9 ÿÿÿ™M&•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>�R/Aøóðºýüüþ²|^ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿؾ¯ÿýûúö®rTU˜L&J—M%L–N'N–P&P˜N%R˜O'T—M'V•M%Y–O%Z˜N'\˜O&^–L%a•M'c˜N&°—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’I$–N&ØN&¾—N&º—M&µ—N%±—O'¬–M&¨—L&6ÿÿÿ˜O$*—N%–—M'‘–N&�˜O&ˆ—M'„—N&—O%{™U"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•J+˜N%E—P(@•M&<™O%7–P(3–N'.˜O$*ªUÿÿÿ™U"•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:šQ(?ïåß±þþþÿ½�vÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ«oOÿþþýþÿÿÿ{ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿšP%0–N&ØN&¾—N&º—M&µ—N%±—O'¬–M&¨•J+ÿÿÿ’I$—N%–—M'‘–N&�˜O&ˆ—M'„—N&—O%{•N+$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•N#$˜N%E—P(@•M&<™O%7–P(3–N'.”Q"&ÿÿÿÿÿÿ™M •J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7œR+;ðåß®ÿÿÿÿÆŸ‰ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—O'ÿèØÏýýüüäÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N'N–N&ØN&¾—N&º—M&µ—N%±—O'¬—M&¢€€ÿÿÿÿÿÿ˜O&ˆ—M'‘–N&�˜O&ˆ—M'„—N&—O%{—L&6ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L&/˜N%E—P(@•M&<™O%7–P(3–N'.™M"ÿÿÿÿÿÿ™33•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3–R)8ðæàªÿÿÿÿÈ¡‹ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÁ•~ÿÿÿÿÿÿÿÿPÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜O&j–N&ØN&¾—N&º—M&µ—N%±—O'¬–N%Šÿÿÿÿÿÿÿÿÿ˜N&r—M'‘–N&�˜O&ˆ—M'„—N&—O%{•M(Fÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•K(:˜N%E—P(@•M&<™O%7–P(3–N'.•J ÿÿÿÿÿÿ•J+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3ðåà§ÿÿÿÿÉ£Žÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¦hEÿüù÷üÿþþ¬ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&…–N&ØN&¾—N&º—M&µ—N%±—O'¬–N&rÿÿÿÿÿÿÿÿÿ—P&]—M'‘–N&�˜O&ˆ—M'„—N&—O%{•L&Wÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–K&D˜N%E—P(@•M&<™O%7–P(3–N'.œU+ÿÿÿÿÿÿÿÿÿ’I$”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+œS/1ñèâ¦ÿÿÿÿÊ¥�ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™R+ÿêÛÒüýüüéÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢–N&ØN&¾—N&º—M&µ—N%±—O'¬–M%Zÿÿÿÿÿÿÿÿÿ˜N'H—M'‘–N&�˜O&ˆ—M'„—N&—O%{—M(gÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™f3–M&I˜N%E—P(@•M&<™O%7–P(3–N'.•U+ ÿÿÿÿÿÿÿÿÿŽG+”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''¨kG2÷ðìºÿÿÿÿÊ¥�ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÖºªÿÿþþýÿÿÿCÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&½–N&ØN&¾—N&º—M&µ—N%±—O'¬˜L&Cÿÿÿÿÿÿÿÿÿ–P(3—M'‘–N&�˜O&ˆ—M'„—N&—O%{–N'uªUÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŸP –M&I˜N%E—P(@•M&<™O%7–P(3–N'.ªU+ÿÿÿÿÿÿÿÿÿ’I$”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( •N#$¹‹t7ùõóÏýüüþ¿’yÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿØ�ÿÿÿÿÿÿÿÿsÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ”Q(—N&Ç–N&ØN&¾—N&º—M&µ—N%±—O'¬šM$+ÿÿÿÿÿÿÿÿÿ™M"—M'‘–N&�˜O&ˆ—M'„—N&—O%{—N'v–K-ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—L&–M&I˜N%E—P(@•M&<™O%7–P(3™O(-ÿÿÿÿÿÿÿÿÿÿ‹F. ”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$—P( Ì­•Aúøöáú÷öý´bÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¸†jÿþþýþýýý£ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L&/—N&Ç–N&ØN&¾—N&º—M&µ—N%±—O'¬™M&ÿÿÿÿÿÿÿÿÿŸ` —M'‘–N&�˜O&ˆ—M'„—N&—O%{—N'v–K&"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ”Q(&–M&I˜N%E—P(@•M&<™O%7–P(3“M&(ÿÿÿÿÿÿÿÿÿÿÿÿ€@ ”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+›R$ÞǺNýûúîöðìü«qQÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ®uVÿüúùüþþü¿ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M%L—N&Ç–N&ØN&¾—N&º—M&µ—N%±—O&§€€ÿÿÿÿÿÿÿÿÿÿÿÿ—M&…–N&�˜O&ˆ—M'„—N&—O%{—N'v”M$2ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N$1–M&I˜N%E—P(@•M&<™O%7–P(3›M'!ÿÿÿÿÿÿÿÿÿÿÿÿ€@@”Q(™U"™M3 €U+ÿUU‰N' ŸP ™M&•J+îâÛiýüüøîâÛü£b?ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ­tUÿú÷õüþýûÍÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—Q#•M&5—O&Q•M'cÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N%h—N&Ç–N&ØN&¾—N&º—M&µ—N%±˜N'�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&q–N&�˜O&ˆ—M'„—N&—O%{—N'v—M'Bÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•M&<–M&I˜N%E—P(@•M&<™O%7–P(3—L&ÿÿÿÿÿÿÿÿÿÿÿÿ”Q(™U"™M3 €U+ÿUU‰N' ŸP ªjJ÷ñð—ÿÿÿÿáÌÁþ�X3ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ­tUÿú÷õüýûûÓÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿ•P%0—N&¶—N&Õ—N&ö—O&ý—N&ù˜N&ô—M'î€U+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&…—N&Ç–N&ØN&¾—N&º—M&µ—N%±˜N&yÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N'\–N&�˜O&ˆ—M'„—N&—O%{—N'v•N%Rÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O(G–M&I˜N%E—P(@•M&<™O%7–P(3’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–K-™U"™M3 €U+ÿUU‰N' Öº¥%ûù÷Åþþþÿ̨”ÿ˜O(ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ°y[ÿú÷õüþýýÒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿ–P(3—N&ÿ—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ðœR)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&¢—N&Ç–N&ØN&¾—N&º—M&µ—N%±—P%`ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N'H–N&�˜O&ˆ—M'„—N&—O%{—N'v–O'aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€@@–N'N–M&I˜N%E—P(@•M&<™O%7–P(3’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’I$™U"™M3 €U+ÿUUïãß@ýûûèúöôü¶ƒhÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿº‰nÿýûúýþýûÍÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿ™M&—N&ÿ—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð–O&=ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N&¾—N&Ç–N&ØN&¾—N&º—M&µ—N%±˜N'Hÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N'4–N&�˜O&ˆ—M'„—N&—O%{—N'v–M'p€€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™U"–N'N–M&I˜N%E—P(@•M&<™O%7–P(3€@ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•U+ ™U"™M3 €U+ÿüüú`þýüùðåßü¨jIÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÄš„ÿÿþþþþþü¼ÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&ó—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð˜N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™U"—N&Ì—N&Ç–N&ØN&¾—N&º—M&µ—N%±—N$1ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿœJ)–N&�˜O&ˆ—M'„—N&—O%{—N'v–N&r™U"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—L&–N'N–M&I˜N%E—P(@•M&<™O%7–P(3€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŽU ™U"™M3 ȶ¤ÿýý�ÿÿÿÿÜöþ›V/ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜O(ÿѰžÿÿÿÿÿýýýœÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&Ò—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð—O%{ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—Q),—N&Ì—N&Ç–N&ØN&¾—N&º—M&µ—N%±•J+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ‹F. –N&�˜O&ˆ—M'„—N&—O%{—N'v–N&r™M"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿšQ(&–N'N–M&I˜N%E—P(@•M&<™O%7™O(-ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿªU+™U"çÛÕ*ýûûÑþýüþÅ�‡ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿšS,ÿÜöþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&®—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N%�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–P&I—N&Ì—N&Ç–N&ØN&¾—N&º—M&µ—N&®ªUUÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•M'„˜O&ˆ—M'„—N&—O%{—N'v–N&r™O(-ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™M$2–N'N–M&I˜N%E—P(@•M&<™O%7”Q(&ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€@@ðêáUýüüñöðíü°y[ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ]9ÿæÕÌýÿþþüÿÿÿbÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N'Š—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð—M%¿ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N&f—N&Ì—N&Ç–N&ØN&¾—N&º—M&µ—M%˜ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M'p˜O&ˆ—M'„—N&—O%{—N'v–N&r–K&=ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ›O&=–N'N–M&I˜N%E—P(@•M&<™O%7”R)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýý�þþþþç×Íü¡_;ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ®vWÿôíéýþýüòÿÿÿHÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N&f—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ßÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N'ƒ—N&Ì—N&Ç–N&ØN&¾—N&º—M&µ—N&€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N'\˜O&ˆ—M'„—N&—O%{—N'v–N&r—M%Lÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&I–N'N–M&I˜N%E—P(@•M&<™O%7•J+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþ¸ÿÿÿÿÕ¸¨ÿ™Q*ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÁ•}ÿýûûþùöôâÿÿÿ)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L&C—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë’I$ÿÿÿÿÿÿÿÿÿÿÿÿ–N&¡—N&Ì—N&Ç–N&ØN&¾—N&º—M&µ™N&fÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N'H˜O&ˆ—M'„—N&—O%{—N'v–N&r—N$[ÿÿÿÿÿÿÿÿÿÿÿÿªUšO&Q–N'N–M&I˜N%E—P(@•M&<™O%7–K-ÿÿÿÿÿÿÿÿÿÿÿÿ0þýüãûø÷üº‰nÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ›U/ÿÙ¿±ÿÿÿÿÿóé㹨fEFÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ”J)—N&ÿ—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ëšM&5ÿÿÿÿÿÿÿÿÿÿÿÿ–N&¾—N&Ì—N&Ç–N&ØN&¾—N&º—M&µ–N$Nÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•M&5˜O&ˆ—M'„—N&—O%{—N'v–N&r˜M&jÿÿÿÿÿÿÿÿÿ™U"˜N%R–N'N–M&I˜N%E—P(@•M&<™O%7ªU ÿÿÿÿÿÿÿÿÿQþþýöñçáû§jHÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¦hEÿíáÚýþýüüæÕÊŒ˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€€—N&÷—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë—M'Vÿÿÿÿÿÿÿÿÿ•U+ —N&ЗN&Ì—N&Ç–N&ØN&¾—N&º—M&µ•M&5ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ›M'!˜O&ˆ—M'„—N&—O%{—N'v–N&r˜M%m•U+ ÿÿÿÿÿÿÿÿÿ—L&˜N%R–N'N–M&I˜N%E—P(@•M&<™O%7ªUÿÿÿÿÿÿyþþþþäÐÆýžY4ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ·…iÿùöôýûøöëбŸbšO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&Ö—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë—N%vÿÿÿÿÿÿÿÿÿ˜O$*—N&ЗN&Ì—N&Ç–N&ØN&¾—N&º—M&µ’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ�N' ˜O&ˆ—M'„—N&—O%{—N'v–N&r˜M%m—L&ÿÿÿÿÿÿÿÿÿ–N''˜N%R–N'N–M&I˜N%E—P(@•M&<™M$2ÿÿÿýýý¤ÿÿÿÿÓµ¤ÿ™Q)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™Q*ÿѰžÿþþþÿ÷òðʸ‡iD”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&³—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë—N%–ÿÿÿÿÿÿÿÿÿ—O(G—N&ЗN&Ì—N&Ç–N&ØN&¾—N&º–N&´™33ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜O&�—M'„—N&—O%{—N'v–N&r˜M%m˜O$*ÿÿÿÿÿÿÿÿÿ–P(3˜N%R–N'N–M&I˜N%E—P(@•M&<¿‘s<ÿþüÈþýüþ˜€ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ£c@ÿéÙÑýÿþþþñæà–¡X41–P(3”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N%�—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë—N&¶ÿÿÿÿÿÿÿÿÿ—N&e—N&ЗN&Ì—N&Ç–N&ØN&¾—N&º—M&Ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N%o—M'„—N&—O%{—N'v–N&r˜M%m–M)8ÿÿÿÿÿÿÿÿÿ–Q$?˜N%R–N'N–M&I˜N%E—P(@»�uSüù÷àúöôü³}`ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¶ƒhÿùõòüýûúïßʽašM$+˜L&/–P(3”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&l—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë—N&Øÿÿÿÿÿÿÿÿÿ—N'‚—N&ЗN&Ì—N&Ç–N&ØN&¾—N&º—O&…ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N$\—M'„—N&—O%{—N'v–N&r˜M%m—O(Gÿÿÿÿÿÿÿÿÿ–N%K˜N%R–N'N–M&I˜N%EÀ’{Yûöõè÷ñîü«oOÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™Q)ÿ˧“ÿÿþþþúöôËÆž„:–N''šM$+˜L&/–P(3”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O(G—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë—N&çœG+ÿÿÿÿÿÿ—M&¢—N&ЗN&Ì—N&Ç–N&ØN&¾—N&º–M%mÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&I—M'„—N&—O%{—N'v–N&r˜M%m•L&Wÿÿÿÿÿÿ€€”P'V˜N%R–N'N–M&IÅšƒeûøõïôìçü¦hEÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¢a>ÿè×Ïýÿÿÿþ÷òï�ªjG$•N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–K&"—N&ÿ—O&ý—N&ù˜N&ô—N&ð—N&ë—N&çšO(:ÿÿÿÿÿÿ–N&È—N&ЗN&Ì—N&Ç–N&ØN&¾—N&º˜N%Rÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•M&5—M'„—N&—O%{—N'v–N&r˜M%m—M(g€€ÿÿÿ–K-•O&W˜N%R–N'NÆ�ˆküúùöðåßü¢a>ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¶‚fÿøôñüýûûïëÜÖX›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€€—N&ó—O&ý—N&ù˜N&ô—N&ð—N&ë—N&ç˜O'aÿÿÿ™R)—N&Õ—N&ЗN&Ì—N&Ç–N&ØN&¾—N&º–P(3ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™M"—M'„—N&—O%{—N'v–N&r˜M%m—N'iŽG+ÿÿÿ–K&"•O&W˜N%R¶‚hbûøöîðåßüŸ\7ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™Q)ÿÏ­›ÿþþþÿüúùÈ×µª-•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&É—O&ý—N&ù˜N&ô—N&ð—N&ë—N&ç—N'‰ÿÿÿ—L$@—N&Õ—N&ЗN&Ì—N&Ç–N&ØN&¾—N&ºœU+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€U+–N'ƒ—N&—O%{—N'v–N&r˜M%m—N'i•N#$ÿÿÿ”M)2•O&W¨jF[øóðá÷ñîü¤eBÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ]9ÿçÖÍýÿÿÿþúøöŒ³sM™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>˜N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&¢—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&›—O&ý—N&ù˜N&ô—N&ð—N&ë—N&ç˜M'²ÿÿÿ—M%g—N&Õ—N&ЗN&Ì—N&Ç–N&ØN&¾–N&­ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N&r—N&—O%{—N'v–N&r˜M%m—N'i•R&5ÿÿÿ—M'B•O&WòèãÊûù÷ý­sSÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ«pPÿõíéûþýüòüüøL‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>–M(?”Q(™M&’I$—Q#�N!•J ™G)“N'—L&›R$•O#™M+”J)—P( “M'!–K&"™P$#•N#$˜L)%”Q(&–N''™M&(•Q%)˜O$*šM$+—Q),™O(-–N'.˜L&/šP%0—N$1—N&©—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜M%m—O&ý—N&ù˜N&ô—N&ð—N&ë—N&ç—N'Ú¿@@—M%‘—N&Õ—N&ЗN&Ì—N&Ç–N&ØN&¾–O'‹ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M(Z—N&—O%{—N'v–N&r˜M%m—N'i–P&Iÿÿÿ˜O'TÚÁ´™ÿÿÿÿ¾�wÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿº‰nÿüùøýýüüÜÿÿÿ'UU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N%>—O&ý—N&ù˜N&ô—N&ð—N&ë—N&ç—N&â™O(-—M%À—N&Õ—N&ЗN&Ì—N&Ç–N&ØN&¾—N'iÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M'B—N&—O%{—N'v–N&r˜M%m—N'i˜O&^’I$³}_nüûúùÞǺþ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜O(ÿÌ©•ÿþþþÿþþü¸ÿÿÿÿÿÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¢F. ˜O&ù—N&ù˜N&ô—N&ð—N&ë—N&ç—N&â—N&s˜N&Ù—N&Õ—N&ЗN&Ì—N&Ç–N&ØN&¾—M'BÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿšQ(&—N&—O%{—N'v–N&r˜M%m—N'i–O&d™M$2îãÝÈùõóý ]9ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜O(ÿÛÀ³þÿÿÿÿÿÿÿŠÿÿÿÿÿÿÿÿÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N'Í—N&ù˜N&ô—N&ð—N&ë—N&ç—N&â—N'͘N&Ù—N&Õ—N&ЗN&Ì—N&Ç–N&ØN&¾™R)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿªU —N&—O%{—N'v–N&r˜M%m—N'i–O&dÉ¡‹€ÿÿÿÿÄš„ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—O'ÿÔ·¦ÿÿÿÿþÿÿÿgÿÿÿÿÿÿÿÿÿÿÿÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N'——N&ù˜N&ô—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&Ì—N&Ç–N&×N&®ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N&l—O%{—N'v–N&r˜M%m—N'i–O&dòèäÖñèãý™Q)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ̨”ÿÿÿÿÿÿÿÿtÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—P&]—N&ù˜N&ô—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&Ì—N&Ç–N&ØN%ƒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜M'O—O%{—N'v–N&r˜M%m—N'i—€…ÿÿÿÿÀ“{ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¹ˆmÿþþþÿÿÿÿŒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ“N'–N&ø˜N&ô—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&Ì—N&Ç–N&ÙP&Pÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™O(-—O%{—N'v–N&r˜M%m—N'iêÞÖÆöðíý™R+ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¦hEÿûù÷ýþþüºÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿUU‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò–N'\ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N%͘N&ô—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&Ì—N&Ç–N&×L&ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™M –O&z—N'v–N&r˜M%m£eCrýüûúÏ­›ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜P)ÿè×Ïýýüüéÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€€‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò˜L&Wÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M&‡˜N&ô—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&Ì—N&Ç–O&¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N$b—N'v–N&r˜M%mÒ³¡žÿÿÿÿ¨kJÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—ÿÿÿÿÿÿÿÿKÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€€‰N' ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ò—L(@ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–K(3˜N&ô—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&Ì—N&Ç–N&fÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M$8—N'v–N&r˜M%mìߨÌñèãý—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿšT-ÿõîëûþþü³ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•U+ ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î—N&ì’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O&Å—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&̘O'Æ’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™M —N(s–N&r˜M%mú÷öõÓ³¢ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÁ•~ÿÿÿÿþÿÿÿ/ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’I$ŸP ™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê—N'î–M%žÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–O%a—N&ð—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&̘N'�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L(M–N&r³|^€ÿÿÿÿ¼Œsÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿêÜÓüþþüÅÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™U"™M&•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç—N&ê–N'å—P( ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€€–N&·—N&ë—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ЗN&Ä™M"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•U+ •O&jΪ˜œÿÿÿÿ¦hEÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¢a>ÿÿÿÿÿÿÿÿeÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¿@@¡Q(•J+›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N&ç˜O&æ•L(Mÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•N#$—N&â—N&ç—N&â–N&Þ˜N&Ù—N&Õ—N&ИO&Wÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•Q%)Üö¯ýýüÿ™Q*ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¿‘yÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¿@@™M&›R$—P( •N#$–N''šM$+˜L&/–P(3”O%7šO(:˜N%>—M'B•M$F˜O&J–N'N—O&Q–N'U˜M%Y—M&]–O%a—N&e˜N'h—N&l–M'p˜M%t—O&x–N%|—N&˜N'ƒ—M&‡–M'‹–N%�˜O&’—N%–—N&š˜M'ž—M&¢—N&¦—N&©˜N&­—N%±—M&µ–M'¹—N&½—N'À—N&Ä–N&È—N&Ì—N&ЗO'Ó—N&×—N&Û—N&ß—N&ã—N%͘N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™O(-—N&Å—N&â–N&Þ˜N&Ù—N&ΗM'Vÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿöîë¢ôìèü—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÓ´£ÿþüüèÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™M —Q#”R!•N+$™M&(—K),•P%0–P#3œP&6šO#:”N%>•N#A˜N%E–M&I—M%L˜M'O—M%S•L&W•N$[˜O&^˜O'a—N&e—N'i—N&l–P'p•M%t–M'w™M(z˜M&~—M'‚–O&…˜O'ˆ—N&Œ—N%�™N&“˜N%—–O&›–O%ž˜N&¡—M%¥—M&©–M&­˜N&°—N&³–N&·–N&»˜N&¾—O&—O'Å—O&É—N&—O&Ÿ–N'\™M ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€@@–N'.•N$[™N%K’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ£íàÙû—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿãÐÆþÿÿþ¼ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ®èØÎü—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿíáÙûÿÿÿ¤ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¯è×Ïý—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿòèâúÿÿÿšÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿšÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŸîãÜû—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿêÛÓüÿÿÿ©ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýýý¥ÿÿÿÿþýüíÿÿÿFÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ�õïëú—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿßɽÿþüüÂÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿoÿÿÿÿ̨•ÿéÙÐýþþþþÿÿÿ�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€üù÷ü—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÕ¸§ÿþüüäÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ>ÿþþûÚ¿±þ—N&ÿ™Q*ÿѰžÿþþþÿþþü¾ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ\ÿÿÿÿ¢a>ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ»Œrÿÿÿÿÿÿÿÿ(ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþýýëìÞ×ü›T.ÿ—N&ÿ—N&ÿ—N&ÿ¸†jÿúöôüýüûêÿÿÿ?ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ,ÿÿÿÿ¶ƒhÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžY4ÿýüüþÿÿÿvÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþýûØ÷òïü¤d@ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¥fCÿëÞÖüÿþþüÿÿÿyÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþýøË¦’ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿãÐÅýþýûÚÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿþüÇûø÷ü¬qQÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿšS,ÿÔ¶¦ÿÿÿÿÿþþü·ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþüûÇåÓÉý—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ´bÿÿÿÿÿÿÿÿ`ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþü´þüüý³}`ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ»Œrÿûø÷üýüûåÿÿÿ8ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿsþþýþ ^:ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿâÍÂýþýýîÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿþµþþýþ»‹qÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ½�vÿÿÿÿÿÿÿÿ™ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþ—ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¢a=ÿõïëüýûúÙÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþüÃþüüýºŠoÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¡_:ÿöïëüýûûÑÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþüûÉêÚÑü—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ«qQÿüù÷üþüüÁÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ,þýýÖüúøü´bÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™Q)ÿæÕÌýþýüðÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿXÿÿÿÿ³}`ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿµ�eÿüúùýýûûÔÿÿÿ'ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿmþþýùóëæû­sSÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿѱŸÿþþþþÿÿÿIÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýûúÚèØÍü—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ®uVÿõîêûþþýøÿÿÿ~ÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿPþýýÕÿÿÿÿÚÀ²þ�X2ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ»ŒrÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿYÿÿÿÿµ�eÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžY4ÿÖºªÿþþýþþýüñÿÿÿ�ÿÿÿGÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ/ÿÿÿ‚ýüüßÿÿÿÿëÜÓü´bÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ²|^ÿýüûýþþüµÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþüûÊõîëûžY4ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¨kJÿÕ¸¨ÿúöôüÿÿÿÿþýýûþüûÊÿÿÿ±ÿÿÿžÿÿÿŠÿÿÿ–ÿÿÿ¦þþüÅþýü÷ÿÿÿÿýûúýÝŹþ¶‚fÿ—O'ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¬rRÿûø÷üþüûÉÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ'þþýøÙ¾¯þ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜P)ÿ­tUÿÉ£ŽÿÝŸÿçÖÍýðäßú÷ñîûóëæúìÝÖûàʾþ̨•ÿ·„hÿ�X2ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ§jHÿ÷òðüþýýÖÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqÿÿÿÿ½�uÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ£c@ÿõíéûýüüâÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþ¯þýýý´€cÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ©mLÿõîëûþýýêÿÿÿ$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþüÂüúøü®uVÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ²|^ÿú÷õüýüüÝÿÿÿ"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþýýÓüùøý¹ˆmÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¼�sÿþüüýþüûÉÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþþü¹þþþÿʤ�ÿ—O'ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿšS,ÿѱ ÿþþþÿÿþþ¯ÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ�ÿÿÿÿåÓÉý¦hFÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ­sTÿíàÚüÿþþýÿÿÿ€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿSþýýèüûúýÊ¥‘ÿ›T.ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¤d@ÿÖºªÿÿþþþýüüÞÿÿÿ=ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•þþþþ÷òïüÉ£ŽÿžY4ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¤dAÿÖ¹©ÿýüüþþýüõÿÿÿ~ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ.ýýý§ÿþþýû÷öüÓ´£ÿ±z\ÿ˜P)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ�X2ÿ½ŽtÿàË¿þþýýýþýüôÿÿÿƒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ#ÿÿÿ–þýüðÿÿÿÿøóðû×»¬ÿÁ•}ÿ­sTÿ›U/ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ]8ÿ´€cÿÉ£ŽÿàʾþýûúýÿÿÿÿýûûÖÿÿÿyÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿEÿÿÿ—ýüüåÿÿÿÿÿÿÿÿþþýþôíéúîâÛûç×ÎýàË¿ÿãÏÄÿëÜÔûòéãúøôñûÿÿÿÿÿÿÿÿþþýúþýûÍÿÿÿ�ÿÿÿ*ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿBÿÿÿqÿÿÿ�ÿÿÿŸÿÿÿ®ÿþþ¼ÿÿÿ¹ÿÿÿ©ÿÿÿ™ÿÿÿ‡ÿÿÿcÿÿÿ1ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÏÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿð?ÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿüÿÿÿÿÿÿÿÿÿð?ÿÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿþüÿÿÿÿÿÿÿÿüÿÀÿÿÿÿÿÿÿÿø?ÿðÿÿÿÿÿÿÿðÿÿüÿÿÿÿÿÿÿðÿÿþ?ÿÿÿÿÿÿÿàÿÿÿ?ÿÿÿÿÿÿÿÀÿÿÿ€ÿÿÿÿÿÿÿÀÿÿÿÀÿÿÿÿÿÿÿ€ÿÿÿàÿÿÿÿÿÿÿ€?ÿÿÿðÿÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿþÿÿÿÿøÿÿÿÿÿÿþÿÿÿÿøÿÿÿÿÿÿÿ€ÿÿÿÿøÿÿÿÿÿÿÿÀÿÿÿÿüÿÿÿÿÿÿÃðü?ÿÿüÿÿÿÿÿÿ€üøÿÿüÿÿÿÿÿÿ~ðø?ÿÿþ?�ààÿÿþ?ÿàÀÿÿüÿÀÀÿÿüÿÀ€ÿÿüÿÀ€ÿÿøÿ€ÿÿøÿ€ÿÿøÿ€ÿÿøÿ€ÿÿøÿÿÿðÿÿÿðÿÿÿðÿÿÿðþ€ÿÿðþ€ÿÿàþ€þÿÿàþ€þÿÿà þ€ÿþÿÿàþ€ÿþÿÿàþÀÿþÿÿàüÀÿþÿÿàüÀÿþÿÿÀüÀÿþÿÿÀüÀÿþÿÿÀüÀÿþþÀ>üÀ?ÿþàÀ>üàÿþàÀ>øàÿÿþàÀ>øàÿÿþð€>øàÿÿþð€>øàÿÿþð€?øàÿÿþð€øÀ?ÿÿþð€ø€ÿÿþð€ðÿÿÿþð€pÿÿÿþðpÿÿÿþøpÿÿÿþø€pÿÿÿþøÿ€pÿÿÿþøÿ€pÿÿÿþøÿ€`ÿÿÿþøÿ€ ÿÿÿþøÿ€ ÿÿÿþüÿ€ ÿÿÿþüÿÀ üÿÀ üÿÀüÿÀþÿÀþÿàþÿà?þÿà?ÿÿàÿÿðÿÿÿðÿ€ÿ€ÿðÿ€ÿ€ÿøÿ€ÿ€ÿøÿÀÿÀÿüÿàÿà?ÿþÿðÿðÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþ?ÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿÿþÿÿÿüÿÿÿÿÿÿÿÿþÿÿÿøÿÿÿÿÿÿÿÿþÿÿÿðÿÿÿÿÿÿÿÿþÿÿÿàÿÿÿÿÿÿÿþÿÿÿÀ?ÿÿÿÿÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿ€?ÿü?ÿÿÿÿÿÿÿÿ€ÿðÿÿÿÿÿÿÿÿ€ÿÀÿÿÿÿÿÿÿÿÿÀþÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿðÿÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿü?ÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀÿÿÿÿÿÿÿÿÿÿÿàÿÿÿÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿ€ÿÿÿÿÿÿÿÿÿÿÿÿÿøÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ(  ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßʿś†i½�rÇ¢�eîÝÌÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÈ¡�b¥gEé˜P)ÿŸ[6ÿ—N&ÿ«pPÚË«“@ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ̦“P�Z5ø­tTËд¢RîææǤ�m¤c@è¨lJàêÊ¿ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¼�r¾©lLÛäȶÿÿÿÿÿÿÿÿÿÆŸŠo—N&ÿ–�uÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•U+ –N'uÞ€"Òµ >�N!Ÿ@ ™M –N''À•|q—O'ÿ±y[»—P&€—N'–˜O$Tÿÿÿÿÿÿ™N'A•N%�•O(:ÿÿÿ–N'.™U3™M&•M&5¶„h¬—N&ÿ±z]¾˜O&¯—N&ΗN&»ÿÿÿÿÿÿ–M&d–M'p˜M'OªUU–N'.’I$™M&»�r{Ÿ\7õ­sUÙ¬uS%™M$2—N&ΗM&¼•P%0˜L"%—M%„–K&=–M&dœG+”Q(&¢F. —�} ]8ö¦gFæÑ²Ÿ8ÿÿÿ”Q(—N&ΗM&¼˜M%m˜M%t—N%¤™U"˜N&r›M'!ªjG$¿“{œšT-ý¬qRÛϲ¤5ÿÿÿÿÿÿ”Q(—N&ΗM&¼˜L&C—N'�˜M%²ÿÿÿ–M&d­mGD¶ƒg½˜O(ÿ·„i¸±uYHÿÿÿÿÿÿÿÿÿ”Q(—N&ΗM&¼•J+—M&Ý—N%–ÿÿÿ–N'N¶‚f¶˜O(ÿÄ™‚�¢]:•M&5˜L$M˜O&j—M(‡—O%¥—N&ΗM&¼ÿÿÿ–N&­–M&PÿÿÿšQ/&«qPë±z]Âÿÿÿ™U"•M&5—M%S–N&r—M'‘˜O&¯—N&ΗM&‡ÿÿÿÿÿÿÿÿÿÿÿÿÿ ¥gDõµ�džÿÿÿÿÿÿÿÿÿÒ³�9Ǥ�mÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ±y[Ê¥fEåÒ¶¦?ÿÿÿÔ´¢GªmKÚ¦hEìáÎÂ*ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˧“N£b=î�X3û«qQèœV0ü£b?ëÈŸ‹Xÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿб§1º‰o‘³}_«¹ˆnŽÓ±¦.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüøðñÇÀÄÀ8�±Ïø‡øü(0 œœÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿåÕËOñâÝ5ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÔ·§|¼�sΩmM÷›U/ÿ¡_:ÿ·ƒfÞͨ•˜çÛÛÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿêê É£�´™Q)ÿ—N&ÿ™R+ÿ¥fDÿ›T.ÿ—N&ÿ—N&ÿºŒqÐàÓÇ)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÉ£�´—O'ÿ›U/þÛƒ·áËÀ]ÿøø%ßË¿XÇŸ‹­žZ5û—N&ÿÀ•|ÇÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÕ¸©sœV0ýšS,ÿÏ®�žÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÒ²¡’™Q*ÿ™Q*ÿÔ¸©vÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿѰŸµœV/þÄšƒ½ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ µ‚fÝ—N&ÿÁ”}±ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—K),–M'p¤mIÙ¼¬ràÎÅ9Ÿ` ™R)€€€€™M&’P$#˜L&/¼‹qË—N&ÿ·‡lÙ—M%`—N&l–N&y˜N&r”Q(ÿÿÿÿÿÿÿÿÿÿÿÿ—O'‘˜N&š—M'Bÿÿÿÿÿÿ•N+$™P$#Ÿ@ �N' ˜L)%•O(:›T-O·…hå—N&ÿ·†kÜ—N& ˜N&´—M&É—N&Ý—M'‘ÿÿÿÿÿÿÿÿÿ¢F. ˜O'²—M'‘—N'bÿÿÿÿÿÿ™M)2›M'!³M3 ™D3˜L)%•O(:ÇŸˆœ›U/þ—N&ÿ¾�wÈ—N& ˜N&´—M&É—N&ݘN&¦ÿÿÿÿÿÿÿÿÿ“O(-—N&³—N'b˜M'wÿÿÿªUšO(:—Q#¢F. ™D3˜L)%ÙƒsªpNë—N&ÿ²|^繆o7”M$+˜N%E—M&É—N&ݘN&¦ÿÿÿÿÿÿÿÿÿ˜O$M˜O&¯™M$2˜N'ƒŸ` ™U"—N';‹F. ¢F. ™D3ʤ�j®vUè—N&ÿ¢c>õ×»¬eÿÿÿÿÿÿ•O#—M&É—N&ݘN&¦˜L)%—N&“•U+ —O%n—N'–žI$˜N'ƒ“N'›R$—N';€³M3 ѱ¡¨iHò—N&ÿ¡^:ùÔ´¤�ÿÿÿÿÿÿÿÿÿ•O#—M&É—N&ݘN&¦™O(-—N&û–N'.—O&Ž–N&yÿ˜O&~”M$+˜O$*˜N'4ÿÿÿÏ®› Ÿ[6ú—N&ÿ©mMðÕ¹«mÿÿÿÿÿÿÿÿÿÿÿÿ•O#—M&É—N&ݘN&¦’I$˜N%÷—M%S–N%±—N'[ÿÿÿ˜M&j™Q&<–M$8¼�qDÄš„¾™Q)ÿ—N&ÿ´€cÞ™ƒqÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•O#—M&É—N&ݘN&¦ÿÿÿ—N&×—N&€—O&ɘN%>ÿÿÿ™N'U–N'N³}^Z¼ŒqЗN&ÿ™Q*ÿØ‚Ľ“xB˜Q(9ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•O#—M&É—N&ݘN&¦ÿÿÿ˜N&­—M&É—N&Ë—P( ÿÿÿ—L(@–O'aÀ”{Ë—N&ÿŸ[6úѲŸ•®k^˜L)%•O(:–O&D”M'V—M(g™O&x–M'‹—N& —M&É—N&ݘN&¦ÿÿÿ—O&x—N&ã—M%¿€@@ÿÿÿœN+$¹Šo–¡_;û—O'ÿЮœ�ÿÿÿ’I7˜L)%•O(:–N'N•M'c–M'w˜O'‹—N& ˜N&´—M&É—N&Ý–N&£ÿÿÿ•O#–N&Å–N&fÿÿÿÿÿÿªUÁ–}­—N&ÿ¬sSñôôéÿÿÿ’I$•N+$•O(:–N'N•M'c–M'w˜O'‹—N& ˜N&´—M&É—N&ЗO(Gÿÿÿÿÿÿ€€ÿÿÿÿÿÿÿÿÿÿÿÿÆ�ˆœ—N&ÿºˆmÕÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßǽhîîÝÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÌ«–ˆ—N&ÿ¬pPïä×É&ÿÿÿÿÿÿÿÿÿÿÿÿ×¾¯f¤b@ö½�tÌâÏÅ5ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿçÕË6¥hDö—N&ÿ¾�wÃçÔÊ5ÿÿÿÿëë Ôµ¦‡§hFñ—N&ÿ³}`ÜæÓÇ)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿаœ˜™Q*ÿ—N&ÿ¦gDö·…jÞ²|^ê˜O(ÿ—N&ÿ¯uVåß´GÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿѲ ‘¬qQï—N&ÿ—N&ÿ—N&ÿžY4ýÀ”{ÅâÎÄ4ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿðàØ!Ó´¤{ˤ�’Ϋ–†à˾Sÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿáÿÿþ?üüü>ðñ€á€áà0pðð„ð„„ŒÞ??þþÿ?ÿÿÀÿ‰PNG  IHDR\r¨fKÈIDATxÚí�w|åÇßÏ]ºè`o�=EP@'("¸·‚ ÷ú‰Š¨¸7*ŠŠ¸‘) àÆ�  ÙÈ^…2 -ÝM“»ç÷Ç5!…Ò&—\’¶÷~½x‘{’<÷Ü¥ÏçžñBJ‰��MÕD‰tlll"‡-66U[llª0ŽH7À&¢Ä €F@= .Pˆ-~/ pù€È2�½À> ­ø=› Š-U‡€^@7 Ðh�Ñуa/°ø¯øÿ¥ÀJ Á°‰r„½ Piiô.Î(>¹À?ÀB`° Ð#}ClŽÅ€ÊE+àjàR KyÎÎu’º?›ùddp #Ÿ¼Î"7Î"Íû9U$&Ä£*Ôªž@ýÚ‰Ô©‘@ƒÚ‰4¬›Œå¶k/ð-ð ð#öè j° â“\\ôJíŽÛ÷fÕ¦¬Ü¸ŸÍ;3ضç0YAŸsý÷¦ÿËø/—¢éöoРv"/ß=:•pgX vFº}["˹À,  7¿ˆÿ½µ€o�t»¢!àÚ'ñÈ�½p¨^û§ƒÀeÀï‘n_EÅ€È1ø’â½ý]û²¹eìwì¬âCþòèÕ¥1o>|Õ“â�»†olŠØ4AU&Œ¾ˆsޏçç+"Ò (Äkx‚â9ç–ÔLß; —;<[Ò ñnÔ…[/=™jñÇdý:ÌÁ™üŽ17Fª±¾#žîµê¾ÿPÎ\É—ó×E$`iµø>7Ð7ñ^Œi¶!¶XA]` Å~7>ù-‹Víˉ{vnÌów÷¡QÝ!µ$ð+F”¡¹€•�ã0 pnšù¾±3-‹—>ù›Ÿÿ¿§c� LyqM¤xŠVb¤K«ò‰Mm=ob²dáŠTn~ê;ËO£rÿµ§qýÀ“PJNòça8ɬ ó=P1ÜtNö}ã§¿·3ö½?IÏ oßkÞ¨:S_¾Ô׋p*pUÜPÈ€ÐRØÄëR2ôþ¬ßvÐÒÖL‰çÝÇ.¢k‡¾Å1Bgýáû!0Üž_ÀÈk9 Ÿœðón kcÎ8¹ Œ¹UñŠäh ¢*‹mZîâ~Z¼ÝòÎß¼Qu¾~iˆoç—À»Q†"Ýù=홎±`øÅYR’âxã¡ó}ë¾Á=,篕»yéãžEÏ`ì TYì@èHÄXXª 0ü¡™¬ÚtÀ²“5oT�/žD�šÕaôEaYPU…ýy¾E�€ç#}CÂ…-Á3Àóâ·e»°bWµÏ©Í|£ßæ£"}Ñ�œG±$&ÄðÁ˜‹iÛ¬VP•–Å R˜òâ÷ië[üðb¤oF¸° xÎó¼°jñïöá%bÝ=�áÛ^É.¤xO¾zR“Æðµáç÷lÁŒ×†rb«:ž"‰ÑñÏÒ"}#Â…-Ás:á¾–­ßúÊ»4ñ ïÇX¨ªÌÀØÙØ P·f5&>Ñß׆?(ªÂÿn>�ñ�^èë&��aø(Æ6e•Á€àh Ôغ+“ì\gÈOpÅE}_ªBú T HhÙ¤ˆ¸X5¨JÕMâ‹çqÃÀξ�QþÁ0�¶Þk+ ± 8zy^ü»a_È+OIŒ£ww¯Wm>ñ«›1vW ºwlÈ ÷œk:¢Ñ9ÝN`Öë—qr»úž"‰á¹y6U86€-Áq¢çź­¡wü9¿W ß§Þ<Œ¡jUb1p-Å >úŸÙŠÛ‡u ¨UU¸ÿÚÓxÿ‰þÔHŽ÷gÀ{1„µÊb @pxsRmÛúPßgœÜÄ÷pJ¤/6BÌòŒºª»oˆ¯2©[³“ŸÀÈËNñ9¬Àˆ\4#Ò Ø^غûpÈ+?õĆž—Xé‹� ¯QÑW‚W8¯\ç¡�1ëõËèÑ©‘oñgÀ™TLÓiK°À<hF€‹P»³6m�B½ZÞx›ÿa¬ŽWeî¤8˜gJbïüïÂÒb¢Á�—wcò3—P÷ˆ·d.†«ôuØaÀJ` €yj±,oÕòˆÃTO?+)Àˆ.t M³Z<7ªw‰EÁZÕø`Lî¾êTߨ?ë0üý¿ˆôD#¶˜Ç;>OϽ4kTbˆkY ¶—SØ´ß™­¸yÈÉtnS�¯\êk1 ð9pÆʦìÌ@æñZç² šÍQÖoá žÝü‚vý9€û¯=�¦ RÖ·=ê‘ðbæÒEº±ÑŽ=0�·‡ZatT2�ôH_l”ñÅÑ�TEpÅ…};ÿ& û »óû�-æñöP§þÿ q%gyf멤HŒLŽ*Ÿ œ ¬Št+ ¶˜ÇkUbE[Ê%#õzàÄò_NðÙŽªö€yb=/¬€£ê �'LåcFr�tŒèB6b €y\žGèRù….ßÃ$³õTæEº{ `ïÞßQÃõ��WPBBïocƒ-Áà�——f‘,Ùy%|Tê˜­ÇÆ¦,Â2è4üݤ¢"1P ôD—­TBfH�ŠT¾ßÐ}ß?r̘ðçŽï >H?õÒØ¹7Ë÷°�ÙzllÊÂRÑnà{·éRyT(BRêR :RÔCÐVGk·¢^j‡Áüï›áÎb ÞÕæ”E«ñå(ïÂŽfë %BѼ÷Çqɉ î8g|‘VÝáÐ5‡æÖ£ŠUWt·SÑ© Ç ‡"]“Š®H%Æ¡ ©+Bפ"5©8Ô8!©èºTTáR¤T„R‘ºTPc…T„�º[Q„T¤Ž‚B ©(Å!¤®¿'EHEE—ŠTŒÏ*ºT¤@‘R*!‰q¬KUE © £u !…¦ Q„T„Æ÷P„)B*ÆG¥‚�"¤î­_HŠT„@ !„® ¥"A12:E �BH© Q¤B£pã}¤¢„”(€"„T¤‰Òh‹§mP2CÀfP–9â´…k§Þ‘ðojUnÀ—¾S[êê[Rr #„&¤Ô¥áÛ­ Ð$èHt„ÔA ÄÄ�söM”2úGc'üÖsÌí½lÞ™Á€»§†´þ”Ä8–~y£çp;Ð2ˆê‚B Ÿ¦¶ÌÌLrÄëIª®9tU*š*©W+îôÅ�ܧÃKÍ¡¨ÅTRq(ÒÛÉuEª"£s«ÂOU…ªHU©KE(xÞ+)Åóž§£Jo'÷yOw4<ßC‘R(ŠB ¼Ôx¯¸ÃKE‘x:(G:¤@RZíÅuŠ0:´‚0:¬Ñy‹;²”ŠŠÆù(u !(þœPÀ¨ Ïù0ÚB‘²XŒŠ¯ÉxϤP¸ÌEwOÜ8ç® þþ¶–¬´>±ºîVçIÉù|M•0²í ú÷XûçRhÓ|]‰|(øšD<‚»¬½?&ˆ‹é¥ø¤�:p(Ϥ£C‹ÐïÔ-ZµÛ7ÑÈ„Á"PÄiÿ´NrÅÚÃýJ@Š"”/Ú œØþxé�,bbB3!¢OûA:[{ü§Õð‰Õ]võ-ûoÇÁž×�-Hcu(«€G¢ ׯ'¡U4ºdbB¾’ú}M›È ¨ú'M‡OK(íí� @»�ëä5!nýÕ–Þœpi§z†þþúw×.Ïë“ÚÔµä¼Ó.± {“•ר½ûĘøxìaeCˆ¶‰ÎŒÇJ{+„#m>r¡i8g¶8Ñ¿°Ò¦ÿø8!ÔNG—ÿ±bwz‘KËh{B-KL‚ç/Üêk|>F2RK(lé¶�Ž*)9²S)})$ †OSâÆàk:¶j‡¢]bùÝ)Gœ£RÓ9t]Ê}‡òv‚¾[dž�W^ù….æ-ôFS€Ç­¸Æî#'Æ8s5{Þ_y‰s£�8º0$?x»ÂCçh|M¥ ÅùbìØˆþaê:�Ž÷Þú-éÞ1ºOŸ�òÁÌ•hš×6ê|‘‡ŠØÌ<Û3´’#á1|Z‰ilH:–„+-lw�Ëžlå�)‹f&ÔDrÜ-É9 6yÍ—Ïëa�ìLËbæ¯=‡*ðT(ëBˆlâì…¿ÊŽ v냧ú-bø4E\le»5ôs¬¾7Ç#V)Ûç—¥»ögç9÷4ª—lY>ûw¦,ÇYä 2è²Ê‡Mµ‡þUU=|�ƒþáÛevGJkþê=z_‰Ùs‹r-7n?äœß³…%ÍØ{0—Ï¿[{äŽÀ$Bµåš^×¶ô«*HYÂ:0hP¤ µÆ‘° ì>rbŒ‚,wâûÅ[Wz^_z^{³)lËaüWKÙ™æun¼Šz»µÝd @•A©]â(Øê$\–v«á䥻Z½÷_Ÿ}»vëá\ç^€&õ“éÕÅ­*pºyô­hº×<ðV _°õfîµ Š ¥Lô=J:\úNm$�åk6ßòSƒ¯$0t·â·CÓ’5i =¯‡�_®†iþýo“g¯ö Œì7­Ã}ol*(B–û @×E_DùOÈ�´Né>rbx­Ôü˜ÿ{xû륋5]ºúölA� þ~5`Þür wxý�ja$ɰ‡ÚLP ¤65„ˆÏÚë:)\§ë<`BM„ð{qsãöŒÜm»3ÿˆq(Ü8¨‹emsiÜñÜ÷dfzŠ:ŸbŒllü&È5qfX«8:_‹ÆÈ€7õ?�³æŒ¬5\ÕÿDj$‡Ö2Ú—Ýûs¸ç¥Ÿp1<®ûcS90-.}§6èá S¥Kë&×ÇœËðJÞ´7ìܹ/k ABnd­3ã?köð¤E¾E�†íÙTxL €æV{I,Úï:RÐш£„n*×ä¹kæx^_{q'K"ùòÙ·kùjþ:ߢ—°ØkЦò`Z„"Ì¯Ê LE"�ÔjÐ;–ÇÆë>rbŒÐE}3ßýâ»5ÛöÈY �T-–QWZ¿yñôû ùö¯Ã�&bäɳ±)ók’^æ¿+3ûU¡cù4 ï€Þ!ÍÝ©ìž8sÅtYnùª~i×¼¶©ªüE—’GÞø•KwzŠTŒEÁÁVß+›Š�©?r!º›ù®Dæ ”éf,%äžpG£KÙÔìw¥t§}=oÍÊ ;2~ÃMøñ[ϰ|²äÖtî}é'–¬MóÅÓ�»­¾_6SÐú’w:J¨aî”b¥[•ÿšn° ½+ìÑH]šžfH‡š.‘þÄÛ¿Mu¹õ<€�qñYÖÛê¹¹}Ü÷üûŸ7Œ˜ ¼ ¼��6US âèiæ{Vl™yÛ{LVѲEŸÉÖí¯aN$úVGíCEšš¾vËÁœ_—nŸáykô-gXjä!7¿ˆžœËü…[}‹ïfb'µ9 S •’Á1AHV•°ÊÔ¹A�©™o�ËÐ~ФdÌvE?$§ÓtG\:À#oýúóáìÂíµª'ðì]½­jv œE÷½òÌXá[<ø ‚‰ØT\L.tIÓÜ¡_HS€&,³?PtWÓÍ’ØñÍ�YRÇ•_àÖž›´ð]M—E}NmÆå†'ÍŸ”ðʧÿð仸 µ–` ÙØ.B @1e‘'‘»þ›yç!MŠ•f-„u�15anûUM�RJTåÀì[vÿ¶lçWž�ÅÞ*¬B¾ „YÈ[×mo‰t·aj ¡_ŒÑãMï8µ„È©Ã4‰Ì9ús7=ùíì­©?{ŽÇÜvVXŒ„Žfí–tß7�_—ìð–ÅÆªoØý�Ï^rrØd€6ýÇÇmM�I²AŽSâ‰+Í zè@QÍÍÿtMÏ8¦ q¸´Ï¹ÚÛir�±\ôÒ}çÒ×¢hÂe‘•ëäŽç¾çÅ�{· !šwi×`þÂ�n¸9ì ² ;��bb:bbá(±èa]·½»䛩N"ä=F7¹�ŸPpŒ©.í³…š>ô�™/¦gä/p¨ o=r{›ÓÖ`�&}³ŠËžÅîýG¦µk&¾¸fÖµŸ\? }øö,mÂN@ ‚é «ò�cÆèH±ÅL} 7>:ÍQ°H0•ã[@AêÔû Ž.W¤zøxßÉÈ)t_=zÎs™9εª"xáîÞ\rv›P^’߬ݒΥ÷O÷õ($>ÎÑïñ›Ïøþ½Çú…-›Mx H¤”æQR)u»G ij!PHáè ešê°¥Ñ§ÏX‡¢†©K“âPiåñE¹‡ËúÞ®´¬‚˘6:3«`%žƒ/Þw.CûFÆZ7+×Éíãæóò俽¹UE´8«k“¹?xåõi”�¥6´ĻµR;º�l6[§ær‡,8ÈΔƵÑÍ™FëÂ�YZùªÌ“ ®²¾»}_váÐf<~èpÁ20Fãîêͨ+My[�”ðᬕ\þÈ7Þ)�"¶iýêãVM¹eâÀ³ÛÙ»•ˆ@ÿàÛøy$2cõ··—ÚIPäÓ­W�ÛwÜ¡ºMíÿYúb€Ôôœò¾¿ç`ŽsØý³OÏÌ_ ÜuEwFßr†eY†ÊcÍæ }`¿/ßå-KŒ�½h܃çÍ~é¾ó¿wic ~ €á€¹ ªdÛñÞÒBÊÈÚ(BbÚ°¨¬¹>R-WÒå�۔ѻ÷eë)»î’“xó‘ ˆ‹�ŒÉþáœBF>3�qþåÝ%PUÑâ’ÞfþøöÕ7D¤Q6!Åoh}é‡�1Ÿ|bëqß8%}�@8MÕ*eèrpéÒô Ì¹¾P²ý­'¯À©_pçW/oÙ•ù…§ì‚^-˜øDR,.z<¤„Oç®áºÇç°ïPžqI‚Ø�k=þï—×¾~F§&‰AžÂ&‚ø-ªËmzþ/Ž»Ò_l�jªbi>rϱu)æ@HתÌ;ÞÛz)Æ@e¡iº0êë +6ì{ ÐzvnÌôW.¥U“š!»Ü@Y¾~ƒï�VbJ�’;àÝ'.˜õÌ]gÙ1*(þ¯sóôãOŠÙaª^!j58±ZHn„07(‡Ëzß­k €‡áÏš¶iç¡»�B€f «3åÅÁœÞÅò ÈÇ%3Û˜¼öÙ?Þ]‚صٰó;L™ýÚÐak˜�iüó;ŠCßZæ¤ùu€ánìMh#\ô¢+¢u¿·b‹�r�¡À ž·Ï8¹ S_B³F‘3××¥d´¹á‰¹¤‡S„ˆïÑ©Á˜ß>ºfLëÆµ¬�àl~ €æ>Ü“ñã”kë¿}Ö½‡áøÆ4e¡ƒio…b:uO¼cùí–&€Z¾�xøp%PТq ¦½|)½º„nGÔ KÖ¦1ðži%¦ ê$˜ñÖÐOF]Þ-¼IdmüÆ¿)€®™ö¼Ó¥^î ˜fêÁñôV"k™ùš"oíÔ1Eå~Ð%ÌÇÜK**mŒ?è FâêIq|8æb®SÄáã‘‘UÀˆ§çñö”eèűÎâcÕæw^yê‡ïADgS*~ €”˜VpE°Ó¿Oêæ…Hª7þzP7$ÒÜ@—~Ííõ8‡yдãMò=€Õ`¸�½ãlÆÝÕ;b–ƒš.ÿÕ2n󇲌µOU ½»7óÓ{W>Ø VRLÄgs ~.š÷½—šðOÌg "Y‹5½ˆ×¦ÿø8¤0gÍ&„_°±kjžLÖº^Ö*ßNà |/;¿=S^BÓ‘õÙY´j7—ŒšÊ¢UG‚@5mX}ðwï\>á’³ÛD<¦��_ c6 ·ÐcªÕÙí×GuóàÖ̯¨1Â|ê^áöKä˜1ºÐ ƒž€O!”Ør>âY|†bËÁŽ-ë0ãÕ¡œÓíÓ— epËØyLœ±Âý8)!¶í‹÷žûîC×�vbDgø)ÌŽö­�:¬ü92 „âŸP”vR1ýDQ4‡©ù?nÿFº¢çùûÙHéÏ>Ÿ< \d€±.ðþýyðºÓP•ÈxhšÎ«ŸþÃmÏÎ''ßøSˆq(µoº´Ë_šœŒ‘þ !àÖ¡§ðñÓ¨]ÝúĤeñÛ²�\öÀ ¶î6¼Â…@íÖ±á=Àû©Ìm"@¹ÐbÈ5S^(Â0`ñ‹ÿ:ïÝÒåïç}‘Hó#E3?¿·÷tU37fbí“ œ LôœvRcf¼6”“ÛEvê½#-‹aÎäç¿·û� #Ú¸*J¹àÐãÍ/"ý9fŒ.¤Øçïç}AŒÐÍí¤¶qÎM~¯î»…4ü VÍô éFº\ÚMRÊ€†u’ølÜ@®»$²áýò \Üõ¼òé?Þ­Bàt``:ë´�9ÊÌ êŽ@>¯ ir@Ö/6™ &§JŽo& òˆqŘ]˜²÷í8èÝ/×nÙw‘^ìl£2ú–3xù¾sIˆ3Ü9H ÌXÁ�ã¾÷® �€ß1Ö1l„kæ·uáÿÀ8•Ù�Û|ðÇÛà !„D17½Qð{þ�ŸgZcjŽ\˜äV>üõê)óWöÖ4ù£§|`ï¶|õâàˆoþºt'W=ú iéÞ�T0 ¸3¢ «B”?PÌgâ�u¹ý^@“ifϧ< hÝï­d�¦:—[ÓrðI�z_��R ä;^$¦Fµâë(cÞ^˜yécs/Ë/r�£x«°C‹:Ì|u(}N5éâ"6íÌ`ØC3Y³ù€§HÞÞÀN_n9å�¤4;p®ë‘~  o(æ·uß TAØ(þoú´Ñ”[°ª)‘rf¹¼¿ï޽Yz»kÆ�ËÊrö£Ø„8%)Ž £û1úÖ3"êZ|03Ÿ«›Íw–p¹ø°£[ˆ?S€ææª©G§+U5o €°¸UÕô€tûëÏ{yÂäN€,25HP”cž Ý¯œø{zºó ŒE7„€ëœÄ¤§.ŽèV¡³HãÁWáÃY+}‹/~LÇk°)›2@„“ædþÛxÐrtÓS)Ô€Í�…4ï,üp>]QM €"º�œ° ½+F+õ÷í×ï“�Ë÷î=Ÿ­Âž�3ëõËèÚ!hçJÓèRòòä¿yâ�ß½Qˆ�îÀŸ˜vG·)‹2 ÅÀë™ •%¤ÿ[€¶þ4"K¦­#eà#¡ Ó#-3>à€Ûe."€« ! i@yÆCÝ'N,F׃ѮúµùlÜ@®Ø™¥#`ê�ÿ1ò™ùäzÍBÚ "5�Mé”)1ŠÛ´êêŠ0és;Š<¯Ÿ&½Îí nض?F1¿(µÜ€ [ÍL'õŸÈÌë¥K#w£CUxìæÓyçE4ÚЩ\÷ø\2²¼Ë&M0Dàôˆ5ªRö@—¦@˜ õ æ¦RÔîÓgl ›Û挀dàç’mz�›ØVà¡MþûŸ;ò• _þ¸á\·[Îñ”�wZs¦¾<„–Mj˜mrЬÙ|€kGÉG€a‘ú#`Çe €ÍÍV,¤É€0@‚r0¥±ßZ Ÿ¦"1KK ›ŠñW;µZ¶�íÔN. hYÿ…�äô¼êƒë²ò‹Ü`Dšúò¥Ý*Ü’šÉ•�ÌbûžÃž¢D`.`Ç e�PLÿòE¨¦@èŠi§ .¿×Z“[K sÉ@5E55X¶l„KJLù; �ç GÀ{èY¹NÙõ†ÉooÚ™9H—2 ¹Z,ˆÛ‡u�غ@Zz.Wýo6k·¤{Šb�iØ"4ew�¤äF`,‚É Û ¼?báÜÞuO:f"0ˆ `@)t™¶z`V€%Î+̹j àT²MÑ\vç”ÅsÙxžÛ-WmÜ{MÞ|øªÅG& OFV×=>‡ÿóº‹Ø"Êœ3oœ;r°êèr1|šÚZËj¨ºÝÍt)› Eœ ¥h² ‚&BJgÀ6žºöHSß!ý÷ B¯m.D(sk:2Æ-ŠBŒý ’‚è«�¾ûKÚÌßÒ.ygô™¯$%Ä^páé-iѸw<÷=©ûLÍ‚‚"¯ÀÅMO}ËÄ'úÓ£S#8"—ãÉÆLy„È©Ã4`wñ¿¿BÙ 5¦všî#ZÿÆ£„¨¼9±΀= =Q~tY©9L�b�E™Á^Ÿj2G€Ôqâ°§Ê\á2ah–[Ç~·púO®Õ4=Œ|�ÀY]›†« ^6î8Ä=/þˆvÄ•xðxØR�ˆJXûý}€É¹vù#Eî:ìÁ)µ @jæ\ž ê–9 èÝû·°‡Ñzâí?7¼úÙÒ«�EÚ€„xï?Þ�¡}Û‡»)üµr7ÏOZä[ôpYØRAˆJH“Þ„¢\0›G@ %oó¼ò qÊÃ¥™Þ ÐÜeg ÚÑ|GDÂý~ðÍÊ=ÿ{ç·ëò ]ëÀØ&wWonÜ%ìmùìÛµÌøeƒçP“°c ”JÔ €D1; H(NfR*B �Š©SBþé�\è2=åDNÌË�X¼ïïÿÜœyóè¹·dç-6Ú �ÜØ‹{®:5ìmyjŸ¬Ü¸ßs˜ ÌÄð$´ñ!j@ˆÀ# yˆqÅwàÄaÕœ•¼T”ŒP\ÛêÌSfºz~™#€WQäþ+·ìϿயFí?”?ÛSvÇåÝxø†^amG‘KãÎçð�%Ðx5’÷&‰Z�Z€9|/JQŽ+En§ù4bº�©cÎ"°¼ùZµˆ @FVžÖû–/ŸÙ“ž;ÃSvó�.Œ¾åŒ°Z ÌÌç¡×~ñÍ@4éûMD­hŠpPQ/eì(A¤×UGHÌï(nbÄØ±ÇýÝb…±ôÝš./¼í«w¦e}å)»î’“xúŽsP¨KÖ¦1é¯C«g=À6*&j@¸´T07LÖËØ $fÀÑÄŨ!YÐ5“‚�æ¿5+u ÆŽU4‡5¿©Û­Ë F~õƶݙŸ{ʆ_Ð�gî<'¬#�7¿Xʆí‡<‡u12ÛŰyÞ(§,N`aⲎ+ÂD¤Ð×�¸ûp¨®O�QL/:rJ€×uŒøð¿4.¼cÊ;vúØs|Ùùíyì–3Âvþ"—ƃ¯ÿ‚³Èë†=éû D­in@ ë·é?¾ôÅ2Ic3uJôÃfƒœ”F‚ÓezI¥^›šU™p=~0äÞ¯?üoëÁ<Ç× 8‰{¯é¶óoÞ™Á[_-õ-z{W º �ôâG!ÅqLGo?hR2HS©¦„!þèÕê"¼ ¾´òìj‡¢V†Ü?ý“�;}á9¾}XWF Ÿ÷îdzW³i§w§)¶•`t Bl7}aB“ÑHW ›˜­OÓus1�ÃکÊ�²ÈÔ—ãKOêpªQ-ï™úþŽ´¬Yžãû¯=�+û�–skšÎ3âã‹y?Ð!Ò÷$’Dµ(([Ì~WcŒ.ôSã1JH@¢šŠ,¤h¥ï¨1"ê`À¨¯ÇïÞŸý#ÆBOŽ<“óNk–s/Y›Æ·nöƯDú~D’¨€D�­ LùÎ+Èc½Q¤4-Š.L.H–…4Z¬Éº”£€>}Æ:¤îˆ`B/ÿqº4ýÒûg¼œž™÷;Q‡_½¿/'¶ OÐ'-öM;Ö83Ò÷$RDµ,›3"_"÷™ù®äØ)ÂÜ mS·½!³8ÒHÝ|lAWͰ¥nJ…xú{ÈÎuiW>8{,°ŠˆžèG£ºI–Ÿ;=3ŸÉsVû=éû)¢rÛÈ!Ù‚ ð­;AR‹!oÔØ>ëÞÃ`ì‘·• Êu*y0”;t-9‡¹cIº»„Äç:bˆ  ÿIÍÈQ®|döë“Æ^Üò�¯_¿%LšµŠ«ûw¢º±¡ræ³ÿšòÙ¼Õ;„T‰T�(R (F(v�"¤P$(H©H�"„ñP„D‘B(!�(BHEJŠß“ŠŠR‘ ãÁߨhDÙ-”[Í~5^VóNN\W·˜óФY{„²ÙrÚ–\³áÁ8*Qˆ#^©P#Ë7ìËzþã…cÜn= Í µxîî>– åäñÁÌ•Þ㛇t9;Ò÷"D½©šÝgÀíTMï DÈÁ¦£šò �R/1(tTŒÀÒ˜öÆÔOæ®yVJ#'á½Zp퀓,?ïçß®á`¦qûÖMj8´oûðG1‰0Q/ŠÛµ-ˆ¯7÷¼Ðó™ŽÝš€ÃäB ¦êJ÷‘cÀ˜Þ¨EÁäŠÿn­÷øšþ'…ßo9ÂD½ü×ã`*`.ˆ&²µ÷¥.[šmCL5i™(wŸ# ùŽfå ŒVnyzÞì=û³8T…×êKÍ”ø`«-“/篣 ÐHóСE�Ö]Û׫éûN¢^ŒÅ7±Ãä×ë´>±º;VÂ\¦cùk§ÞD¯²ÑõxÓ;š+/@É+ (sp´¢iRŽ|nþ[ù®ý ë$ñÄkwè²r�Ìþ}`‹9¬k×H߇põP̳_Œ+¤Õ‰Ë6i²“˜ÏQ๦ó ÈX5@¸õJ!·ÊÿÕò—t)ÝŸÕš¾=[XzÎOç¬ñZžÖ¹qÇj ±l?Å<B¬7û]]h­ÜÂÝÊüÉ¥éþ°yÞ('º45ÅÂ+ÄX%A¯<ðñ7«¶þ»aÿtÏñS·�EJ’u—¸uw&ËÖï >ÖU¿Ž¦§‹� !.)L €¢ó?¨Û|’QsÆRw‹v›&º£$H(ñ̼é‡sœÛêÖ¬ÆÝWZ»>7ç·MÞ×ýÏlUeü*„l›;2Õ¬Ù¬@i.PZ›ù.@QÝËs`é˜Î7(õ"SÞ�ÑN^�[{úŠ÷¥4çWöëHË&5,;ß÷móÆ hߢNËÆõ’¬]}Œ*„H)%Rügî»z¼Ù0àÀÁíß`r"âÍ ÒìÚFôóÑ7«¶lÛ“õ;»�Üh]`Ñì<'¿-3¢Ð©ŠP¯¼èDÓ�ŠD…¡`J‚:§4Ÿ¢,¶�¸7GHrSãÊÀ³ï/üRÓt'@ïîÍ8©�éˆnå2ÿ¯#6g=NjÜ<Ò×*ŒèA,šE(ÖÏÿ¡x«S–m5Vd¯Ù“¹jSú|ÏñÈˬ ²hån4ÝØh×¢Ö qµÂô³T˜ Œ‰Ñ×#̹›E-²vÀáÖ‚J;0cÆ„õ^ÃËŸ,š§éFð”óNkN«&ÖØêdå:Y½É°ùŠ�uÄ�zóJ=¸Â€aŒ#MG !EÁºywì¾&ÿp%Ä×¹Œ ¬¡X±~ÖÛþFì€Ë/´n‘~áŠ#éúœÚÜ´ùxE¡ÂR. ¾?QØ*%a{J6Iß•)„¶óyV×âk6ŒúÑÀ§ß®úÉóz`ï¶ÄZ”}ñê#Ë>í›Õ®t#Qìpå¡B €TKÂu.MšwC6ÂcÜ(",Ó€ää¶%:üòåá¼RsÌ^°e÷ÁÛj¦ÄÓçTkÎë·ô®4©Ÿ\O(ª‹”�(ù7V¡®®ñá6k…É”Z�!¤J¬éx„fqÉ�F>u릗|â'ï�úÀßköüîyÝ×¢‚…n¶¥?CB|Lü‰-ëT*;‹£#mW(X° ·[‡UÁ×T6BÈýfßdÚIÇ,±Ž¼Ð‡+…iÓÖ—ìð½Íe` 7“¿]µÜ3u9§{3T‹é×n=þ¡WçF¦sIF#R*k|�+”¨º°|@×åæàk œ ³ÎÑu\Á×T6R– o&ÇŒÑÕøü¨¬Þx ;#»p@õ¤8:[d°~ÛAïëÖ'Ôªéë!R®ß} * h– €JlD@X: ¨~ ®Ô§}þÁ8w uE‚-»y#xtí`ÍÃy÷¾#Óäz5«%GúšC†dɆÙw•0m¯p°ö»;öI)7_Óq�"wc·=æ3‰#.Ö’ðc–·¬Yªl9-Ãò‘G(X»%Ý»8Û¥­5�vðˆMV�Ê$‚�.ªp`\ˆüÕººYeE`Y7íæL©X8 ˜6¼Ôk“cÆè…Iî¨�ù ·yg­Ê#�vàˆÔª_I@lj”}ÄÅÚC…€8·ò›Â’!«�®•‘¼6)‘¸äÁàk:–êdk¾ûÿG³ûÄl§›Õkk·ÈqV“ ë&a…µnvž“¼C «'Å[Ÿ¨ÀbR Äà Œ9¦ÏTHXýíí™HýÏP×+aÇÑs¤H�_­p¯õ®;‘2ESŽ£Çm±Þû1Hrò‹¨Š ¡E‰D²óœ8TÅ[Á}¤`†Ù#”ö^…½0�ØYÁ×R‡ƒ¿"}]©Sï+PP‡²ÎÄÂxÝŸ©ÍÚ©cŠ’ªFµdç:½#¤†u¬O P€”䨍O s\$?4ÊÚ?æxoWXØ2ûæ-ùo¨êÈýÿuÙv�Ãã¶G8Bꈗ¾Þïùý¢©÷$ºc "}އÓéöƒ%&X ½ÀyD’ã*ª|WT+î¦Ò†þ*¬èBÿÐBQ—€ïB´øœl%fß”£ézH ƒªiN­¬?‚ÒX6gD>äf+ª;êÖ \n§çu‚E}³ÀyD/SªÅV¬œ —”ŒÛÔuÿM崩аå›;RA~l=R²vÃìÛ7† IçbX*ÎÚ[™pi©*2h�[]½™©!ýæy£œ[»ì?èTÃa~í?n·îí�q�Î}—J• ä áWtåüMsn{ËŸZʲãÐ9¾îd„\gº!Ò â ¾²��)À/@ŒQÀKÁ^Ûæy£œE®ä­ÕôS81¦q¾œ:Ì´ˆÈ1cô­SGdmJ¨½OÆ:²u—ŒøV¡Ã¡x{}‘+$ÀcˆqéEîh7•Þ-`²Tõó7;íš�sFøF¿¢Îm¼L�:Lk6`Â+ñžE˜m¨$7FÓ>N�}ŸÙùn p/ð$pôjÔ%#‚ l6Ï»:»å Ó´€]Sã µÂes†…$Ín±ˆdYbø4µ »c�ÂQä�Š.c…î*qÕ …âHAs „æ*R‹$ ZŒ�î"A"èšCèn‡À]$¤æî„$EBê!u—pÄJ!�HéRs =FRw ‡®ŠVïïiWa‘5Œ¾îÆMê¤Ìذõ@¦ÄŠ&$Š�Rª"B u]€"Ð…0z”.t©)5�êÀ!u�DH)…�Š@‰.�B(*])u�T„D`êž2E(R7ÎèÆ6øA÷– sÌï\ i29m´Ñtø´„j…‡nCÐÐhR #Ñ…”ºT„†º@×¥ºb“+NLÞ:u„Ù€œçoc<ñãi´nkºoN»•@7ÞÙ¦Í5_¤Äåæ6qên!t¡«±1šKséª&t%Fè.©kª¦èj¬¢I]s: rR§š¶hg*0 àšÇf³t]èwM|ïJš5¬î9Ló)Ü¢™ ?ð�:uXÁÆ9·½® &IŽoO/‘yHñÝÆ®ûÆ›ìü �O�Ÿñéü¯ÞÃ�û¦sÓ˜ïHÏôN™O®Åõmþüêìøúb³VN.A)]EM3÷gTâÎÆ” €Ö,Oøä$tRI;?T¢€/Ý»OŒÉkB{#=¸Þ�8áéšk·œ²oƒÉÕþR‡ûirx~Ò"~\|$ZÙeç·gÜ]½½ÁŠ�ûè4|l¬3¶u¼#ÿpŒSs;bd‚&ÄaçÆ¸AÍ÷+»€¦RÂ)W|XbÏ>¨Š`íÌ(Æh{/Ð(Òl•R, Ôáþ¤oVñÞ´K샷næëCéÐ¢Ž§è-àžH_D%¡:ÆO¤¥çÒç–ÏC~‚úµùcÒµžÃ•€u¡ˆ#L¥™XÄÑ«û€8rÀÝSyýó%Çt~]Jƾ·ýˆ¸Þ T©¬³r 6ï²&~Jãz%ü"æl(�à!Œ¬Ä—{ Óä0ê…¸ù©ïØ™VöòÁŠ û˜ñ³w7FÞþߡàtÏ‹u[­ñœ>JvDú‚­Äþƒ<–s1†}/Q<×/ri¼7í_úßõu‰¹~y¼<ùo2²¼kq§·Gúâ*gz^,_¿Ï’•ƒ0ì±!É-Gðîwô–7Ü/‹¬\'/MþÛ·è"S±M<Ð@ÓtVn´&mC»fµ}Í™Ul8Þp?=×ïá~Y|³`#‹VycÍ'“°ï»YΧxT¶dí^róCbãt íš—€5fë©Tõ?Ä6o¸甀†ûÇCJ=þ7ß?Ös€Q‘¾ð ÊpÏ‹Ÿÿ±&IT� 4©ï]H, Î-Tu؃aåÀÛr‰Éá~Y¤¥çòÜG‹|‹ž#ÎBUŒÀP0†ÿ?,ÚfÉIºv(aqñ!¬¤ª @>ð?ÏAÝš‰o|±”ÿ󱤳�Ê”‚*”ŒZìH;̜߬KÚÔÿ¬Ö¾‡s"}ááÀ€#¤¯xžºí,âb­ÉA¯i:÷¼ô2òÎ"�;žû�ý‡ò‚«µîÞÍ÷ð-Œu *ƒ-ÇG·9`<)n*9T 9Î"�‘ÏÌgáŠTO‘Š1¾Ï|­‚8ŒX ƒÁ©öÈ›¿²bƒ5?<œÞ¥‰¯½G6FÜÇ*…-e³c=€{¯îAÇ–uÌ׿…Enn÷= –zCÑ à5Œu�Êh'PcáõR(ž½óónµô¤BÀƒ×Ÿæ[ô2e„“¯¬ØP>“€™`¤‹zùþóˆ·8[t‘KcÔ ?0¯d'¸˜‹·²ÐÃã®7Oþ§&üÁ´Ÿ¬söñpéyí9±U]Ïá>àõHߌH` €ŒÀˆïOë¦5yøFë|<¸Ü:÷¿òÓÑ[„ÿÝ#}CBÀåÀRŠüœE÷½ü3S~°>C{õ¤8º¾ÄoøUlîïÁÿ8ÜH±ÇàUý:ÑÿÌV–ŸTJxýó%üï­¸Žä§l‰ñÔ¼—âðØŒZÀgsþd€CYÜðä\¾ÿËÚa¿‡‡oèå›ùçŒQ^•Äÿù‘bÿ}!`ܨ޴iÿ�™¿läÚѳI;�ã)ŠÅ².ÀFWÆS-p�§pÙú½ ¾wº¯s”¥œÕµ)Cûz­þ4Œ©U´gÿµ ;3P`Ä`D > `gZ—=8“ìG“’ÇÜñÃiP;ÑSô2ðp¤oN$± pË)Î÷ëÒ�Ü9î{Ë|ÔKãò ;òÐ =I®ë[œŠaF<°&gvàôFã3]Ù¸ãÿ{k붆/Þ¦ðîcqn�枢õ­¢l0G/ŒpQ±ã¿ZÆÛS–…µõj%òø­gpáéǤØ„áÖüÅñ ÂL†ÏÅ÷ÉKv®“·¾ZÊ—óׇí©ïáŽË»qÏU§z‹€3€ðþhQˆ-æ¹xŒÅºGÞü•Ù 6…½çt;�¯ïIÛc×#21·>Åpp±#Üù‘{K4¦ ÐÍW߯ãýé+8œþîÙÝNàý'úùZrÞŽ‘ª­Êc @p|Ü Æœö(+¾°¡ÁÅg·æî«Nå„)¥}d3†-Ã÷À‚(Ûw=� �¾ûíû’‘UÀ”ÖóéÜ5dfGf¤Ý´A 3_JJRœ§ècªŽyu¹Ø1Æ9丸æ±Ù¬ß™\ªªÐ÷´æ\sq'zt:nJ{7°Þ`[ñ¿T “ç| #üv·-]ß“ZÕ|ßòÄ\é6Vlˆÿn-?-ÞŽ;Ìñô¬DèÙ¹1· 9ùhk>€ýÀ“ÀG^}6!Æ€è$Ñå~ †ïé™ùLýñ?¦|¿ž7™MbB Înõw*-¿BÆpÿEŠ“³ÚXƒ-ÑM ÓÖ»8jD é’¥kÓ˜ÿ×V~Z¼�CY‘nk¹8T…^]3¨w[úölABÜ19³1’ ¾F±G¥�µØP1H®îĈ9PM—,Y“ƯKv°xͶìʈâÉñôìܘ¾§5çœî'�’WÚÇvatü¨‚z#‰-�ž1÷sœÀœY,]·—%kÓX½é›S3(( �mA“úÉtnS�.íêÓ£S#Ú·¨íŽÛ7ðF”ÞÙDO2“*…-—xŒlÁÀK8Ž€‘v{÷þ6î8Äæ]ìÞŸÃÁÌ|öÌåàá2˜>$Ä;H®G£ºIÔ¯�H£ºI´lR“�kÐæ„šÔHŽ/ëë:°Âï+ŒE>›b @å #6ÿy^‡� ÚS‘K£Ðé&ßéÆåÒÈ/táÖtE�”Kµ„âbT’Ì=ˆ5ùG`v§�*l¨œÔÂð98C Nˆío5†sÎ’â‹€5Táì»ÑŽ-U‡ºbÐh\ü¯añÿõ1òÄ•ñýÃxùůÓ0ŠìÆÈ<´Ã#/<©’mB‚-6GS C1:{Añ?›Jˆ-66U;,¸�MÆ›*Œ-66U[llª0¶ØØTal°±©ÂürÏ1Ö]9<IEND®B`‚( @ %%ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿæÓÇnϬ›°Äœ†Ð˜€ÞÚƒÚÈ ŒÇ׺©žíÜ×:ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿâÔÉG̦’ÌžZ4þ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¯wYïÚÁ³¤ÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿâÐÃb³aé—N&ÿ—N&ÿ—N&ÿ ]9ÿ¬rRü£b?ÿ—O'ÿ—N&ÿ—N&ÿšT-ÿÔµ¤Âÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿçÕËI³bé—N&ÿ—N&ÿ§jH÷Õµ¤´ëÝÕ[ÿÿÿ(îàÚKÛÁ³”Á•}Ù—O'ÿ—N&ÿ™Q*ÿؼ­²ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿððÈ ŠÔ—N&ÿ—N&ÿ°y[ìâÐÇhÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿõëàÒ³¢Â—O'ÿ—N&ÿ¦hEøäÑÉ_ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÚÀ²Ÿ™Q*ÿ—N&ÿ«pPòâÎÂhÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôêß¿“zÛ—N&ÿ—N&ÿÏ«™´ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÞÆº¢®tVñ™Q)ÿÚÀ²¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÙ½®›—N&ÿ—N&ÿÀ”|àÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜M%Y—N&eŽU æØÍHÞÈ»»ÿòòÿÿÿ™G)–K-ÿÿÿÿÿÿŽU œU+•J+™M"ÜÀ³Š™Q)ÿ—N&ÿ³|`ô·‰mR˜L&C–M&I˜M'O–N'U•P%0ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N'.˜N&ª—O'˜–O&Dÿÿÿÿÿÿÿÿÿ”Q(—K),“N'€@’I$™M"™O(-™M&<—M%LΫ˜°—N&ÿ—N&ÿ³~bø§iG¢—N&§–N&·—O'Æ—N&Õ—N&ã–O&Dÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜O&j˜N&ª—O'˜˜N'oÿÿÿÿÿÿÿÿÿ–N''—K),“N'ªU+•@+ ™M"™O(-™M&<—M%Lɤ�ЗN&ÿ—N&ÿÀ”{ì™Q(˜—N&§–N&·—O'Æ—N&Õ–N&å–N&�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N%�˜N&ª–N&†–O&…€@@ÿÿÿÿÿÿ–P#3šM*+�N,Ÿ@ •@+ ™M"™O(-™M&<É£�ˆ«nNø—N&ÿ—N&ÿÇ ŠÖ—O'˜—N&§–N&·—O'Æ—N&Õ–N&å—N%�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N&­—M&›—N'b–N&†—Q#ÿÿÿÿ™M&<˜L)%œU+Ÿ@ •@+ ™M"™O(-Ç�„h¼Œqã—N&ÿ—N&ÿ±y\ñÄžŠd–O&=˜L&C—M%`—O'Æ—N&Õ–N&å—N%�ÿÿÿÿÿÿÿÿÿÿÿÿ”Q(—O&¼™N&˜O&J–N&†•Q%)ÿÿÿ•U+ ˜N%>™M+’I$Ÿ@ •@+ ™M"È¡ŒTÖ€à—N&ÿ—N&ÿ›V/ÿÜöšÿÿÿÿÿÿÿÿÿ–N''—O'Æ—N&Õ–N&å—N%�ÿÿÿÿÿÿÿÿÿÿÿÿ•M&5—O&¼˜M'c—N$1–N&†•M&<ÿÿÿ™R)˜N%>›N,™M3 Ÿ@ •@+ Õ¸§h½�vã—N&ÿ—N&ÿ™Q)ÿÕ·¨¾ÿÿÿÿÿÿÿÿÿÿÿÿ–N''—O'Æ—N&Õ–N&å—N%�’I$—N%¸–N&œÿÿÿ—M'V—O&¼˜N'H•J+–N&†˜Q'Oÿÿÿ”Q(&˜N%>�P0€U+Ÿ@ ÝÆº�±yZî—N&ÿ—N&ÿ›T.ÿÓ¶¦Áÿîîÿÿÿÿÿÿÿÿÿÿÿÿ–N''—O'Æ—N&Õ–N&å—N%�ÿÿÿ—M&í—N&Üÿÿÿ—O&x—O&¼—K#,€€–N'ƒ—N$bÿÿÿ–P(3˜N%>ŽU ßß¿ÛÁµ¥¥fCø—N&ÿ—N&ÿ£a=ûÛµ¨ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N''—O'Æ—N&Õ–N&å—N%�ÿÿÿ—N&Ä—N&ò�P ˜N&™—O&¼–Kÿÿÿ˜N'o˜N&r€€•N'A˜N%>ïæÞÕ¸§¼›V/ÿ—N&ÿ—N&ÿ®tUòÒ²££ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N''—O'Æ—N&Õ–N&å—N%�ÿÿÿ—N&š—N&ò˜N'4—N&»˜O&¯ÿÿÿÿÿÿ˜M%Y˜O%t–K-•O(MÇžŠdÄ›…×—O'ÿ—N&ÿ—N&ÿ¾�wáÒ°Ÿw—N';ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N''—O'Æ—N&Õ–N&å—N%�ÿÿÿ—O&q—N&ò—O%g—N&ΗN%�ÿÿÿÿÿÿ˜P&C˜O%t–N'.º‹pr¾‘xÝ—N&ÿ—N&ÿšT-ÿΫ˜Ëʧ‘C™O(-—N';ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N''—O'Æ—N&Õ–N&å—N%�ÿÿÿ—O(G—N&ò—O%±—N&ΗO&qÿÿÿÿÿÿ–N'.˜O%t£eAVÆŸ‰ß—N&ÿ—N&ÿ¥eC÷Û³¢¿Œ€™M"™O(-™M&<–K&=–M&I–N'U—N$b—O%n–O&z–N&†—N&š—O'Æ—N&Õ–N&å—N%�ÿÿÿ“N'—N&ò—O&ß—N&ΗO&Qÿÿÿÿÿÿ—Q#˜O%tͪ—¸šS,ÿ—N&ÿ ]:üÞÇ»’ÿÿÿ•@+ ™M"™O(-™M&<—M%L—N'[˜O&j–M&z—N%‰—O'˜—N&§–N&·—O'Æ—N&Õ–N&å—N%�ÿÿÿÿÿÿ—N&Ä—O&à—N&ΓN'ÿÿÿÿÿÿ—N&eÄš…ß—N&ÿ—N&ÿЯ�½ÿÿÿÿÿÿ¢F ™M"™O(-™M&<—M%L—N'[˜O&j–M&z—N%‰—O'˜—N&§–N&·—O'Æ—N&Õ–N&å—O%{ÿÿÿÿÿÿ—M%S˜O&Ù—N'‚ÿÿÿÿÿÿÿÿÿÿÿÿºŠm?¶ƒgô—N&ÿ�X3ÿçÖÌiÿÿÿÿÿÿªU™R™O(-™M&<—M%L—N'[˜O&j–M&z—N%‰—O'˜—N&§–N&·—O'ƘN'Ò–N&­™M&ÿÿÿÿÿÿÿÿÿ’I$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ'­tTù—N&ÿªnNýúúô/ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ íßÛ8ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿºŠoé—N&ÿ�X3ÿèÖÎcÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÙ¿¯³·†kêàɼzÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÈ¢�Í—N&ÿ—N&ÿÕµ¥ºÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßǺŸŸ\7ý—N&ÿ¥fDùçÔˈÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÝŸ�™Q*ÿ—N&ÿŸ\7ýÚÁ²¬øòò&ÿÿÿÿÿÿëßÛ?Õ¸¨¸žZ5þ—N&ÿ—N&ÿË¥’Íòòäÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿíį́”Ì—N&ÿ—N&ÿ˜O(ÿ±{]ò˜�Ú™�Û¬pPõ—N&ÿ—N&ÿ—N&ÿÀ”|ÜèÝÒ-ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿë×Ò3È¢ŽÑ™Q)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™Q)ÿÉ¢ŒÓéÛÒ9ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîîÚÀ°žÂ–}Ü¢a=ÿ—O'ÿ˜P)ÿ¤d@ÿÙ‚ØÚÁ³—ÿððÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ìÝ×RâÎÃ|ãÒÈxñæßHÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿðÿÿàÿÿÀÿÿ€ÿÿàÿðþø?ü €øpøpø0ø ð àð à à� à�àƒàƒàƒƒÃǃïƒùÿÿƒðÿÿÁàÿÀÀÿÀÿÿàÿÿðÿÿüÿ(0` $88ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿúú7íåÜtìàÙ’ìßÖ�íáÛöñëXÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ÷òî<èÙѵؽ®Ü¼Žtô§jHÿŸ\7ÿœW1ÿ¥fCÿ¯wXþͪ—åâÍÂÊîäÞzÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿéÚҹÙ‚í™R+ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ­tUúâÍÃÓòèßOÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ÷ïëAßȽ×�X2ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÇ ŠêìߨˆÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿðéæFÓµ¤ã—O'ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜O(ÿ«pPÿ·…kù²}aý¦hEÿ˜O(ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿµ�dôíáÚ‘ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿõõï1×»¬á—O'ÿ—N&ÿ—N&ÿ—N&ÿ›V/ÿέšåèØÐ¹óéäiÿÿÿ<øõõLîâÜ{çÖͼҲ¡á ]8ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¿’yîïãÜmÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ äÒÇÓ™Q*ÿ—N&ÿ—N&ÿ—N&ÿ©nNúèØÏÆÿøø#ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿêÛÔ¸¶�eó—N&ÿ—N&ÿ—N&ÿ—N&ÿؼ¬ÞÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿìÝÕ«£c@þ—N&ÿ—N&ÿ—N&ÿ®tUøìÞ׬ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿíâÛŽ²|_õ—N&ÿ—N&ÿ—N&ÿ ]8ÿêÝÔ«ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóëç@Åœ‡ë—N&ÿ—N&ÿ—N&ÿ¥eCýíÝÖ¶ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿêÜÕ·Ÿ[6ÿ—N&ÿ—N&ÿ—N&ÿ׺ªÝÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿëÝÖÔ˜P)ÿ—N&ÿ—N&ÿ™Q)ÿåÓÉÑÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ"˧“ç—N&ÿ—N&ÿ—N&ÿ°z\úñéä]ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿðéàSãÐÅÓ©mLü—N&ÿ˧“çõñì5ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿãÑÅ—N&ÿ—N&ÿ—N&ÿ™Q*ÿëÜÖ¦ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ›Q%)–O&k–O&DÿÿÿÿîãÞ‹×¼­àëÝ×¥ÿÿÿÿÿÿªU�N!”Q(€€ÿÿÿÿÿÿÿÿÿ€€€U+’I$Ÿ` ™M ¢F. èØÏ±˜O(ÿ—N&ÿ—N&ÿ—N&ÿåÓȺ’I$—Q#›N!™R)“N'—L&—L&¿@@ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ”Q(—N'¤–N&¡—O&•—N';ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–K&"•Q%)•O#’I$ÿÿÿÿÿÿ�N' œR)”M$+•M&5–M$?–M&I—M%SÛĶÁœV0ÿ—N&ÿ—N&ÿ—N&ÿÖ»¬â—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&ИL$Mÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜O'T˜N&­–N&¡—O&•˜O%tÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™33“N'4•Q%)•O#–K-ÿÿ™f3—Q#—P( ”M$+•M&5–M$?–M&I—M%SÞǺЗN&ÿ—N&ÿ—N&ÿ—N&ÿؽ®ß—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â—N&æ—L&ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O'‘˜N&­–N&¡—O&•—N%‰�N!ÿÿÿÿÿÿÿÿÿÿÿÿ™R)•M&5•Q%)•O#–K-ªUUŸ` —Q#—P( ”M$+•M&5–M$?–M&IžX5Wؽ®å—N&ÿ—N&ÿ—N&ÿ¢a=ÿÒ²¢Í—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â—N&ì—N&Xÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™M —O'¸˜N&­˜M'ž—O&•—N%‰™O%7ÿÿÿÿÿÿÿÿÿÿÿÿ›Q%)•M&5•Q%)•O#–K-¿€@ŽU —Q#—P( ”M$+•M&5–M$?–M&IÕº«£¯vWü—N&ÿ—N&ÿ—N&ÿ½�xù¶†j¨—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L&/˜O'¹˜N&­–M'p—O&•—N%‰–N'NÿÿÿÿÿÿÿÿÿÿÿÿœP&6•M&5–N''—Q#–K-™f3ŽU —Q#—P( ”M$+•M&5–M$?¹„k_Ö¹©è—N&ÿ—N&ÿ—N&ÿ—N&ÿÚÀ±è—M'‘—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•L&W˜O'¹˜N&­˜L$9˜P&�—N%‰—M(gÿÿÿÿÿÿÿÿÿªU›P(@•M&5”R)�P –K-™f3ŽU —Q#—P( ”M$+•M&5¥eDGåÒÇÕ›V/ÿ—N&ÿ—N&ÿ—N&ÿ¯wYúÚ¾±�˜L(M—M%S—N&X—P&]˜O&~—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M%{˜O'¹˜N&­•J+—O&q—N%‰—M'{ªUÿÿÿÿÿÿ–K™N'A•M&5�N!™M –K-™f3ŽU —Q#—P( ”M$+§iK=ãÐÆÒ£b?þ—N&ÿ—N&ÿ—N&ÿ˜P)ÿåÓÉÐÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N%—˜O'¹˜N&ª€€–O%Z—N%‰—N'}–K-ÿÿÿÿÿÿ›R$™N'A•M&5ŸP €U+–K-™f3ŽU —Q#—P( °y]7äÓÉѤd@þ—N&ÿ—N&ÿ—N&ÿ—N&ÿЮ›äñìè7ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜M%²˜O'¹™N&“ÿÿÿ•M$F—N%‰—N'}›M'!ÿÿÿÿÿÿ–N''™N'A•M&5‹F. €–K-™f3ŽU —Q#É¡‹9äÑÆ×¡_;ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ»ŒrðîáÛwÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿªU™M"•M&<Ÿ` ÿÿÿ™M ˜N&ĘO'¹™O'{ÿÿÿ•P%0—N%‰—N'}”M$2ÿÿÿÿÿÿ—N$1™N'A•M&5™f3ÿÿÿ�P0™f3ŽU áʽMßȺޜV0ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ·…iòíàÙ�ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿ—M'‘—N&ÿ—N'õ˜O$*ÿÿÿ–N''—N&ŘO'¹˜M'cÿÿÿ—L&—N%‰—N'}™N'Aÿÿÿÿÿÿ™Q&<™N'A“N'4ÿÿÿÿÿÿ�N' ™f3êÞÖ|ά›ç˜O(ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ½Žtïìߨ‘ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿ—O&q—N&ÿ—N'õ–M&Iÿÿÿ˜P&C—N&ŘO'¹™N%Kÿÿÿ€U+˜O&ˆ—N'}•N%Rÿÿÿÿÿÿ—O$G™N'A“J(-ÿÿÿÿÿÿ¸Ž€êÝÕ¬¸†kó—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÈ£�èîáÛwÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿ–N'N—N&ÿ—N'õ—N'iÿÿÿ–O%a—N&ŘO'¹–P(3ÿÿÿÿÿÿ—O%{—N'}˜O'aÿÿÿ™f3˜O$M™N'A˜L)%ÿÿÿÿÿÿèØÏ˧iHü—N&ÿ—N&ÿ—N&ÿ—N&ÿ›T.ÿ×»¬æðçáTÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿ”M$+—N&ÿ—N'õ—N'‰ÿÿÿ—N&—N&ŘO'¹�N'ÿÿÿÿÿÿ˜N%h—N'}˜N'o€€œG+˜O$M™N'A‘M"÷ïê>ßʾØ�X2ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¥fCþâÎÂÛº�uYÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿŸ@ —N&ý—N'õ˜N'ªÿÿÿ—N%�—N&ŘN'·€@@ÿÿÿÿÿÿ™N$U—N'}—O&q�N' ”J!˜O$M™N'AåÒÊ~Ϋ™å˜O(ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¸…jóåÓÊ»¦fH<”N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿ—N&ã—N'õ—N'Ìÿÿÿ—N&º—N&ŘO&¡ÿÿÿÿÿÿÿÿÿ—M'B—N'}—O&q›R$šM$+˜O$MßȺ§¾�wî—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜P)ÿЭ›çãÎÈ”M$+•M&5”N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿ˜O'¹—N'õ—N&è”R)—N&Ñ—N&ŘN'ƒÿÿÿÿÿÿÿÿÿ—K#,—N'}—O&q˜L&/™M&<ÙÀ±§¸ˆmõ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ]8ÿàÌÀØàǹI—P( ”M$+•M&5”N%>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N';—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿ—M&‡—N'õ—N&é—N&s—N&Ñ—N&ŘO&^ÿÿÿÿÿÿÿÿÿ–K—N'}—O&q˜L&CÈ�ˆxÈ£�ð—N&ÿ—N&ÿ—N&ÿ—N&ÿ¯vWøêÛÓ¾âκ—Q#—P( ”M$+•M&5–M$?—L&6–K&=”N%E—M%L•O$T—N$[šN'b˜M&j–L&r˜N&y—P&€—O'˜—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿ˜O'T—N'õ—N&é—N&Ç—N&Ñ—N&Å—N';ÿÿÿÿÿÿÿÿÿÿÿÿ—N(s—O&qšU/]ßɽà˜P)ÿ—N&ÿ—N&ÿ—N&ÿ·…iòìâÛ•ÿÿÿŽU —Q#—P( ”M$+•M&5–M$?–M&I—M%S•O&^–N%h–N&r˜N%|˜N&†—M'‘—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿ•O#—N'õ—N&é—N&Ý—N&јN&Ä™M&ÿÿÿÿÿÿÿÿÿÿÿÿ—N&X—O&qͬ—Ÿ³~aú—N&ÿ—N&ÿ—N&ÿ«qQùìßסÿÿÿÿÿÿŽU —Q#—P( ”M$+•M&5–M$?–M&I—M%S•O&^–N%h–N&r˜N%|˜N&†—M'‘—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â—N&ì–L'aÿÿÿÿÿÿÿÿÿ—N'Ä—N&é—N&Ý—N&Ñ—M&›ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—N$1—O&qÝŸڗO'ÿ—N&ÿ—N&ÿ˜P)ÿæÕËÌÿÿÿÿÿÿÿÿÿŸ` —Q#—P( ”M$+•M&5–M$?–M&I—M%S•O&^–N%h–N&r˜N%|˜N&†—M'‘—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â—N&ì—M%Sÿÿÿÿÿÿÿÿÿ™M'p—N&é—N&Ý—N&Ñ•L&Wÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™M œX4qÕ¶¥ê—N&ÿ—N&ÿ—N&ÿºŠpôøîîIÿÿÿÿÿÿÿÿÿ€@@—Q#—P( ”M$+•M&5–M$?–M&I—M%S•O&^–N%h–N&r˜N%|˜N&†—M'‘—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ø—N&â˜N%ÚŸP ÿÿÿÿÿÿÿÿÿŽU ˜M'²—N&Ý—O&Ÿ™f3ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ̬–PÁ•~ô—N&ÿ—N&ÿ—N&ÿÛÁ´×ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿªU ›R$”M$+•M&5–M$?–M&I—M%S•O&^–N%h–N&r˜N%|˜N&†—M'‘—M&›—M'¥˜O&¯˜O'¹—N&Ä—N&ΗN&Ò˜M&¯šP%0ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ�N!ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ=¸†lù—N&ÿ—N&ÿ—N&ÿáËÀÅÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ9º‰nö—N&ÿ—N&ÿ—N&ÿáÌÁÄÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿïãÝ~éÙÑØòíé9ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÇ¡‹é—N&ÿ—N&ÿ—N&ÿÙÀ°ÙÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿòéàQɤ�è˜P)ÿÏ­›åîâÜ{ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÜö֗N&ÿ—N&ÿ—N&ÿ±xZøïäÝqÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôôï/Ù¾¯ß—N&ÿ—N&ÿ—N&ÿ¶�eóìàÙ³ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿéÙÒ¯›U/ÿ—N&ÿ—N&ÿ—N&ÿÖºªàòíé9ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôééEÛ´ޙQ*ÿ—N&ÿ—N&ÿ—N&ÿ©nNúëÞÖœÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùô-É£Žé—N&ÿ—N&ÿ—N&ÿ˜P)ÿѱ ãìßÚ�ÿÿÿÿÿÿÿÿÿÿÿÿúöö6èÚѳǠŠé—O'ÿ—N&ÿ—N&ÿ—N&ÿŸ\7ÿéÛÑÄÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿíÝ×§¤dAþ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¥fDþͪ—åÜĵÒÞÇºÐØ½®ÜÁ•}ï›T.ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿœW1ÿåÔËÑÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ç×ÎÌŸ[6ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿŸ\7ÿæÒÈÒÿööÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿéÚÑȪnMû—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ°xZ÷êÜÓÃÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿíáÚ�×»«Þ¤eBÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ©lLýÛÁ³ÚïáÛ€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿîâÚ‹ãÎÂÆ×»¬ÝÉ£ŽçÄ›„ë˦’æÚ¿±ÚäÑÅÃñäÞ|ÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøÿÿÿÿàÿÿÿÿ€ÿÿÿ?ÿÿþÿÿüÿÿøðÿÿøüÿÿðþÿÿàþÿÿð?ÿÿÿ€aÀÿa€ÿÀÿÀþÀþÀþ€þ€€þ€€þ!€€„!„?€„!Œ€„!Œÿ€„1ÿ€„0ÿ€„0ÿ€Äpÿ€Àpÿ€ÀpÀxÀxàøàøàü€ùüÿ¿ÿÿüÿÿÿüþÿÿüüÿÿþøÿÿþàÿÿÿÿÿÿÿÿÿ€?ÿÿÿÀÿÿÿðÿÿÿÿþÿÿ(@€ @KKÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿEóéå”ñèãÏïãÜÝìÞØâëÞÖçìÞØãîâÛÜñçáÖòèã´úõõcÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóëæ—ðæàáͪ—ð¯vWÿ›T.ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¤eBÿÁ”}ùè×Îåñéå¼ÿÿÿ/ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôìè†íàÙå²}aý—O'ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¦hFÿßʾêóêæ®ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóêçÑ¿“z÷—O'ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ²|^üñèâßúúõ3ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿúúõ4ðåàã©mKþ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ]8ÿëÝÕçøññJÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùù,îâÛåŸ\7ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžY4ÿ¶‚fÿÁ•}úÇžˆõ½�uþ¬qQÿ™Q*ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ›U/ÿêÜÔèÿúú2ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿðåßâ¡_;ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ±z]ýçÖÍæñèãÉ÷ïí}ÿÿÿSÿÿÿJúúú`òèãœòéãØÞǺé«oOÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ]8ÿðæáâÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóêçÓ¦hEÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžZ4ÿÞȼëôìè¤ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ*ôêæ¶ÛÁ³ëœW1ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ§jHÿóêçÒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôìèž¼�t÷—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¥fCÿðåàãÿû÷?ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøõõIíáÜæ¢a=ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÇ¡‹óöïípÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿøøøDÛ¶ì—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¥fDÿòéäßÿÿÿ"ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ*íáÜæœV0ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™R+ÿðæââÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿòéäÛžZ5ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžZ5ÿïäÝäÿÿÿ!ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüõõJÚÀ²ë—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÇ ŠôüööXÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóëç�Á•|ö—N&ÿ—N&ÿ—N&ÿ—N&ÿ—O'ÿæÓÉéûûö9ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóê俦hFÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ^:ÿòéãÂÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿùôñṇkú—N&ÿ—N&ÿ—N&ÿ—N&ÿÄ›„ôôìè†ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ9Ô¶¥ì—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿîâÛâÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ óëè›æÔÉè¥fDÿ—N&ÿ¢a=ÿòéæÔÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿïãÝà—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿßʼæÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€@ —M'B—M%`›R$ÿÿÿÿÿÿÿÿÿ)óêåÍÏ®›ðâÌÁëÿúú5ÿÿÿÿÿÿÿÿÿ€@@–K-œG+ªUÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿòéäЙQ*ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿЮœíÿÿÿ8ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&�—N&¦—N%�•M'‘–N''ÿÿÿÿÿÿÿÿÿ÷ñï^öîê”ÿÿÿÿÿÿÿÿÿ€€•Q%)“M&(”J)’U$€€ÿÿÿÿÿÿÿÿÿ¢]. “N'•N#$˜O$*—N$1™O*7˜N)>˜N%E™N%KèÙÐÔŸ\7ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÇ ŒøÃ™ƒ¢—O&‡—M&Ž˜N(”—O&›—M&¢˜M&¨˜O&¯–M&¶—M&¶—O'•–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿšO(:–M&¯—N&¦—N%�–N&”—N'vÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ“N'—N$1“M&(”J)—Q#ŽU ÿÿÿÿÿÿ�N' “N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&WëÜÔàšS,ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÌ©–÷»Šo¬–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—M&ä•N'Aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜N&y–M&¯—N&¦—N%�–N&”–M'‹•U+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™O(-—N$1“M&(”J)—Q#‰N' ÿÿÿ€@@œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&WêÝÖë—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÕ¸ªõ°vY¤–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é˜N&Ïÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’I$˜M&¯–M&¯—N&¦—N%�–N&”–M'‹–O&DÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŸ@ •O(:—N$1“M&(”J)—Q#‰N' €ªU+œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P¼�vsÙÀ±ñ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿæÔËó›T.—–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð›M'!ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—P&6—N%¸–M&¯—N&¦—N%�–N&”–M'‹˜O$jÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—L&•O(:—N$1“M&(”J)—Q#‰N' UUU’I$œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&PæÔÉȬrRÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿšS,ÿèØÏê˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•O&^—N%¸–M&¯—N&¦—M&Ž–N&”–M'‹—N&€€@@ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•K%)•O(:—N$1“M&(’I.—Q#‰N' €@@’I$œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'HÀ”}náÍÁð—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¸ˆmýÓ´£Å˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N'ƒ—N%¸–M&¯—N'¤–N$U–N&”–M'‹—N%‚™R)ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’L&6•O(:—N$1•N#$”Q(—Q#‰N' €@@’I$œU+“N'–K&"•Q%)—N$1”L$9—P(@¥dDOíâÛä¤d@ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿäÑÆò£fB–˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N&ª—N%¸–M&¯–N'Š•P%0–N&”–M'‹—N%‚—N$1ÿÿÿÿÿÿÿÿÿÿÿÿ€€•K'A•O(:—N$1’I$�N' —Q#‰N' €@@’I$œU+“N'–K&"•Q%)—N$1”L$9›T,@íßÙǶ�eû—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ­sSþåÔÊÏ—O%g˜M%m–N&r—O&x—N'}˜N'ƒ˜M'ž—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™U"—N&Á—N%¸–M&¯—N'i–K–N&”–M'‹—N%‚–M&Iÿÿÿÿÿÿÿÿÿÿÿÿ™U"”L&C•O(:—N$1™M&’I$—Q#‰N' €@@’I$œU+“N'–K&"•Q%)—N$1”L$9çÖͳȢ�ñ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿêÜÔæÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ˜L&/—N&Á—N%¸–M&¯–N%Kÿÿÿ–M&ˆ–M'‹—N%‚–N'\ÿÿÿÿÿÿÿÿÿÿÿÿ—L&”L&C•O(:—N$1‰N' ÿ—Q#‰N' €@@’I$œU+“N'–K&"•Q%)—N$1èØÏ¯Ì§”ô—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ–~õôíç‹ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—M%L—N&Á—N%¸–M&¯–P(3ÿÿÿ—N&s–M'‹—N%‚–M%mÿÿÿÿÿÿÿÿÿÿÿÿšQ(&”L&C•O(:—N$1’I$ÿÿÿ™M&‰N' €@@’I$œU+“N'–K&"šY/+ëÜԱͪ—ó—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿªmLþôëæÌÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–N%h—N&Á—N%¸–M&¯—L&ÿÿÿ•O&^–M'‹—N%‚–N&y€U+ÿÿÿÿÿÿÿÿÿ—N$1”L&C•O(:—N$1€€ÿÿÿ–K‰N' €@@’I$œU+“N'¨pQ)îäÞÆÄ™ƒö—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ]9ÿðåßâÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—O'„—N&Á—N%¸•N&®€U+ÿÿÿ”L&J–M'‹—N%‚–N&y’I$ÿÿÿÿÿÿÿÿÿ—N';”L&C•O(:—K#,ÿÿÿÿÿÿ’I$‰N' €@@’I$œU+ÆŸŽ-ðæàÖ·„hù—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžZ4ÿìàÙæÿúú0ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿ�N' ˜M'c˜N'ƒ—N'�•O&Wÿÿÿÿÿÿÿÿÿ–N& —N&Á—N%¸–M&›ÿÿÿÿÿÿ˜N'4–M'‹—N%‚–N&y•N+$ÿÿÿÿÿÿÿÿÿ•M$F”L&C•O(:˜L"%ÿÿÿÿÿÿ‹F ‰N' €@@’I$éÚËEïäßä¬qQþ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžY4ÿëÞÖçÿúö8ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿœU+—N&ÿ—N&ÿ—M&÷˜N%�ÿÿÿÿÿÿÿÿÿ–N&½—N&Á—N%¸•M%‚ÿÿÿÿÿÿ—P( –M'‹—N%‚–N&y˜N'4ÿÿÿÿÿÿ™f3—M%L”L&C•O(:™M"ÿÿÿÿÿÿ€@ ‰N' €@@òëærç×ÍéŸ[6ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¢a>ÿíáÛçû÷÷<ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿ—N&ð—N&ÿ—M&÷–O&¯ÿÿÿÿÿÿ�P –N&Ê—N&Á—N%¸•M&jÿÿÿÿÿÿ‹F. –M'‹—N%‚–N&y–O&Dÿÿÿÿÿÿ–K-—M%L”L&C•O(:•J ÿÿÿÿÿÿ¿@@³Œsóêæ­Ò²¡ï˜P)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ©mKþñèâßÿÿÿ'ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿ—N'Í—N&ÿ—M&÷—N&Ðÿÿÿÿÿÿ“O(-–N&Ê—N&Á—N%¸•N%Rÿÿÿÿÿÿÿÿÿ—M'‚—N%‚–N&y—M(Sÿÿÿÿÿÿ•O#—M%L”L&C•O(:–KÿÿÿÿÿÿööíòéåÔºŠoø—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ·…iûóëçÔÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿ˜N&ª—N&ÿ—M&÷—N&ë™f3ÿÿÿ˜O&J–N&Ê—N&Á—N%¸˜L$9ÿÿÿÿÿÿÿÿÿ—M'n—N%‚–N&y•M'cÿÿÿÿÿÿ•K%)—M%L”L&C•O(:™M ÿÿÿÿúú4ïåßߪmLþ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—O'ÿË¥‘óêÞÖÇÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿ–N&†—N&ÿ—M&÷—N&î–K&"ÿÿÿ—N$i–N&Ê—N&Á—N%¸—P( ÿÿÿÿÿÿÿÿÿ—N'[—N%‚–N&y•N'o€@@ÿÿÿ•M&5—M%L”L&C•O(:ªUöïìmçÖÌçžZ4ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿŸ\7ÿäÒÈïà˽’˜N)>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿ˜M'c—N&ÿ—M&÷—N&î—M'Bÿÿÿ–N&†–N&Ê—N&Á—N%¸ŽU ÿÿÿÿÿÿÿÿÿ—O(G—N%‚–N&y–M'p–Kÿÿÿ•N'A—M%L”L&C�b:9ôëè¨Ò³¢ï˜P)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¯wYýïåßåÓµ¢]”L$9˜N)>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿ—P(@—N&ÿ—M&÷—N&î—N'bÿÿÿ˜M&£–N&Ê—N&Á—N%¦ÿÿÿÿÿÿÿÿÿÿÿÿ˜N'4—N%‚–N&y–M'p”J)ÿÿÿ—M(L—M%L¢dEJïåßϽ�t÷—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—O'ÿÈ£Žóðæáɵ†k7—N$1”L$9˜N)>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿ—L&—N&ÿ—M&÷—N&î•M&‡ÿ—M&Ä–N&Ê—N&Á—N&Œÿÿÿÿÿÿÿÿÿÿÿÿ—P( —N%‚–N&y–M'p•J%0€U+–N'U§gCTïãÜÛµ€dû—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿŸ[6ÿäÑÇëëÞ׌™P,#•Q%)—N$1”L$9˜N)>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿ—N&ë—M&÷—N&î–N&³’I$—M%Ó–N&Ê—N&Á–O&kÿÿÿÿÿÿÿÿÿÿÿÿŸ@ ˜O&�–N&y–M'p˜L&C•J+šW1Xìߨֲ{^ü—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ­tVýðçâáæØÍH“N'–K&"•Q%)—N$1”L$9˜N)>ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M&P—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿ—N&»—M&÷—N&î–N&ݘO&J—M%Ó–N&Ê—N&Á—O$Gÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M$p–N&y–M'p—K&X˜O$*àË¿´Á•}ø—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¿‘yöôìçÉàÌœU+“N'–K&"•Q%)—N$1”L$9šQ(?šP%0•M&5šO(:–M$?šO&D–P&I˜M'O—M(S˜M%Y•O&^˜M'c–N%h˜M%m–N&r˜O'w˜N'——N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿ—N%‰—M&÷—N&î–N&å—M'˜—M%Ó–N&Ê—N&Á™P$#ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ—P'V–N&y–M'p—M%gÜ…bãÐÆð—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÒ³¢îôìé¡ÿÿÿ’I$œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&W–N&_™N&f—O%n—N'v—N'}—O&…–N&�˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿ—M'V—M&÷—N&î–N&å–N&Ù—M%Ó–N&Ê—M&¼€@@ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ™Q&<–N&y–M'p—M%gêÝÓÞ¤d@ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˨”ñöîé‹ÿÿÿÿÿÿ’I$œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&W–N&_™N&f—O%n—N'v—N'}—O&…–N&�˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿ•O#–N&ö—N&î–N&å—N&Ü—M%Ó–N&Ê–N&”ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ”J!–N&y–M'pº‹p‚Û´ñ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¼�söóìç­ÿÿÿÿÿÿÿÿÿ’I$œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&W–N&_™N&f—O%n—N'v—N'}—O&…–N&�˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð–N'.ÿÿÿÿÿÿÿÿÿÿÿÿ—N&—N&î–N&å—N&Ü—M%Ó–N&Ê—M'Vÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ–M%m–M'päÐÄÌ«oOÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžZ5ÿòéäÛÿÿÿÿÿÿÿÿÿÿÿÿ’I$œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&W–N&_™N&f—O%n—N'v—N'}—O&…–N&�˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&ð•N#$ÿÿÿÿÿÿÿÿÿÿÿÿ˜N'o—N&î–N&å—N&Ü—M%Ó—O&É—Q#ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•M$F–M'pëÞ×ë—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿÖ»«ìûû÷Bÿÿÿÿÿÿÿÿÿÿÿÿ€@@œU+“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&W–N&_™N&f—O%n—N'v—N'}—O&…–N&�˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&é—N&تUÿÿÿÿÿÿÿÿÿÿÿÿ™R)—N%á–N&å—N&Ü—M%Ó—O&Žÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ�N!­tV}ÞÆºð—N&ÿ—N&ÿ—N&ÿ—N&ÿ™Q)ÿñèâÛÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¤[$“N'–K&"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&W–N&_™N&f—O%n—N'v—N'}—O&…–N&�˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜N&Ù—N'á—N&è—M%Sÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ•L$M–N&Ø—N&ܘN&ªžU$ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÙ»«^ͪ˜ô—N&ÿ—N&ÿ—N&ÿ—N&ÿ¯vWÿóéæ–ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’I$™M"•Q%)—N$1”L$9—P(@˜N'H–M&P˜O&W–N&_™N&f—O%n—N'v—N'}—O&…–N&�˜N&”–N&œ—N%¤—N'«—N&³—N&»—N&—N&Ê—N&Ò˜M&ЖN&­–O&Dÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ’I$”J)€€ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿOÄ›„÷—N&ÿ—N&ÿ—N&ÿ—N&ÿ¼Œrþú÷÷dÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿTÁ–}ú—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜�øÿÿÿQÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿúú2õðì¸ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿEÉ£Žò—N&ÿ—N&ÿ—N&ÿ—N&ÿ¹ˆmÿúöómÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿñèã⸇lýñèãàúúö7ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ$ÛÁ´è—N&ÿ—N&ÿ—N&ÿ—N&ÿ¢`<ÿòçã½ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôíèѪoNþ—N&ÿŸ\7ÿæÕÊëõïìƒÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿíáÛã—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿèØÏåÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôì縷„iù—N&ÿ—N&ÿ—N&ÿ˜O(ÿ̨•ñôìè»ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿñè伤d@ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¬qQþóìçÇÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóìè±Äš„ó—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ™R+ÿïãÜêÿÿÿ(ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüüøKЮœï—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¾‘xõôìè¼ÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóëæÌ¾‘yõ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿؼ­í÷ñì]ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóêåמZ5ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ³~aûñçâáøñïmÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ-óíè›êÜÔæ©mKÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—õôìèžÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿû÷>ÞÇ»ê—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿšS,ÿÀ•|÷äÒÈæïãÜÝòçá×ñæàØíâÛâÙ¾°é°y[ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¹‰nøôìè»ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿóëèšÂ˜�ô—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ¶„hùôìçÈÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôì蹿’zö—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ˜�ôôìè¼ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿôìè¤Õ·§î›U/ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿžY4ÿÝŸëòëæ›ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿöóñXðæá⼎uù™Q)ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿšT-ÿÆ�‡ôòéãÝû÷÷Aÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ õíë}ñçãàÔ´¤ì±z\ÿ�X3ÿ—N&ÿ—N&ÿ—N&ÿ—N&ÿ ^:ÿ¶‚fÿÛ³éòéãÛøðîiÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ8òëèŽñèãÆñæáØïäÝÜïãÝÛñçá×òèã¿õíëÿÿÿ+ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿàÿÿÿÿÿÿ€?ÿÿÿÿþÿÿÿÿüÿÿÿÿøÿÿÿÿðÿÿÿÿàÿÿÿÿÀøÿÿÿÀþÿÿÿ€ÿ?ÿÿÿ?ÿ€?ÿÿÿÿÀ?ÿÿþÿÿÀÿÿÿÿÿÀÿÿðÁÃÿàÿÿàs�Àÿà�€ÿà?�ÿÀ?ÿÀ?ÿÀÿÀÿÀÿ€~ÿ‚þÿ‚þÿ‚þÿ‚þƒ†þƒ† þà þà ?þÁ þÁÿþÁÿþÁÿþÀÿþàÿþà€à€à€à€ð€ðÀð?À€ø?à€üàÿÿÿÿÿÿàÿãÿÿÿÿàÿÁÿÿÿÿàÿ€ÿÿÿÿàÿ?ÿÿÿðþ?ÿÿÿðüÿÿÿððÿÿÿÿøÿÿÿÿüÿÿÿÿüÿÿÿÿþÿÿÿÿÿ€ÿÿÿÿÿÀ?ÿÿÿÿÿðÿÿÿÿÿÿÿÿÿÿÿDataLab-Platform-SigimaX-0182bf4/doc/_static/pypi.svg000066400000000000000000001530021524425144300223550ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/api/000077500000000000000000000000001524425144300177755ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/api/adapters_plotpy.rst000066400000000000000000000000711524425144300237370ustar00rootroot00000000000000.. automodule:: sigimax.adapters_plotpy :no-members: DataLab-Platform-SigimaX-0182bf4/doc/api/app.rst000066400000000000000000000000551524425144300213070ustar00rootroot00000000000000.. automodule:: sigimax.app :no-members: DataLab-Platform-SigimaX-0182bf4/doc/api/config.rst000066400000000000000000000000601524425144300217700ustar00rootroot00000000000000.. automodule:: sigimax.config :no-members: DataLab-Platform-SigimaX-0182bf4/doc/api/env.rst000066400000000000000000000000551524425144300213170ustar00rootroot00000000000000.. automodule:: sigimax.env :no-members: DataLab-Platform-SigimaX-0182bf4/doc/api/h5.rst000066400000000000000000000000541524425144300210420ustar00rootroot00000000000000.. automodule:: sigimax.h5 :no-members: DataLab-Platform-SigimaX-0182bf4/doc/api/index.rst000066400000000000000000000035571524425144300216500ustar00rootroot00000000000000.. _api: API === The public Application Programming Interface (API) of SigimaX provides the building blocks for creating scientific desktop applications. .. list-table:: :header-rows: 1 :align: left * - Module - Purpose * - :mod:`sigimax.app` - Application launcher — ``create()`` and ``run()`` entry points for starting a SigimaX-based application with splash screen support. * - :mod:`sigimax.config` - Configuration system — ``SigimaXOptions`` singleton (``CONF``), typed option fields (``EnumOptionField``, ``TupleOptionField``, ``FontOptionField``), and translation function ``_()``. * - :mod:`sigimax.env` - Runtime environment — ``SGMXExecEnv`` singleton (``execenv``) for controlling unattended mode, verbosity, demo mode, and screenshots. * - :mod:`sigimax.mainwindow` - Generic main window — ``SGMXMainWindow`` with customizable menus, toolbars, console, HDF5 workspace, and status bar. * - :mod:`sigimax.widgets` - Reusable Qt widgets — scientific dialogs (fitting, peak detection, baseline, cursor, delta-X), HDF5 browser, log viewer, wizard, splash screen, status bar, and dockable plot widgets. * - :mod:`sigimax.h5` - HDF5 I/O — import, read, write, and browse HDF5 files with node factory and data extraction utilities. * - :mod:`sigimax.adapters_plotpy` - PlotPy adapters — converters between Sigima objects (``SignalObj``, ``ImageObj``, ROIs) and PlotPy plot/annotation items. * - :mod:`sigimax.utils` - Utilities — Qt helpers (log management, signal blocking, progress bars), configuration directory resolution, and callback workers. .. toctree:: :maxdepth: 2 :caption: Public modules: app config env mainwindow widgets h5 adapters_plotpy utils DataLab-Platform-SigimaX-0182bf4/doc/api/mainwindow.rst000066400000000000000000000000641524425144300227030ustar00rootroot00000000000000.. automodule:: sigimax.mainwindow :no-members: DataLab-Platform-SigimaX-0182bf4/doc/api/utils.rst000066400000000000000000000004671524425144300216760ustar00rootroot00000000000000:mod:`sigimax.utils` --- Utilities =================================== .. automodule:: sigimax.utils :no-members: Submodules ---------- .. automodule:: sigimax.utils.qthelpers :no-members: .. automodule:: sigimax.utils.conf :no-members: .. automodule:: sigimax.utils.svg_icons :no-members: DataLab-Platform-SigimaX-0182bf4/doc/api/widgets.rst000066400000000000000000000022721524425144300222000ustar00rootroot00000000000000:mod:`sigimax.widgets` --- Reusable Qt Widgets ============================================== .. automodule:: sigimax.widgets :no-members: Submodules ---------- .. automodule:: sigimax.widgets.plotdock :no-members: .. automodule:: sigimax.widgets.splashscreen :no-members: .. automodule:: sigimax.widgets.h5browser :no-members: .. automodule:: sigimax.widgets.logviewer :no-members: .. automodule:: sigimax.widgets.status :no-members: .. automodule:: sigimax.widgets.fitdialog :no-members: .. automodule:: sigimax.widgets.signalpeak :no-members: .. automodule:: sigimax.widgets.signalbaseline :no-members: .. automodule:: sigimax.widgets.signalcursor :no-members: .. automodule:: sigimax.widgets.signaldeltax :no-members: .. automodule:: sigimax.widgets.imagebackground :no-members: .. automodule:: sigimax.widgets.wizard :no-members: .. automodule:: sigimax.widgets.warningerror :no-members: .. automodule:: sigimax.widgets.filedialog :no-members: .. automodule:: sigimax.widgets.fileviewer :no-members: .. automodule:: sigimax.widgets.connection :no-members: .. automodule:: sigimax.widgets.objectdialog :no-members: DataLab-Platform-SigimaX-0182bf4/doc/conf.py000066400000000000000000000160731524425144300205320ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. # pylint: skip-file import os import os.path as osp import sys import guidata.config as gcfg from docutils import nodes from docutils.parsers.rst import Directive from docutils.statemachine import StringList from guidata.utils import qt_scraper class OptionsTableDirective(Directive): """Custom directive to include dynamically generated options table.""" has_content = False def run(self): """Generate and include the options table.""" from sigimax.config import CONF # Get the RST content rst_content = CONF.generate_rst_doc() # Create a container node container = nodes.container() # Parse the RST content and add it to the container rst_lines = rst_content.splitlines() string_list = StringList(rst_lines) self.state.nested_parse(string_list, self.content_offset, container) return [container] sys.path.insert(0, os.path.abspath("..")) import sigimax # Turn off validation of guidata config # (documentation build is not the right place for validation) gcfg.set_validation_mode(gcfg.ValidationMode.DISABLED) def exclude_api_from_gettext(app): """Exclude detailed API docs from gettext extraction. This excludes API docs but keeps api/index.rst for translation. """ if app.builder.name == "gettext": # Get all RST files in the api directory api_dir = osp.join(app.srcdir, "api") if osp.exists(api_dir): for filename in os.listdir(api_dir): if filename.endswith(".rst") and filename != "index.rst": # Remove .rst extension and add wildcard pattern = f"api/{filename[:-4]}*" if pattern not in app.config.exclude_patterns: app.config.exclude_patterns.append(pattern) # Also check subdirectories (may be useful in the future) for dirname in os.listdir(api_dir): subdir_path = osp.join(api_dir, dirname) if osp.isdir(subdir_path): # Exclude entire subdirectories except their index files pattern = f"api/{dirname}/*" if pattern not in app.config.exclude_patterns: app.config.exclude_patterns.append(pattern) # Suppress warnings about excluded API documents during gettext builds app.config.suppress_warnings.extend(["toc.excluded", "ref.doc"]) def setup(app): """Setup function for Sphinx.""" app.add_directive("options-table", OptionsTableDirective) app.connect("builder-inited", exclude_api_from_gettext) # -- Project information ----------------------------------------------------- project = "SigimaX" author = "" copyright = "2025, DataLab Platform Developers" release = sigimax.__version__ # -- General configuration --------------------------------------------------- extensions = [ "sphinx.ext.autosummary", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.mathjax", "sphinx.ext.githubpages", "sphinx.ext.viewcode", "myst_nb", # Replaces myst_parser and adds notebook support "sphinx_design", "sphinx_copybutton", "guidata.dataset.autodoc", "sphinx_gallery.gen_gallery", ] # MyST-NB configuration nb_execution_mode = "off" # Don't re-execute notebooks during build nb_execution_timeout = 180 # Timeout for notebook execution (if enabled) templates_path = ["_templates"] exclude_patterns = [ "sg_execution_times.rst", "**/sg_execution_times.rst", # exclude .py and .ipynb files in auto_examples generated by sphinx-gallery # this is to prevent sphinx from complaining about duplicate source files "auto_examples/**/*.ipynb", "auto_examples/**/*.py", ] # Suppress the warning about unpicklable sphinx_gallery_conf # (it contains reset_modules function which cannot be pickled) suppress_warnings = ["config.cache"] # -- Sphinx-Gallery configuration -------------------------------------------- # Using guidata's generic Qt scraper for capturing all Qt widgets # Configure to use the last widget as thumbnail for a complete pipeline view qt_scraper.set_qt_scraper_config( thumbnail_source="last", hide_toolbars=True, capture_inside_layout=True ) sphinx_gallery_conf = qt_scraper.get_sphinx_gallery_conf( filename_pattern="", min_reported_time=60, show_memory=False ) if "READTHEDOCS" in os.environ or "CI" in os.environ: os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") sphinx_gallery_conf["subsection_order"] = [ "./examples/getting_started", "./examples/features", "./examples/use_cases", ] sphinx_gallery_conf["within_subsection_order"] = "ExampleTitleSortKey" # Note: The handler also cleans up the stub server from previous examples # -- Options for HTML output ------------------------------------------------- html_theme = "pydata_sphinx_theme" html_title = project html_logo = "images/SigimaX-Banner.svg" html_favicon = "_static/favicon.ico" html_show_sourcelink = False templates_path = ["_templates"] # if "language=fr" in sys.argv: # ann = "" # noqa: E501 # else: # ann = "" # noqa: E501 html_theme_options = { "show_toc_level": 2, "github_url": "https://github.com/DataLab-Platform/SigimaX/", "logo": { "text": f"v{sigimax.__version__}", }, "icon_links": [ { "name": "PyPI", "url": "https://pypi.org/project/sigimax", "icon": "_static/pypi.svg", "type": "local", "attributes": {"target": "_blank"}, }, { "name": "CODRA", "url": "https://codra.net", "icon": "_static/codra.png", "type": "local", "attributes": {"target": "_blank"}, }, { "name": "DataLab", "url": "https://datalab-platform.com", "icon": "_static/DataLab.svg", "type": "local", "attributes": {"target": "_blank"}, }, ], # "announcement": ann, } html_static_path = ["_static"] # -- Options for LaTeX output ------------------------------------------------ latex_logo = "_static/Sigima-Frontpage.png" # -- Options for sphinx-intl package ----------------------------------------- locale_dirs = ["locale/"] # path is example but recommended. gettext_compact = False gettext_location = False # -- Options for autodoc extension ------------------------------------------- autodoc_default_options = { "members": True, "member-order": "bysource", } # -- Options for intersphinx extension --------------------------------------- intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), "scipy": ("https://docs.scipy.org/doc/scipy/", None), "scikit-image": ("https://scikit-image.org/docs/stable/", None), "guidata": ("https://guidata.readthedocs.io/en/latest/", None), "plotpy": ("https://plotpy.readthedocs.io/en/latest/", None), "sigima": ("https://sigima.readthedocs.io/en/latest/", None), } DataLab-Platform-SigimaX-0182bf4/doc/contributing/000077500000000000000000000000001524425144300217335ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/contributing/index.rst000066400000000000000000000105171524425144300236000ustar00rootroot00000000000000Contributing ============ .. meta:: :description: Contribute to SigimaX project, the open-source GUI framework for scientific applications :keywords: SigimaX, contribute, open-source, scientific, GUI, framework, Qt, Python There are many ways to contribute to SigimaX, depending on how much time you have, your experience with open source projects, and your skills. Share your ideas and experiences -------------------------------- .. only:: html and not latex :octicon:`info;1em;sd-text-info` :bdg-success-line:`No coding required` Besides the classic bug reports and feature requests, you can share your ideas and experiences for improving SigimaX. In particular, we are very interested in your feedback on the documentation and tutorials. Moreover, if you have a use case that you would like to share with the community, please let us know. .. only:: html and not latex .. grid:: 2 :gutter: 1 2 3 4 .. grid-item-card:: :octicon:`bug;1em;sd-text-info` Bugs :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title= Reporting a bug .. grid-item-card:: :octicon:`light-bulb;1em;sd-text-info` Enhancements :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title= Suggesting an enhancement .. grid-item-card:: :octicon:`book;1em;sd-text-info` Documentation :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=documentation&projects=&template=doc_request.md&title= Suggesting a documentation topic .. grid-item-card:: :octicon:`mortar-board;1em;sd-text-info` Tutorial :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=documentation&projects=&template=tutorial_request.md&title= Suggesting a tutorial topic .. only:: latex and not html Without coding, you can contribute to SigimaX project by: - `Reporting a bug `_ - `Suggesting an enhancement `_ - `Suggesting a documentation topic `_ - `Suggesting a tutorial topic `_ Share your scientific/technical knowledge ----------------------------------------- .. only:: html and not latex :octicon:`info;1em;sd-text-info` :bdg-success-line:`No coding required` Your technical or scientific knowledge is also very valuable to us. You may contribute documentation or tutorials directly. Or, if you want to write a tutorial, we will be happy to help you get started. Without coding, you can contribute to SigimaX project by: - Writing documentation - Writing a tutorial - Sharing a use case of a derived application Contribute code --------------- .. only:: html and not latex :octicon:`info;1em;sd-text-info` :bdg-info-line:`Coding (beginner)` :bdg-warning-line:`Coding (advanced)` Even if you are not an experienced developer, you can contribute to the project by: - Testing new features - Writing or improving tests - Reporting and fixing bugs If you are a developer, you can contribute to the core of the project by fixing bugs or implementing new features. Development setup ^^^^^^^^^^^^^^^^^ 1. Clone the repository: .. code-block:: console $ git clone https://github.com/DataLab-Platform/SigimaX.git $ cd SigimaX $ pip install -e .[dev,doc] 2. Run the tests: .. code-block:: console $ python scripts/run_with_env.py python -m pytest 3. Format and lint: .. code-block:: console $ python -m ruff format $ python -m ruff check --fix Code conventions ^^^^^^^^^^^^^^^^ - Use ``from __future__ import annotations`` in all modules - Define ``__all__`` in all public modules - Wrap UI strings with ``_()`` for internationalization - Follow Google-style docstrings - Use ``snake_case`` for functions, ``PascalCase`` for classes DataLab-Platform-SigimaX-0182bf4/doc/examples/000077500000000000000000000000001524425144300210425ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/examples/README.txt000066400000000000000000000010571524425144300225430ustar00rootroot00000000000000Examples ======== This section presents a collection of examples demonstrating the capabilities of SigimaX. Each example is a standalone Python script that showcases specific features or use cases of the SigimaX library. Of course, some of these examples may seem trivial, but they serve to illustrate how to use various functionalities of SigimaX in a clear and concise manner. .. note:: These examples are automatically generated when building the documentation, thus ensuring that they are always up-to-date with the latest version of SigimaX. DataLab-Platform-SigimaX-0182bf4/doc/examples/features/000077500000000000000000000000001524425144300226605ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/examples/features/README.txt000066400000000000000000000000421524425144300243520ustar00rootroot00000000000000Various Features ---------------- DataLab-Platform-SigimaX-0182bf4/doc/examples/features/adapters_plotpy_factory.py000066400000000000000000000073231524425144300302000ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ PlotPy Adapter Factory ======================== SigimaX converts Sigima objects (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`, ROIs) to/from PlotPy plot items through a small set of adapter classes. Which adapter class is used for a given object is resolved by :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` — a single, overridable indirection point. This is useful when a derived application: - adds a **new object type** that needs its own PlotPy rendering, or - wants to **substitute** one of SigimaX's built-in adapters (e.g. to draw images with a custom colormap policy) without touching SigimaX itself. """ # %% # Importing necessary modules # --------------------------- import numpy as np from sigima import SignalObj from sigimax.adapters_plotpy import ( SignalObjPlotPyAdapter, create_adapter_from_object, ) from sigimax.adapters_plotpy.factories import ( PlotPyAdapterFactory, get_adapter_factory, reset_adapter_factory, set_adapter_factory, ) # %% # Default resolution # -------------------- # # :func:`~sigimax.adapters_plotpy.create_adapter_from_object` asks the # *currently active* factory # (:func:`~sigimax.adapters_plotpy.factories.get_adapter_factory`) # for the right adapter class, then instantiates it. Out of the box, this is # a :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` that # dispatches on the Sigima object type. signal = SignalObj() signal.set_xydata(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100))) signal.title = "Demo signal" adapter = create_adapter_from_object(signal) print(f"Adapter class: {type(adapter).__name__}") assert isinstance(adapter, SignalObjPlotPyAdapter) item = adapter.make_item() print(f"PlotPy item: {type(item).__name__}") # %% # Overriding the factory # ------------------------ # # Subclass :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` # and override # :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class`, # delegating to ``super()`` for the types you don't need to change. Install it # with :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` so that # every SigimaX component (dock widgets, HDF5 browser preview, ROI editing) # picks it up transparently. class LoggingAdapterFactory(PlotPyAdapterFactory): """Adapter factory that logs every resolution (for demonstration).""" def get_adapter_class(self, object_to_adapt) -> type: adapter_class = super().get_adapter_class(object_to_adapt) print(f"Resolved {type(object_to_adapt).__name__} -> {adapter_class.__name__}") return adapter_class set_adapter_factory(LoggingAdapterFactory()) try: adapter = create_adapter_from_object(signal) print(f"Active factory: {type(get_adapter_factory()).__name__}") finally: # Always restore the base factory so later examples/tests are unaffected reset_adapter_factory() # %% # Summary # ------- # # - :func:`~sigimax.adapters_plotpy.create_adapter_from_object` is the single # entry point application code should use to go from a Sigima object to a # PlotPy adapter # - :func:`~sigimax.adapters_plotpy.factories.get_adapter_factory` / # :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` let a # derived application install its own factory once, globally # - Override # :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class` # for object-to-item resolution and ``get_adapter_class_for_plot_item()`` # for the reverse (item-to-ROI) direction # - Call :func:`~sigimax.adapters_plotpy.factories.reset_adapter_factory` to # restore the SigimaX base factory (mostly useful in tests) DataLab-Platform-SigimaX-0182bf4/doc/examples/features/configuration.py000066400000000000000000000135101524425144300261010ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Configuration System ==================== This example demonstrates SigimaX's configuration system — typed option fields with ``get()``/``set()``/``context()`` API, JSON persistence, and validation. The configuration system is the backbone of any SigimaX-based application. It provides: - **Type safety**: Options are validated on set - **Context managers**: Temporary overrides that auto-restore - **Serialization**: JSON round-trip for persistence - **Enum constraints**: Options restricted to specific choices """ # %% # Importing necessary modules # --------------------------- import os.path as osp import tempfile from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField # %% # Creating a custom configuration # -------------------------------- # # Subclass :class:`~sigimax.config.SigimaXOptions` and add typed fields. class DemoOptions(SigimaXOptions): """Demo configuration with various option types.""" def __init__(self): super().__init__() self.app_name.set("ConfigDemo") self.iterations = TypedOptionField( self, "iterations", default=100, expected_type=int, description="Number of iterations", ) self.precision = TypedOptionField( self, "precision", default=1e-6, expected_type=float, description="Convergence precision", ) self.algorithm = EnumOptionField( self, "algorithm", default="gradient", choices=["gradient", "newton", "simplex"], description="Optimization algorithm", ) self.verbose = TypedOptionField( self, "verbose_mode", default=False, expected_type=bool, description="Enable verbose logging", ) # Capture defaults for reset support self._defaults.update( { name: getattr(self, name).get() for name in ("iterations", "precision", "algorithm", "verbose") } ) # %% # Basic get/set operations # ------------------------- conf = DemoOptions() print("=== Basic get/set ===") print(f"Iterations: {conf.iterations.get()}") print(f"Algorithm: {conf.algorithm.get()}") conf.iterations.set(500) conf.algorithm.set("newton") print(f"Updated iterations: {conf.iterations.get()}") print(f"Updated algorithm: {conf.algorithm.get()}") # %% # Context manager for temporary overrides # ----------------------------------------- # # The ``context()`` method temporarily overrides an option and automatically # restores the previous value when leaving the block. print("\n=== Context manager ===") print(f"Before context: iterations = {conf.iterations.get()}") with conf.iterations.context(10): print(f"Inside context: iterations = {conf.iterations.get()}") print(f"After context: iterations = {conf.iterations.get()}") # %% # Enum validation # ---------------- # # ``EnumOptionField`` rejects values not in the allowed choices. print("\n=== Enum validation ===") try: conf.algorithm.set("invalid_algorithm") print("ERROR: Should have raised ValueError") except ValueError as e: print(f"Correctly rejected invalid value: {e}") # %% # Serialization round-trip # ------------------------- # # Options can be serialized to a dictionary (and from there to JSON). print("\n=== Serialization ===") d = conf.to_dict() print(f"Serialized keys: {sorted(d.keys())[:8]}...") # Create a fresh config and restore from dict conf2 = DemoOptions() conf2.from_dict(d) print(f"Restored iterations: {conf2.iterations.get()}") print(f"Restored algorithm: {conf2.algorithm.get()}") # %% # Persisting options to a JSON file # ------------------------------------ # # :meth:`~sigimax.config.SigimaXOptions.save`/ # :meth:`~sigimax.config.SigimaXOptions.load` go one step further than # ``to_dict()``/``from_dict()``: they read/write an actual ``options.json`` # file. Called without arguments, they resolve a per-application directory # under the user's config directory (the same one used by the legacy # INI-based system, via :func:`guidata.configtools`). Here we pass an # explicit path (a temporary directory) to keep the example self-contained. print("\n=== JSON file persistence ===") with tempfile.TemporaryDirectory() as tmpdir: json_path = osp.join(tmpdir, "options.json") conf.iterations.set(42) conf.save(json_path) print(f"Saved to {json_path}") conf3 = DemoOptions() conf3.load(json_path) print(f"Loaded iterations: {conf3.iterations.get()}") # Calling conf.save() / conf.load() with no argument targets the default, # per-application user config directory instead of an explicit path. # %% # Reset to defaults # ------------------ print("\n=== Reset to defaults ===") conf.iterations.set(999) print(f"Before reset: {conf.iterations.get()}") conf.reset_to_defaults() print(f"After reset: {conf.iterations.get()}") # %% # Listing all options # -------------------- # # ``list_options()`` returns the names of all registered option fields. print("\n=== All options ===") for name in sorted(conf.list_options()): print(f" {name}") # %% # Summary # ------- # # SigimaX's configuration system provides: # # - **Typed fields**: ``TypedOptionField`` for int/float/str/bool, # ``EnumOptionField`` for constrained choices # - **Context managers**: ``option.context(value)`` for scoped overrides # - **Serialization**: ``to_dict()`` / ``from_dict()`` for in-memory JSON round-trips # - **File persistence**: ``save()`` / ``load()`` for JSON files, defaulting to a # per-application directory under the user's config directory # - **Validation**: Type checking and enum constraint enforcement # - **Reset**: ``reset_to_defaults()`` to restore initial values DataLab-Platform-SigimaX-0182bf4/doc/examples/features/h5_workspace.py000066400000000000000000000152241524425144300256300ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ HDF5 Workspace Save/Load ========================= SigimaX provides the plumbing for an HDF5-backed workspace (menu actions, file dialogs, browser) but does **not** know what a derived application's data model looks like. This example shows how to plug your own model in by overriding three extension points on :class:`~sigimax.mainwindow.SGMXMainWindow`: - :meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` — serialize your objects when the user chooses *File > Save* - ``load_h5_workspace`` (a convention, not a base-class method) — the counterpart used to reload a workspace saved by your own application - :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` — import data coming from a *generic* (non-SigimaX) HDF5 file, as offered by *File > Browse HDF5 file* See :doc:`../../user_guide/hdf5_workspace` for the full reference and ``sigimax/tests/hdf5/test_h5_derived_app.py`` for the complete test this example is derived from. """ # %% # Importing necessary modules # --------------------------- import os.path as osp import tempfile import numpy as np from guidata.io import HDF5Reader, HDF5Writer from sigima import ImageObj, SignalObj from sigimax.config import CONF as Conf from sigimax.env import execenv from sigimax.mainwindow import SGMXMainWindow from sigimax.utils import qthelpers as qth # %% # Step 1: A minimal data model # ------------------------------ # # Any object store works, as long as it can serialize/deserialize itself # using :class:`guidata.io.HDF5Writer`/:class:`guidata.io.HDF5Reader`. Here we # use a plain list of :class:`~sigima.objects.SignalObj`/ # :class:`~sigima.objects.ImageObj`, grouped under two HDF5 groups. class SimpleObjectStore: """Minimal object store: two ordered lists (signals + images).""" def __init__(self) -> None: self.signals: list[SignalObj] = [] self.images: list[ImageObj] = [] def add_objects(self, objects: list[SignalObj | ImageObj]) -> None: for obj in objects: if isinstance(obj, SignalObj): self.signals.append(obj) elif isinstance(obj, ImageObj): self.images.append(obj) @property def count(self) -> int: return len(self.signals) + len(self.images) def serialize(self, writer: HDF5Writer) -> None: with writer.group("signals"): for idx, sig in enumerate(self.signals): with writer.group(f"{idx:03d}"): sig.serialize(writer) with writer.group("images"): for idx, ima in enumerate(self.images): with writer.group(f"{idx:03d}"): ima.serialize(writer) def deserialize(self, reader: HDF5Reader) -> None: self.signals.clear() self.images.clear() if "signals" in reader.h5: with reader.group("signals"): for idx in range(len(reader.h5["signals"])): with reader.group(f"{idx:03d}"): obj = SignalObj() obj.deserialize(reader) self.signals.append(obj) if "images" in reader.h5: with reader.group("images"): for idx in range(len(reader.h5["images"])): with reader.group(f"{idx:03d}"): obj = ImageObj() obj.deserialize(reader) self.images.append(obj) # %% # Step 2: Override the workspace save/load hooks # ------------------------------------------------- # # ``save_h5_workspace`` is the only method the base class calls (from *File > # Save*, wired to :meth:`~sigimax.mainwindow.SGMXMainWindow.save_to_h5_file`). # ``load_h5_workspace`` is a symmetrical helper of our own — SigimaX does not # impose a name or signature for "load one of *our own* workspace files" # since it depends entirely on the data model. class MyAppWindow(SGMXMainWindow): """Derived application window with a custom HDF5 workspace.""" def __init__(self, console: bool | None = None) -> None: Conf.app_name.set("MyH5App") super().__init__(console=console) def _before_setup(self, console: bool) -> None: super()._before_setup(console) self.object_store = SimpleObjectStore() def save_h5_workspace(self, filename: str) -> None: filename = self._check_h5file(filename, "save") with HDF5Writer(filename) as writer: self.object_store.serialize(writer) self.set_modified(False) execenv.print(f"Workspace saved to '{filename}'") def load_h5_workspace(self, filename: str) -> None: filename = self._check_h5file(filename, "load") with HDF5Reader(filename) as reader: self.object_store.deserialize(reader) self.set_modified(False) execenv.print(f"Workspace loaded from '{filename}'") # %% # Step 3: Round-trip # -------------------- # # ``save_to_h5_file(path)``/``save_h5_workspace(path)`` and # ``load_h5_workspace(path)`` accept an explicit path, so they never open a # file dialog — this is what makes them usable both interactively (*File* # menu) and headlessly (macros, tests, this example). with qth.sigimax_app_context(exec_loop=False): win = MyAppWindow(console=False) signal = SignalObj() signal.set_xydata(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100))) signal.title = "Demo signal" win.object_store.add_objects([signal]) with tempfile.TemporaryDirectory() as tmpdir: path = osp.join(tmpdir, "workspace.h5") win.save_h5_workspace(path) print(f"Saved {win.object_store.count} object(s) to {path}") reloaded = MyAppWindow(console=False) reloaded.load_h5_workspace(path) print(f"Reloaded {reloaded.object_store.count} object(s)") win.close() # %% # Summary # ------- # # - :meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` is the single # contract point SigimaX relies on for *File > Save* — the base # implementation is a documented no-op, override it in your window class # - Add a symmetrical ``load_*`` method for reopening your own files; there is # no base-class hook to override because the data model is entirely # downstream # - Override :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` # to support importing *generic* (non-SigimaX) HDF5 files through # *File > Browse HDF5 file* # - To add a new node type recognized by the generic HDF5 browser itself # (rather than importing raw datasets), subclass # :class:`~sigimax.h5.common.BaseNode` and register it with # ``sigimax.h5.common.NODE_FACTORY.register(MyNode)`` DataLab-Platform-SigimaX-0182bf4/doc/examples/features/mainwindow_customization.py000066400000000000000000000117701524425144300304040ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Main Window Customization ============================ :class:`~sigimax.mainwindow.SGMXMainWindow` is designed to be subclassed, not configured. This example walks through the four extension points a derived application typically overrides, in isolation: 1. **Docks** — :meth:`~sigimax.mainwindow.SGMXMainWindow._setup_docks` 2. **Menu layout** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_menubar_layout` 3. **Actions** — populating menus (custom and standard) with :func:`guidata.qthelpers.create_action`/:func:`guidata.qthelpers.add_actions` 4. **Status bar** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_extra_status_widgets` See :doc:`../use_cases/full_app` for a complete application built the same way. """ # %% # Importing necessary modules # --------------------------- from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action from plotpy.constants import PlotType from qtpy import QtWidgets as QW from sigimax.config import CONF as Conf from sigimax.config import _ from sigimax.mainwindow import SGMXMainWindow from sigimax.utils.qthelpers import sigimax_app_context from sigimax.widgets.plotdock import DockablePlotWidget # %% # Custom main window # --------------------- # # Each extension point below is independent: override only the ones your # application needs. class MyAppMainWindow(SGMXMainWindow): """Main window demonstrating docks, menus, actions and status bar.""" def __init__(self, console=None, hide_on_close=False): Conf.app_name.set("CustomizedApp") self.curve_dock = None self.task_status = None super().__init__(console=console, hide_on_close=hide_on_close) # -- 1. Docks -------------------------------------------------------- def _setup_docks(self): """Add a business-specific dock (a dockable curve plot, here).""" self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) self._add_dockwidget(self.curve_dock, _("Signal Viewer"), name="signal_viewer") # -- 2. Menu layout ---------------------------------------------------- # # The base layout is ``[("file", "&File"), ("view", "&View"), ("help", "?")]``. # Insert your own entries while keeping the ones the base class relies on # (``file_menu``/``view_menu``/``help_menu`` are used internally). def _get_menubar_layout(self): base_layout = super()._get_menubar_layout() # Insert "Analysis" between "File" and "View" return [base_layout[0], ("analysis", _("&Analysis")), *base_layout[1:]] # -- 3. Actions ---------------------------------------------------------- # # ``_post_setup`` runs once menus, docks and the console all exist, so # it is the right place to populate both custom and standard menus. def _post_setup(self, console): # Populate our own menu (created from the layout above as `self.analysis_menu`) add_actions( self.analysis_menu, [ create_action( self, _("Run analysis"), icon=get_icon("libre-gui-check.svg"), triggered=self._run_analysis, ), ], ) # Add an action to a *standard* menu created by the base class add_actions( self.file_menu, [ create_action( self, _("Export report..."), triggered=self._export_report, ), ], ) def _run_analysis(self): self.statusBar().showMessage(_("Running analysis..."), 2000) def _export_report(self): self.statusBar().showMessage(_("Exporting report..."), 2000) # -- 4. Status bar -------------------------------------------------------- # # Widgets returned here are inserted between the console status (if any) # and the built-in memory status widget. def _get_extra_status_widgets(self): self.task_status = QW.QLabel(_("Idle")) return [self.task_status] # %% # Instantiating the window # --------------------------- with sigimax_app_context(exec_loop=False): win = MyAppMainWindow(console=False) win.resize(900, 600) win.show() print(f"Menu titles: {[a.text() for a in win.menuBar().actions()]}") print(f"Analysis menu actions: {[a.text() for a in win.analysis_menu.actions()]}") print(f"Extra status widgets: {win.task_status.text()}") win.close() # %% # Summary # ------- # # - ``_setup_docks`` / ``_get_menubar_layout`` / ``_get_extra_status_widgets`` # return declarative descriptions consumed by the base class — override # them instead of poking at Qt internals # - ``_post_setup`` is where menus (custom or standard) get their actions, # once everything else is guaranteed to exist # - Standard menus (``file_menu``, ``view_menu``, ``help_menu``) remain # available for derived applications to extend, not just replace DataLab-Platform-SigimaX-0182bf4/doc/examples/features/plot_widget.py000066400000000000000000000044261524425144300255610ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Dockable Plot Widget ==================== This example demonstrates how to use the :class:`~sigimax.widgets.plotdock.DockablePlotWidget` to embed interactive PlotPy curve and image plots in dock widgets. The ``DockablePlotWidget`` is a key building block for SigimaX-based applications, providing: - Embedding of PlotPy ``CurvePlot`` or ``ImagePlot`` in a Qt dock widget - Configurable dock location (left, right, top, bottom) - Optional watermark image - Automatic integration with the main window's dock system """ # %% # Importing necessary modules # --------------------------- import numpy as np from plotpy.builder import make from plotpy.constants import PlotType from qtpy import QtWidgets as QW from sigimax.utils.qthelpers import sigimax_app_context from sigimax.widgets.plotdock import DockablePlotWidget # %% # Creating a curve plot widget # ----------------------------- # # The simplest usage: create a ``DockablePlotWidget`` with ``PlotType.CURVE`` # and add some curves using PlotPy's builder. with sigimax_app_context(exec_loop=False): # Create a main window to host the dock main = QW.QMainWindow() main.setWindowTitle("DockablePlotWidget Demo") main.resize(800, 500) # Create a dockable curve plot curve_widget = DockablePlotWidget(main, PlotType.CURVE) dock, location = curve_widget.create_dockwidget("Curve Plot") main.addDockWidget(location, dock) # Add some curves x = np.linspace(0, 4 * np.pi, 500) plot = curve_widget.get_plot() plot.add_item(make.curve(x, np.sin(x), title="sin(x)", color="blue")) plot.add_item(make.curve(x, np.cos(x), title="cos(x)", color="red")) plot.do_autoscale() # Show the window main.show() print(f"Plot type: {PlotType.CURVE}") print(f"Dock location: {location}") print(f"Number of items: {len(plot.get_items())}") main.close() # %% # Summary # ------- # # The ``DockablePlotWidget`` wraps PlotPy's interactive plots into dock widgets # that integrate seamlessly with ``SGMXMainWindow`` and any ``QMainWindow``. # # - Use ``PlotType.CURVE`` for 1D signal display # - Use ``PlotType.IMAGE`` for 2D image display # - Call ``get_plot()`` to access the underlying PlotPy plot for adding items DataLab-Platform-SigimaX-0182bf4/doc/examples/features/remote_control_widgets.py000066400000000000000000000067661524425144300300320ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Remote Control Widgets ======================= This example demonstrates how to use :class:`~sigimax.widgets.connection.ConnectionDialog` and :class:`~sigimax.widgets.objectdialog.GetObjectDialog` to build a minimal remote client for a DataLab-derived application server. These widgets are generic: they only depend on :class:`sigima.client.SimpleRemoteProxy` (or any proxy exposing the same ``connect``/``get_current_panel``/``get_group_titles_with_object_info`` API), never on a specific application's branding or object model. To keep this example runnable without a real DataLab-derived application, :class:`sigima.client.stub.DataLabStubServer` is used to simulate one in-process. """ # %% # Importing necessary modules # --------------------------- import numpy as np from guidata.qthelpers import qt_app_context from sigima.client import SimpleRemoteProxy from sigima.client.stub import DataLabStubServer from sigimax.widgets.connection import ConnectionDialog from sigimax.widgets.objectdialog import GetObjectDialog # %% # Starting a (stub) server and connecting to it # ------------------------------------------------ # # In a real client application, ``server`` would not exist: a real # DataLab-derived application would already be running, and ``proxy.connect`` # would be passed to :class:`~sigimax.widgets.connection.ConnectionDialog` for # the user to see the connection progress. server = DataLabStubServer(verbose=False) port = server.start() proxy = SimpleRemoteProxy(autoconnect=False) with qt_app_context(exec_loop=False): dlg = ConnectionDialog(lambda: proxy.connect(port=str(port), timeout=2.0)) connected = dlg.exec() print(f"Connected: {connected} (error: {dlg.get_error_message() or 'none'})") # %% # Populating the server with a couple of signals # ------------------------------------------------- # # ``add_signal`` stores the signal on the server but does not attach it to # any group by itself: the stub server's default group is wired here # directly, through the server instance, to get a non-empty tree below. A # real DataLab-derived server groups newly added objects automatically. proxy.add_signal("Signal 1", np.arange(10.0), np.arange(10.0)) proxy.add_signal("Signal 2", np.arange(10.0), np.arange(10.0) ** 2) group_uuid = next(iter(server.signal_groups)) server.signal_groups[group_uuid].objects.extend(server.signals.keys()) # %% # Picking an object from the server # ------------------------------------ # # :class:`~sigimax.widgets.objectdialog.GetObjectDialog` lists the groups and # objects available on the server. Restricting ``panel`` to ``"signal"`` # hides the panel selector combo box. with qt_app_context(exec_loop=False): dlg = GetObjectDialog(None, proxy, panel="signal") group_item = dlg.tree.topLevelItem(0) dlg.tree.setCurrentItem(group_item.child(0)) print(f"Selected object UUID: {dlg.get_current_object_uuid()}") server.stop() # %% # Summary # ------- # # - :class:`~sigimax.widgets.connection.ConnectionDialog` wraps a blocking # ``connect`` callback in a background thread, so the Qt event loop keeps # running while the connection is attempted. # - :class:`~sigimax.widgets.objectdialog.GetObjectDialog` displays the # groups/objects reported by # :meth:`sigima.client.base.SimpleBaseProxy.get_group_titles_with_object_info`. # - :class:`~sigima.client.stub.DataLabStubServer` lets both widgets be # exercised headlessly, without a real DataLab-derived application. DataLab-Platform-SigimaX-0182bf4/doc/examples/getting_started/000077500000000000000000000000001524425144300242315ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/examples/getting_started/README.txt000066400000000000000000000000401524425144300257210ustar00rootroot00000000000000Getting Started --------------- DataLab-Platform-SigimaX-0182bf4/doc/examples/getting_started/minimal_app.py000066400000000000000000000073521524425144300271000ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Minimal Derived Application ============================ This example demonstrates the **derivation pattern** — the core concept of SigimaX. In just a few lines of code, you can build a full-featured scientific desktop application with menus, toolbars, console, and status bar. The three-step pattern is: 1. **Subclass** :class:`~sigimax.config.SigimaXOptions` for app-specific options 2. **Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` for custom UI 3. **Call** :func:`~sigimax.app.create` to launch This example creates a minimal "MyApp" with a dockable curve plot widget. """ # %% # Importing necessary modules # --------------------------- from plotpy.constants import PlotType from sigimax.app import create from sigimax.config import CONF as Conf from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField, _ from sigimax.mainwindow import SGMXMainWindow from sigimax.utils.qthelpers import sigimax_app_context from sigimax.widgets.plotdock import DockablePlotWidget # %% # Step 1: Define custom configuration # ------------------------------------ # # Subclass :class:`~sigimax.config.SigimaXOptions` to add fields specific to # your application. Options are typed, validated, and support JSON persistence. class MyAppOptions(SigimaXOptions): """Custom configuration for the demo application.""" def __init__(self): super().__init__() self.app_name.set("MyApp") self.app_version.set("0.1.0") # Add a custom string option self.greeting = TypedOptionField( self, "greeting", default="Hello from MyApp!", expected_type=str, description="Startup greeting message", ) # Add a constrained enum option self.theme = EnumOptionField( self, "theme", default="light", choices=["light", "dark", "auto"], description="Application color theme", ) # %% # Step 2: Customize the main window # ---------------------------------- # # Subclass :class:`~sigimax.mainwindow.SGMXMainWindow` to add your own menus, # toolbars, and dock widgets. class MyAppMainWindow(SGMXMainWindow): """Main window with a dockable curve viewer.""" def __init__(self, console=None, hide_on_close=False): Conf.app_name.set("MyApp") Conf.app_version.set("0.1.0") super().__init__(console=console, hide_on_close=hide_on_close) # Add a dockable curve plot widget self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) dock, loc = self.curve_dock.create_dockwidget(_("Curve Viewer")) self.addDockWidget(loc, dock) # %% # Step 3: Launch the application # -------------------------------- # # Use :func:`~sigimax.app.create` to instantiate the window (without entering # the Qt event loop, so sphinx-gallery can capture the screenshot). with sigimax_app_context(exec_loop=False): win = create( window_class=MyAppMainWindow, splash=False, console=False, size=(900, 600), ) win.show() # Print configuration to verify it works print(f"App name: {Conf.app_name.get()}") print(f"Window title: {win.windowTitle()}") win.set_modified(False) win.close() # %% # Summary # ------- # # This example showed the minimal derivation pattern: # # - **Configuration**: ``MyAppOptions`` adds typed, validated options # - **Main window**: ``MyAppMainWindow`` adds a curve plot dock # - **Launcher**: ``create()`` or ``run()`` starts the application # # For a production app, use ``run(window_class=MyAppMainWindow)`` instead # of ``create()`` — it enters the Qt event loop and shows a splash screen. DataLab-Platform-SigimaX-0182bf4/doc/examples/use_cases/000077500000000000000000000000001524425144300230145ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/examples/use_cases/README.txt000066400000000000000000000000241524425144300245060ustar00rootroot00000000000000Use Cases --------- DataLab-Platform-SigimaX-0182bf4/doc/examples/use_cases/full_app.py000066400000000000000000000133131524425144300251710ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Full Derived Application ========================= This use case demonstrates a complete derived application built on SigimaX, showcasing: - Custom configuration with typed options - Custom main window with menus, toolbars, and dock widgets - Interactive curve generation using PlotPy - Configuration display via console This example mirrors the derivation pattern used by `DataLab `_ — the flagship application built on SigimaX. """ # %% # Importing necessary modules # --------------------------- import numpy as np from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action from plotpy.builder import make from plotpy.constants import PlotType from qtpy import QtCore as QC from qtpy import QtWidgets as QW from sigimax.app import create as sigimax_create from sigimax.config import CONF as Conf from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField, _ from sigimax.mainwindow import SGMXMainWindow from sigimax.utils.qthelpers import sigimax_app_context from sigimax.widgets.plotdock import DockablePlotWidget # %% # Step 1: Define the application configuration # ---------------------------------------------- # # Custom options extend :class:`~sigimax.config.SigimaXOptions` with # domain-specific settings. class SciAppOptions(SigimaXOptions): """Configuration for a scientific analysis application.""" def __init__(self): super().__init__() self.app_name.set("SciApp") self.app_version.set("1.0.0") self.app_desc.set("Scientific analysis app built on SigimaX") self.sample_rate = TypedOptionField( self, "sample_rate", default=1000, expected_type=int, description="Default sampling rate (Hz)", ) self.signal_type = EnumOptionField( self, "signal_type", default="sine", choices=["sine", "square", "sawtooth", "noise"], description="Default signal type for generation", ) # %% # Step 2: Build the custom main window # -------------------------------------- # # Override :class:`~sigimax.mainwindow.SGMXMainWindow` to add domain-specific # menus, toolbars, and dock widgets. class SciAppMainWindow(SGMXMainWindow): """Main window for the SciApp analysis application.""" def __init__(self, console=None, hide_on_close=False): Conf.app_name.set("SciApp") Conf.app_version.set("1.0.0") self.curve_dock = None super().__init__(console=console, hide_on_close=hide_on_close) def _setup_docks(self): """Add the dockable curve plot.""" self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) self._add_dockwidget(self.curve_dock, _("Signal Viewer"), name="signal_viewer") def _post_setup(self, console): """Add custom menus and toolbar.""" self._setup_analysis_menu() self._setup_toolbar() def _setup_analysis_menu(self): """Add a custom Analysis menu.""" menu = self.menuBar().addMenu(_("&Analysis")) add_actions( menu, [ create_action( self, _("Generate signal"), icon=get_icon("new_signal.svg"), triggered=self._generate_signal, ), create_action( self, _("Clear plot"), icon=get_icon("libre-gui-close.svg"), triggered=self._clear_plot, ), ], ) def _setup_toolbar(self): """Add a quick-access toolbar.""" toolbar = QW.QToolBar(_("Analysis"), self) toolbar.setObjectName("analysis_toolbar") self.addToolBar(QC.Qt.TopToolBarArea, toolbar) toolbar.addAction( create_action( self, _("Generate"), icon=get_icon("new_signal.svg"), triggered=self._generate_signal, ) ) def _generate_signal(self): """Generate a test signal and add it to the plot.""" t = np.linspace(0, 1, 1000) y = np.sin(2 * np.pi * 5 * t) + 0.3 * np.random.randn(len(t)) plot = self.curve_dock.get_plot() plot.add_item(make.curve(t, y, title="Signal", color="blue")) plot.do_autoscale() self.statusBar().showMessage(_("Signal generated"), 3000) def _clear_plot(self): """Clear all items from the plot.""" plot = self.curve_dock.get_plot() plot.del_all_items() plot.replot() # %% # Step 3: Launch and demonstrate # -------------------------------- with sigimax_app_context(exec_loop=False): win = sigimax_create( window_class=SciAppMainWindow, splash=False, console=False, size=(1000, 650), ) win.show() # Generate a signal to demonstrate win._generate_signal() # noqa: SLF001 print(f"Application: {Conf.app_name.get()}") print(f"Window title: {win.windowTitle()}") print(f"Plot items: {len(win.curve_dock.get_plot().get_items())}") win.set_modified(False) win.close() # %% # Summary # ------- # # This example demonstrated a complete SigimaX-based application with: # # - **Custom configuration** (``SciAppOptions``) with typed fields # - **Custom main window** (``SciAppMainWindow``) with Analysis menu and toolbar # - **Interactive plot** via ``DockablePlotWidget`` # - **Status bar** messages on user actions # # For a standalone application, replace the ``create()`` call with # ``run(window_class=SciAppMainWindow, console=True)`` to enter the Qt event loop. DataLab-Platform-SigimaX-0182bf4/doc/images/000077500000000000000000000000001524425144300204715ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/images/Sigima.svg000066400000000000000000000457101524425144300224320ustar00rootroot00000000000000 image/svg+xmlS DataLab-Platform-SigimaX-0182bf4/doc/images/SigimaX-Banner.png000066400000000000000000000524261524425144300237540ustar00rootroot00000000000000‰PNG  IHDR”ÜÎ[1IDATxœìÝxTeÖð÷–©é�$�„–Bcˆ¤©¬¢öî꺮k[Û§«îªkYŵ�Ø•®ˆÒQšÒ{HhB é™~¿s†I˜@€�I™$ÿßó\†¹ÓnfîÌ=÷œ·ÈÀ²ðJð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð Jð‰*üHÒøOZ ›u”$¹ú IIB‹¤ÕŠ€†Ì)„tBhÎ4M“W ½á‡ôY·e¨7$á8�”íÖG5IŒÐèIš˜íÒ^G` P?Ôy@™2úý›4!^¥™HMèi«8sJåx %ùFArÑ?.úìôÙÛ<+�´sþ=uÞ½_ðkuZNNõþC@¼ ÜÁ£¤£K3¥§t§¯ûGöjÖòé Þ(GɧBŒOeÏIûq�¿UgA›'3ù/ÏU#ÅzþOTX€h&b"Ì"Ь²Œ¸²!s¹4QXlGO‹ŒC§ÄñSEž[$ÎTZÜÿâ1d*üW�DkÜfRrXWˆÓÒ²`²wÇ8‘ÜÀûÂi¼o¸›B(ž}üP­”œ�<Ó›[3𿜙l(ï ¼Oœvzá}Æ=¬ø�ÚÏPÚ¬£Ü—Ü›[h2·™DfÎÆûïîžþî}EœÙwÀ¯Ôz@é´ÜýŸÓƒªs€Š”íž}¥lß¿RûJ÷ 8g^›{sTÄkß8½ŸžÙwÀ�ÔA§÷tŠ¢tÐr "gö�ÒîK÷ð'u1—w¹ÁÔ1Î$œOûæuðC˜Ú|‚€|‚€|‚€|‚€|‚€|‚€|RãPzHùüïÓwç3ã‡íü2”à”à”à”à”à”à”à”à”à”à”à“:œËêƒCÙÊæ´lÝ�#yjn¾Å}lt%Ć8º$GÛ㢃œ5”p^?üºÇ¸y÷QýÙësòJdZô|[—61¶Q--”P¡/l7gfå^tÿà 2¯À"ßtu‡b�ÚPÂ983Y™`²ß—# QB@ å¸ÛLVPæ¾~ ?V@£ƒ€Êá8¢Š|y,Ô_hC åponQE¾<ê/PNéÐ@µýX¨¿€OPB9×5€ÏwM§Ñt:7XÒ”§RF¿¿B“¤×ÒæÞ³@ÔC(yWÒ“ï, ¹ÿå…á&¡ºQp9XhÚ�)cÞŸ*ê!”‘[`‘oüûœˆ™‹SÍe+µ²| •Û�4MÜMÙÊå #ß õÊ‹¸÷¥…açd%¥²| �³q¶Ò¤H³@=‚€ò¸Ì�7Ô6*Qþ€úåypœren€ZÄåïä1S¯õÊóàÞÜ Išö7P`Ø  ð8“e¥nî4Q…æ’íZEŠ~]âÜÿ_½ù�Ø™q¢Zî �€gŸãÒwÒ˜w{cœJðw(+Àƒ–—]‘.=¢¼c|ñØä>gVLâ_Ó׊�fmöé¾ÐHxís²K¹†.P€_CÉ»<Ιk—Lr¶±\€èÁëø¶ªÞÉû¿€ŸC@YžNQTQiéº2·]Ê}¡q¢\eàçPV€çæþ¡Ñ¦ª5MGËjZŠ´ÓŠiÙHË$ÑÑßÝ�–Ù´äÐâ¢ÅNK-ÿ %HÔ!´¡¬€ÓUõYp¸S ·ƒ<ïmU¼/4ZЍC¨4¡‹)´\N §Í9À §·«�–S´p/²­´Ì�$i©¨ôº÷ÐÅã…Å6ãw¿ì.Y½õ�©}b”îÚaÉIM£‚^§Û›Òk½* ú{ùýÉát%.\�aÿyÕ^-2Ô,�–Ñ¡uä_EŽ£ûø×É} #ï£ûí¡×˜)8ú;“éâÏE%ö„W?]ôÍÏ;Ë~·—þ¾/øá›{;¯ÐúZ“^ɤUÿÂ½ß £ >1㓳;pÖ$›–÷é;´\Ô ÚŽ)t1�sÛᤅ3Þ í@¥žC@Y¸‡6g+3Хܠ¦Ñ�+ºø;-×[íN‰ƒ¸¯~Ú¡_µùÐ9¥wº�›†ðAÂ,&Œh«�˜Ø16*¨»$‰ñô\¯ÒAb¾¸tƒK¬Ž˜?l‹( &KmNÍ3ïÖÅÅôŠq8´Qô¿Ðkˆ†­—ÍîêøËšÌàV¦—ûÍ>z²H|½p‡’Ò<<8.:ðz?fÒû‘&„8äp:ƒœN1 ý@ŽBß©²TE­âÂlf“Ž¿ó;k*³MÏÝÇjs<”[`íx0;_Õ¼ŠMÂÌ"&2ÀbЫ+L6 (k…• /å¾5…~ü;ÑÅ‹.M¾a×QÇŸ­ Þ¸ëh¥{ðh¾xý³uÒÔï6ÜumWí¦‘z˜tÿ£çüÏ¥”¦=m›QT—ºïd…¿O™Y¹âøÉ"�fJ ЇЪ†PÆYlvÓνÇM”¥<çF~ïù`nn,D­B@ ‚O,èû4Ýî°·;–SûüÔ_u|Âè»)žº£¿îÊþ­‡éLòu´ê=QÍø»ìt¹¦”X�IÓæmU?™³¥ì¶Ëz·OÜÖ¯X–¤ÕtõK :Ÿÿø·£‹—lvç�/Ú¡ûó+¿T:˜ôÆÏ›Ÿÿ.=ó߆ÌC¹N—ö4=÷;ž¶˜•FAí›(Ñ!¹\Z�¶/­EšËéR.~§Fó~@åýh2èævo×´dÔल•ü=�³,MÊÊδؓ¹Ã—¨~c¬VÇ5ë¶g™üº§leLD€74Ù�£Ó)Ó‘Qo8P4rž`ïq»Ã5˜ÊÛ”™Ô�Ì+©ð¾ÝÚÆˆÛÆv÷ÿ©»¸vx‘Ò"¢ÂûýôÛ^ñeD¶¤3:œ.n‹ù·ÊôDö”¯3�:ENn^á}Z6 ¡A­°ÄžÕHJe™ôwÚ“èýàÌÒÙ¸¹AlT  6î uDxx¾O3L9uôऒíbËnû}ûañï{ŒV«“O&'UçHÜî×jwL9‘k Ÿ»,M.ÍŒ²«´=;4+1n3W@ƒ�’7L¤ ïŠ¥¿ïÓQYJ9»¬zÓÕ´¯jWÒ*.LUd‰ý/¦ÅFK 8ä¬cùÓço ülþ¶r¿'|Àú×ô5êßní+µoy§^§äÐê—+±=KTU™8r`bÐÞC¹Ü޳ìhÇM¦lœ°Iš˜%‡ÕNMlë×¹Y䘡ÉÊ— v”ÝÀÙž?]ÑN4 pæZR‘!¼Ð>ñ}Og$Ä„<=nXŠ~W扲ïø:ñëÞ6ÖØ«Cìµ&£ŽËÏ߉ê1ÑfsõøeM†qɺ}e+ùû{Íà$+“œ²ü¬´nTP4bÜnRÓÄ8 Ÿÿ¸Ýè�IàÌ×C{Ú®êßÚªSåí´j-Ü f•×ã'ÅEOyâö~Ý'Œhëz÷ë ¡ Wí-ëHÃeów¿Z¯PPinr?Ý=þ‚m¦øùívíáà@Ã_¼oP«=O‰lÚ®ˆP“Hn!ròJ{æþßå}[|.ÎÂÒûöçÜ|ËÔû&tpÍ $ißá�»¥,¨ümÓAdTÿ|SÏh ¹ô�ÎY“ m”N'-çá9)ù,8@ßuÜÐäæÛÒ�éùD�q€JáFÊ^ö£ÊïóЍ"n>ãt¹&Ùãf-MÕ§î;YvÛe½[Š¡=›—P0¹BT_&üJ€FÊÓv²{n^‰~íÖ¬r:Æ_–¢ ïÓ’ƒIþáéb¥)ºý;Î>ÒëåûþÔ½‡Ãá ™ñãö² rþÊt‘Ü"\¡òy§ÓõÝ÷�‹=gD„”O·x #úµ~–.ž—ˆ¾o?Ò÷®o»ÖQŒ”¤ûÏ¿»¿Ÿ¥t:%EPÆû&ºÏ*ºï¯¢j®³Ú\Ãé$ÒÄí¨Kµh"ÆK¶ê�ªŠ4 Ã5Lè”ÐxÅÙìŽd*ŸêÒ=Ù Æm.ïÛÒ¢×)ûÅévN•£Î“u|&À¬Ûzûø.yWôkå^ÏÙοßÖO»éêö“QÝ¡(ò,´�¨_š ʦ«¶.¡òwÙJÊX *Oë‹-Ž–N—¸µ*t¸§8÷Ï:–4gYªìÝ{ô�dÑ!1:½ÆIDATºØdÐ}Sűi¡@@ Ðx5sº´ˆ#Ç Õ¯^ÝmZFˆfQAÜnr¹w{ÉÊàvQŠ$=°÷îëºå ëÙ\¼òç¡öI£:XLú…²$ÝJ÷AÏN€:ÀM'¨¬=½IX@•¸]Ü©«Ô¢5™bí¶,“Õ긊®^ O[:~ì$î1þÃÊ=Fªx”Ý6 k¼¸ª+‹^/óô¬_ h°e@¹~çÝâuû  q ¥ò³!'ߢ÷Î&ÄF *uó”hUŽçó¦²Ö{mZDüço#œý»Æ—¨ŠüÝt7ݶU@]šIÁÝO}:6+¦JDÙJî�ÇCüÏ- ±;œ·xƦ­¬+K,ök7§fôs’«c‡%Sà”kÐ)ÓðýoØ]@IÙe᪽¦µ[øLJ4^&ú�—¨LUn¥Ù螎�'I+UDOû‰¢HŸôÊE–ž¢ë�ÔÔônPyÜÜD‘åi&5cìÐdKǤ3s,_¿_,\•a²X�]éêM•y>ž®ÕápNÉÉ·Fr�qî9^êŠþ­D¿Nq%ƒú“@Gœ¯Ñ”³–î6•þsêQý¬%©&›Ã% €ÆÇÊ�#eÊ­,*±ñA‡|蘞ã ZºÓò®¿Á�n zõËVqáE<”�÷€ù󖧉]™'M%VûŸèçad%žî«ÝÕwéï™Fï1c9P¥ç¶Ñsg*²øí¦¾FÕË›ƒÇSù–rAôÎŒã:‹Í.�”T`Ð@ã‘K?ò³Iï^'—ô6»Ëàp¹âE#DÑ1tÁÒdZâhá){iá£.7àc-Ü“‰;.- å«†|Àô”?'ÒÒ™Þ/bhái’øb¡¥�–}´,¦e†g˜#_^�ßwÎ�õ§…ç Œ§Opøõ8sžK ”Ï̞׬Ó÷Ÿ¶ùfºEK¢8½Ï„Š3Û\úqúŽ÷›LZæy†Ýª+ßéuJÿá}ZÛ°ëH@i¯lNhîòT]˦!±:Uå±)·œ¯W6Ý6ÌjsMÜ{0'`îòt©´é ¨£'i‰ñáEFƒú…½Æ¡i4åš­Yz+º-ãP®:sñnóÈ�I%MÂÍ.Ð8d˲œ`(ÔclÞ4Dk¤Oˆ 66� ‰7›tcéñùø›K#xÛ8ëñÔ�ýÅ î AQ¡fQ8P¢‹iÁ¥þß6’f.Þ¥®Øp°Â‰Ä7§fóÑŸÂÏÂŽ349ôò>­zÅFr>…V¿Fìååþ‡¦ÈÎ+yö½•�¥ƒÈOÝIÜ;¡›#4¨R͹]ûçæ>ñöòpž�ˆÝymW1ùšŽjTXåÞ�ÜKñ럭3ûË.÷õ!=š‹§ïìg‹ŽTÏz_8Pº�‚¤æ{œ*údöæðÙËÒ*<^¬ÝšÅë9çrê†+ÛÉ#´H�v/zîÕûêÅÚÏòì*tñ8-}K,Žây+Ò•gmŠ §ÉÙ÷]¿ó7Q2{qíe)®I£:†¥´Œx\–¤Ë蹞«éŒ˜çý¹ÃéÒZ§ež´,X½7Œ‚0=mo…$m3_˜&hŠg:¶ŸEâÉø=JËÄ‚"«òõÏ»LŸÎÙz2ïÒû!íØ{‚—€—?Z0î²dë_nêÝ9*<`:ÏL"N–~] §í+û¿gø—'5MÜ’¶ÿ¤ãŸ¯ ^³õpheŸ‹gFy~ê¯úOæl‰xðÆ…#$Þ¡ªrgzÞ§Ï7ÕÝö]ü™²�¡³—¦Y?ø~c¸wÇŽ‹™¹$U¦%’Yë#7÷êDAÖTÎÆÒëUû5ž“…»\.­ã®Œ–w¿Ù¶ô÷}Ujƒ¿pu†LK8÷„¾çún¹F´›@�å@z *§‹Úó…É ô5(±+÷Ò.ò‡/¹ãêmc»´1êÅÍ´]»K÷ež²ÕjwN¦ŒqÈœ¥i²÷÷†SÑ5%Úb2ê¾ç€U@£Ñà;å|¿8ÕL™‡Jÿ�…—p_€`1•¥²t�³ðl¥Vm>$¦ÏߪÛ[PÆèÿèÒS4ô·ð8{³édó.ʬ¯ûë¬ØO_[¥`òl³—¤®lVÓ™‹wóÐ+¨^Ù¿5ei£—“¹´hÜ#¾ØêyVw pøx�{.dÀKqyþ) Œ¶¤f;»µo‡àýë´úEá§N�å.ã?cs8:g«òÆŒuaÞ÷éÑ.VŒèÛRÄdžˆ¨P“¡÷ƒ²tâø©bn.$þØqØÝîÎ{—¬Ë”èD!ü™»ˆØ&�Ñóï­†BœÍ~ŽþÞ›)ëzùãÕQÞóÞ{ã€jp÷,¨äëÞox.~¯¨tí~fçÓvîÞeböÛÆƒÒG󣿵oþàÍ—3ËOoƒ¨i3 yÀ€®ñc®Ð:àûE»Ý+ùý�»>6¸eç”&Í©ÌyïõÝ >ä/~Ü.¼³½3§*m[Ftk{½A¯p�Á=›98™œº?Çúô»+bK;tð¬*“Fuà×ÕEv8®£z�|ÀózùÍš‡&Ň7í×9¾Íí㺄QЮ~¿x·øyõ™fzË×ï—bƒCþ<±g»@³~ ­ú›¨"O0ù2em¯ÿmó!ùµOVíñš¦”qÉúOW¶ã6‘¢u|˜F÷µ¸\®lU–³u:…?óã´èN-Âît6In‘|õ€Äð'oïÇeeé«…;Ei›Ø}‡ó½Á�ÜÔ«`Üð”ñª"ógQãÃÚyš¿L 4«ÝÇ Mi¹-ý¸ž›/0›²[Ûã¸a)}és˜DG×üBkô¬¥i:ï÷‚Q1°k¼E¯W8F©»j�%•è¤9KÑ 2¸·4LÞ�� yö¾ Ý y…Ö²ƒ ûrÁqôx¡î¾?uïéphŸÑò¨ªJsD=Âen &IÛ2ì©wWÄx÷HeÜÑ�qYï. z UYZA¢é”]šy‘çå€cr‹¦!7¶Ž ëtýˆ¶ ÊÄ·�&]zž)›¦�2Æï®Ðé䟯ÞÆqÛØÎe·qPFãÀ«3–®Ž®â¾z­7PyZOÁd w0É' oüu¸øóM=m1‘�Ëèý¹’ö�®´Í¯U&˜â¡¥(ƒ9V§*ã̺ŸGN²¿óø×øËRÜ·óçðéÜ­aë¶ÖSµÛ Iµ¶Û|©×Ëë/ïÓÊJ'We+ytƒù+÷è‹,öÐ}‡óõs—¥IÞÍhû9¨,6éu_6´|Py . \´6Ó@™´›¸žÞ›ÏÓAúã¡=›ç=uç 4½ñAå/ÿ^$}½pgHN¾íÊdò�#�„ó �}_±Åžüñì-áÞgÅ((°7� úƒ§ :婪fWxó£n¬"˯´N+|æ®Ú㺸oûé·½ÒŸýN¹‰Çnô¼-Û÷ÓSv/‚{«óHM쩵i•GÙÙ§BŒ\ÊûAûÏÛô¸7)(Í›xUû²X8øX³%Kg±Ú#é3é½â�ý*OÓÇ™Ðnè!z´�årúÛaÁÆKW‘ï`Ô?«×«[¨ŒlÔ=¡ì¶�»³�_iw8¹SP’¸GY“:HÜàzíÓµfï6•¼óÕzüÜp×u݆i.m>•À_ øÇÂ?]OÅ0*ÏÎ]žV–æá6�á8aD»â“2‡²’ÏTÇxyžÀüiz3\Nñ“®é�[h‘¹“—¿¹óJU{1×Îî9�#qÀÄÁÃ]ôž$&„š ê³:�ò–¨ *>5›t�‡ôl>jõ–¬ nƒÇ¶RfxûžãrBlpÑ·¿ìr§/o¹¦£èÖ.Æ*Kâ�à@ãc¢ x|Dz¿?‰Žx~h�ævqgÏeçÓë�0&ŇÇëTÁ'>•ê$ÂsSÓŧòŠ›}:wK¨w󸹹—³mëÈ,:ùGu ïCÏÃCmv¸\/ÝpUûA”¡UÿóÅïî_´¯õ:EÔ¢¹ƒ: Wû¦7QI>�2¥î•¼�¼õeù¡(yŸáÀ“P D?©ï�öÀ7 &C™[`‘ç,Oóm”W€FŽ"ZžVdé¥ö­£r^{xh!—„ÏÆ� ^üàWeGƉx‹Íö®ÅæøÐ“ ôœ=ujÚ8 à À¼Kt< øõÃÛZè`ý=­©îÁ—éù>1›Ô'ƒÍ†“7�ì qï_æOÁd©ÒìÛ�Wµ=:4µõÊÇz}Õ‚IÆM�"ÿjÊï×9®,Ëû‚ßö?šµ9„{Åó ê´8t²¼,(ÀPå×óX®Såô®m¢-­âNgÖùóN?pR¶Ú�Üž>åžk=¦ÿ¢µûJƒaÆÁ�„¸:$F 0<_ÝcEò8�”í¼WÒòQƒ�7^ÙÞ½ž{ƒS‰YÔþ  ,û ³QM¿fp’µ[Û˜óÞ÷ê�‰¢g»¦%€Î£«ó4j & äÁË]. í&ªTÞ�%é�&‘�»¿µoñÃ7õҼǧc\òíeÒœeiF«Õ1…ÊÊ_{¦Lô#m6gÊâµû‚Î.Ñ]38Ùf2ª«(xz¾¦:ÐóN3Ô×ãcBÜAùÙíRý  ºÆ»$MÛFŸóûÂw[E>””få²v)ŠŠgéá÷‚²_Zp€>'$ÈðÑù抾)‹·52ÌäࡆJ=^ÈÃõ(N—+¡2í(='!W:š¯›¿"]ç}2ñꢗø‚@³ž3qŸˆÀ¾³îú[v�šìèߥnÎÑx$Ê’~Þ¢ihá˜!Éç|÷ï3£%ÙL&5�Ðéþ>€?Ô¼P.øm¯ñðñÂZ­ 4tt€˜«HÒ:€.¼c|—Âïd÷îhÁ8»õ¿‰7f¬S�å÷9U`ùˆÊÃDóWde—­ß_ö;WZ¢‹ 3ž ƒäÿ¤žŽÊ¿ïÑYîüžšÚ†yurð7¸DG‡¿­¦²åQ (÷ÅDÚ¢ÃÏ ¤{SÆ–2}U•yŠDŸ§Iô3™”m¶5ñš†2;§HœÊ·HN—ƽף*ñTClVgò’uû‚K‡òa<û O%Fƒ²†®Öhóž6’¾soÅFæ];¼�V‡'"ßRû×ÁÝ,—õnYî0Ç KÑè÷ ØD�çùfA‚Æ¥Þ””%ÑmÜuD/ Úy…žBåÄ®ìß:ÿÍÇ./áRåÙx,½ÿ÷›²ÿp^Û§Šg8ÚM¢nõ¶Û\É¿ï8lò t‹ÝÚÄ–˜Œî‰ú“¨aèÐÁwš^•� è–àòÇ,%g)¸ÓTE:HÞrQ Îç2keqp`6ꨌ©+î˜ØDø›–ÍBÙv“^·¥šƒì|z>-ÀX¾ý]Ó&AT^pèt ÏÛçkÛIoE”5sPfO2êO`b±9D±ÅA[¡q<ð"�oïpja;÷ž0”ÎôÃ�i¯ŽMµ ³!Ÿ2® jyÀîõª"oOŒ³6oz±X¸zq€h³»nÞ$7`ÞŠ4ùì)5gþX¹ÇXbqp‡§›ë˸´Psêm@9oyºñø©b´›¨EQ½ø½v˜ŒåG§kn¦r·ê”eé`5vòJ¯WË¢„Š-vÕ¥ Žj/P¶).±3³rËVp›á¶­"-ªê.soµˆKëFƒº6"ÄhKi.j‹'0œDï[â¼é嚌œ�*„bî#F ÚÇÐÕ‘µzPþ±ãˆ~kz6ÚMÔO�ÜÇ({òv¿ÎÍ Ÿ»w` öì�§Û{cÆ:iÓîlc‘Åþ Ó©Õfù»eª‚�œ,Ô—fV¸dØ:>̦(â]Ý$jß*a¤`Êy±¸¦öðûf’�8ò®î¹Æü�AV@ hßqtJu·EtišË¥RdLY貕Tî.ýïy<ä•Ãálž_l•�œ(,["¢é3£Ïn]=(j_*½E­Îšd †�±Øœ£7ìÐ0úݯ×Kû�ä…•XŸ¢ƒÍ8Q;"N—1·ÀzfMÑafeŲéê~QË8WdéPh�Áí•¡”êx 33 áÁ&‡, NËÕËÍÓß]n%wÒ¡˜Ï^¯ç¤CšS:ëõèd†þu)w¡‰<‚.-"¯ÐªæxÊÝ,ŽÊó½ÂÛº·Ž†Å9DûH~lD€½6ÚQr@H�á-'s‹Ãf.IÕyÏ4¼OKñ—I½ì÷NèfóíaÑÚL±hÍ>“ÕîêMW¯ÐhÕ»€rÞò4´›ðt�}‡‚ʧ۵Š<öâýƒŠÎîÎcU~óóN¹ Ø�_d½ß3 IM ¤2»Îbu”­ 4ˆ @ƒK‘brE ŒÜq½ª8Cýg2/³Q'ŒÅE�6§åŠEõ¢OÒ(Y¶‚³\üš´Ž£¶~kŸâ]ËsÂ3},^—©Øí®>‡«6²”TûÔŠ½:ðÔu”irªªÄÁd‰¨fìêxz�*(«Ç%o›¨^îáfd¯¿—;Ëp›J§¦ñ{QÝ?÷6RÕÒ¾:M:›Í!sÏðRAf½�¶Ÿß—ê~+«ÐåÒl”jxúEúÊ\nµ9þD�¡Ù{šR6vhŠÖ¾udmÇ�FeíÈA‰E¥³?1îŒ7oEº¾°ØÖœâ÷[ÐA§qª7ÁÙê͇ô;3Ž×Ê)\ž‹˜6¯4� Épb�žó¸·cä òð‰BcA‰ulº‰Z¦We¡œ4¬u=£‡Ë+·£Õñ~h«ôÞÐvpÐT#�¶w@ÉmM�ƒ<\K-þDÒ$§«üâÉ®r„YÝÙÛJãžët")JÍ®=cpN¦JBÂ쥩ïùËùqDß–%FƒŽuŸFYöéM£s(ÈtzÏ óËê ±zË!“Õ긒®NÐèÔ‹€2#+W]úÇ>´›ðc¨½K½imZDó\ÄÞ·qOÑß6T\.)‘²”WˆšUBÛ" g2:6Ê:9OGo†:Ìž9sj³;…¿àl©N•¸ƒŒUÔU‘è“pgýjí5+ÁNå¤`©\‡*S´í.•‹:BçA:Eº”=5h¼Í®]FßQ“w©›ÛmŽ–ìjaæ1E§y:äýh4¨?èÒ¬ðª­ËîËC-ÍY–ªdŸ,¶Ÿî ÓI@£â÷¥Õæ”æ¯HG0 P?|h )SIDATÔ+«®êŸ˜;¨{B¹þØ~˜:†ÂÛåÜ«VÔœ"Ê,9M†3}0Š-vA%pÅáÐxŒ£Zo‡ÍA,’‘§&çXÊÖ×u§.{Jð5­”ÒQFT¦”¥v:¸¯•׬$� ÙÌf�KçUZ>‘W¢ÒI7z�u#�Þ.�ÍîÒÎΞV®ØíÎI‡�åSvR9éÕ)‰Æ>›šŒºÙ´¯ÌäuœáWdù³ÀÞñ—¥”xÆ“,X•a²X]LÞ( Qñû€’ÎxŒEV´›¨<ÓÒ}jÎ=$¹Ø»$ƃŒï9�£Òa1‰®¶5礢Höðà3q#÷ÜÍ/´JNÍÉ㯄ŠÚBîX ¦åÜÂ3‰¹º.ysI×ÓæÐU[Mܬ¦qÙŸÊBڪ ³^ 83"]öÉ"A'¼¢®æ?Œt:]¡9ù%Šwïój6ÑbsµÿaåóÚ­gFŽâ‰Æ I²š »TYžáýž»›NؾHiYpÍ ¤òt–§‰í{²Í%Vûõ´ÏclÊFįµëÒä Ý$@ý²B¯“ÿèÖ6ÚÖ³}Ó²•œùÈ8tJ¸\î)ðjr¼ºý”+jì* h�Ÿ*YÙ:§Sã±�ZŠÚG%÷ø§ŠÕÃÇê´ çùÔXh{vÐìÕПÊ#”uË 4ëAgzás@Ylqèè³kÃí EíK  iÐÑã…ºŠf«ñåe±9ÇoN=bš¿2½l=o(˜ÔZ6 Í7ÕÏ(€ÜXÁÿ3êÕUWôkY<¬ç™Ñxbƒ¹ËÓuù…¶XÊÈO©ájø¿ (S÷�TÝtÀÆ×€JñÌ~²5$ÀhOL(? sS”ñQ­VGMu) v¬el°£t¼<>ïÍÊÕS@ÉTë�‚HGÊÉEgfåé½Çöó#5V|÷.ëûSwoœ�ÕéäL ¤œqÑgšØòLK³óuN‡Ö–®vµ¯½Õæ4í9xªÚß8÷`îð�Ê·4™½4Mðè™Y¥¸¹Ê°^-ŠŒFݺú}E�çö”ª*M� 3¥Ò·Ý{¨_ÖdpBÈh³9‡ÐÕñ¿ ( ‹mÒ�¿îÁx“õ×~JD•ÄF”ËBåæ[„“Gš–¥šìäp�J¹;c#ƒœÉÍÏ´ïÚ–~LœÊ/18œÎ¡µ9£·Ÿtº\ýŠ-vóÖôÚžñ.Á.ƒN)IN/Ë©r¶mÃŽ£‡ÃEWû‹ZÄû¨ÝáìA' :ï^×Õh"�Üõ_².Ó¼|ý™±þãc‚Ÿ¡ÉöÐ Ã�"O¿Ðô˜tÛ\“Q7«GûØ"ï:|7wyš|0;�§_å‘z hðê" ,×Åñô,åÑŽh¢_ÿ<•h@è@õïýGòòŽçYͽŸOQÎËa2è\Þ+‹­aw¸4I5ÕÛš³Mz�ò«Á uHŒ*[σ¬¯ÙšEÁ�Ö™®^-jOO:p÷Mߟ£ß’ZWãcC%ìPuJVÛV‘åf‚Y»-KÊ>Ud cÕ°ZžZp Ý®%oØyÔÌãÉ¥îþ]â‹�ýº:óRž—ÛYòØ”!A†¬±Ã’Ë5à±)¯Ë4Zí®>tõ Víg(õ†¼¯nÞ�Í?ðbÞ²4£ÝáD»I¨·.¿ç«r×uŠõyóÌr±†Êz9Tt.8r¢°ì¶Ý#¶¤å^Ð].q·ðÍ»«ßÆ�gZ¸pÆ'1>ÔNeožO;UÔ0ΰ˜MºOb£󯻼­VÚ •³&o|¶N¿|ý�@ÊrVöÑsN±;]ÿ9–S’ôá¬ÍFnOÆø@ë]ÿÂ�}ogèO\ѯ¥Ã»çòÜeibùîžË£éê=¢Ð~Ó‡ŽƒÏœÌµ´œ>o[¹rt5¹‘JÝÝ)KoòîˆÃƒ“�šl ÐeÒÏÖôªT¸½¥É û¶SrtÑ(¯±)=t$:ѰXéo" Aªõ#^ú¬Û2$MÌö^÷ìWßqí&¡ÞzèÕ_xt‚rënºº&ÇïE^}Y¯–%Ãû´,ûÑç kúümÒ¾Ãy�”yœ~ôïUÀÊNÞJ‘ðÅ¿ïÓyg@Ú¶ŠíZEY)k¸‹®îµã+³I?{@×xë�W�ù<¸iÂ?þ»B?kñ®ÈâÇŸi»ßªŽqù9hyÝåÒÞ­ŽÎ]ôOPfòÃÜBkǾߨŸ>o«{=·ÿ}ô–Þb¨×ÀáU|þ&TªŸ\Pl‹�½4UÏG)~�}[• Êjºú�ðWTUš�7vhг´*ã«5Û²L6›ƒ;6]/ Á©“€Ò�¥‚ÊLOêZ¶Ç­Ûæn0ï,ó ­ÂåªãyÉ.â`v¾xö½•åÖµo%›ÜGø+úÑŸM%ïoÛ´ŠpÞ9¾kžw –;­¼ýåïê¡£ù1”©üˆþ]™, —±(39Ýjw^ûãÊtÓÿfn*7 ÷Ú+¡È`PÖÓÕù¢Ñß»5À¨>¨ßFA�mÌÐò#¿LŸ¿U~èµEa‹Öìí^Xb�Â�ô÷|H˵{nî AË«v‡k=îý£Ç ;Ný~“ú××+wußçÚámÄÝ×v³†]²\~vÕ?g�iôhŸùC§*ÿ‰ 1eßCÛå}ÎŒÃÏÙ¶g§þ*ÍZ’ª+*±�°Ù]3iøgU:œÐcnv¸\KœNíùÌÃyQ¯M[£~:÷t0ÉYɇoîåè”ÜÄiÐ)6á› 6›kèÊ MÞ'¿\ÒO'Z‘¡¦ãzU™î™:ÕW³ u!e(‹Gô=ó¾ñ Üì%©òÑ“…¡ô~ùý`(²ÔzxßWìÚwÂññ¬Íe¿ÜI'¿È¦Üym—�~�ã”eyMt°9§¡>­¿œ.n¥âå¹…–�¹ËÒtÜsü¤×¼Ãœ�|M§¢¨Ð€Š$qïÑ4QËè5—Ó¶þ52LüûÑI½;„uÓ!&d‚Ýîüˆ2мÐݸÑ)�«ivº´`úãC)¹b±9\ŽäÉ+6�¾øq»ð.óß1®‹¸}\g{P€aŸÓé<@�g_ïew@)IšçyÁ�Ð>ó5í3æØÈÀþrKïh½NQK3ÎÜDä™ÿ®àv•ê�W¶‹ܳùÃfƒÊíq¹\ü9=öÛó=¯'£y+e³¯ ý§e^¾Uûem†üéœ-¢tX)&ÿ2©—Ö:.Œî&lô½¨"~=›Ý1é`v^Ь%»ï½«&ŠîíbK¨ÄÏÍЊjÀ^zÍi�f]�1Ã’“·¦3–Ž£Ét~Zµ×4id§.°ßD«žÐ`ÔY@™8fêu´§gÒÞ·Bhb íö~=¯xUmÚ}T|B?¿¬É¬ðvnè}óÈâÊþ­…?¨oÛ[W–¬Û'î{ùÜßßÇ(˜älœ¿ã’3gMz5âþ Ý:Û"¿^¸³ì÷€;‘Ð"Q©V˨ŽÝ¨Tû•Ì $!¥˜9—´T…[mΠŒ¬S2•³Ìß/Ú-y’Œ3 ÜÐÝÖ½mŒ�Ê»3ø -ê½öRÚî;tŠüê7ö˜”®ŸúýFÉ{Ê9ž‘ä¥�Vóe _OŒ=;4 åKþ[èÀ+J,vA™Lw¯þ-iǨª²W9ûµøþ÷^ß]£€Ý©ªJª*KÏ9Ú$ &5*•–ÝOQ$—“�ð(yû!Úg>¡}ÆÖ4*è…¿ßÖ/>*̬zŸˆ”~OZ4 1ÒɶqD¿VWÆG�¢Ïø:K HçIDAT8LŸ;ÿ�ò¸?¶¢à°-aô2åŠEk3¥¯Ú!yŸ„ éÑ\üõ–ÞΖq¡%´o|]bµ§ÕMExÚ*ÞTbuµ™·b�©´sëÖ6FŒœd1Ýmš¿¬Îa¼¸&½öWÉ 5(É@eY–æÇ•{D·6ÑæÎ)Ñ<ìjºï�„: (÷̽‡çuÏš4þ“V²Ýú(e+Ç ÿ ªŸ�ÝþÜ…¿+ì8â^†÷N§ÌV_ÑÜk¸…ÚVß¶·ŸqóóN±ì�3½)«àΨ¶Š “šˆ¦Q�¢º|:w‹xå“5笧ÀIÜ6¶³¨/x@búA!À¤å±É}9( ó*Ù— vð¢ ëÙ\tLnÜ<6$˜KäÇO»KË~ß'Î7·ŸzðÆž6:@Z(˜äìæEóüÍ·*²òèÕƒ§tNi4ã‡í ï?á¶fÞíÍ*ƒpqó¨θ&ANÊî.¤üŸ´ú�Ãé¼ÃjwªN¯ ¥Q¯ò6q0‰1…ü}>ŸÓ>sX пvß„n�#BMêÔï6 ïLgÿ5}-/:>¡ìÕ¡i�T$ÑïNRX°IP‰WäZÅ©| U6ŠÜ Òlg)þ^Q&ŸÛÛ:ÃMt²ñ6­ÞLËe¢ê®¢€tÜÆ]GLg·i;4E‹� *¤}�3ªˆê÷­A'¸¢_«!”ý àÙ½’¿Os—§ëZÅ…7Õ©êdzo·ÔV›j¨Y~ѳšÛTÒÅýX¾.lÖQ’äê/$%™Î¯¸;¤"ê!Ît<ñöòJß1e¼8ÛñÔý…wÏÂÚRß¶—ñÓW?í³–¤ŠbË…ÇjãáZø‡¾Sr´èœÜÄ�AºT6»Sp9÷­/Ïýíå`’‚'QßÐù*6‡ÊSÏýýö~]c£Íÿû~“éì±ï¸ ¾Ô+`¿~é„£x@×8‡¢È³hÕ?/4'pmò¼¡¿{A˦a�>q[ßÁ×OÑûË.™²¬¢*8xæqüè»àê”ÜÄE/²‡²KïÐk½Ç·{Ú×™4—&ó~Äø dÖ—–»1…Žód·o£2íÿMÝéJÊÍß&�Ý�qç¶µ—8©÷ˆ¾óÚ®ZÏö±.Y’v˲ô&½æÇôšcDñ>çp8'çäY#g/MÕygâ‡÷i)wO(1tKéêlQ¸=&mÃgá!Æ㇥跥+õ�ß·îmc�Tr¢**'’ÞPïùÕP=žÀòmÏRg’G¿ïSo ¹%îàŒÏD½õíÜL ê– ¢©tÆe³W§­uw>*ÅYŸ‡ÿµHìÈ8N%�ÚëÔQß¶—q0ùðk‹ÜƒˆWgx¡ œûz›ÜvH êï.1]È¡ì|1Å \w‹^?Ê¥êk0YÊTÞMåïgë5½;4-xcÆïQ¿o?\¥ÆÄã/k£Ý5¾K^BÓ+USiÕë59+NUÑ6-¢‹E<œJÛV‘>s×ÀA�ÜÔ˰3ㄺ)5[¬X¿_\he©œGÙú‚ƒHn7KåÌ}ô7sýÖYY—PªÀ•Xe'?œ� Ð;(ƒÉ_¼|~�;wq“ ¡(÷tJоýŸE4»²kù“9[¤ªŽÉ'^7SFûªþ­œ�f}¡ªÈ3hõ«Õ”±»ÁfwõY´&£\G~ÍñÃ’í!AúÃ:Eþ¬†³ƒ?™ ºþÝÚÅN¡ïŠî£Ù›Ý+ù„•‚\¹cRTU¹xžïµ5”%…Z„±k7Öæù_½=~kßsÊ¡#úµï~½AL÷j“Ã>œ¹YìÊ8)>~n¤¨ õm{/5˜¬ÈnÊÈòòÅ‚íîëÍcƒ©|ê¾ ñêP³“þ®¥TÖ=ŸúL–òô~ØèÚ&æÎO_%­ÚtÈ9ãÇmMxV�‹=žÛŽ’ì;4¹°e\(O×½IœªæŠª’„Ò:.,àõ¿÷Œ¬#D“03w|ªÖöÖ´�?ÑÅOÜëÔ¨7OîÛ9n$eŒÿ<±§�|.>‰Ê/²Ê š *w¸Ò8³h6é$¡q"HdRy’»ú%Éî µ"Á.§+ ¯À"{²¿ôxdpÑës0Y™ ¥j6ó¥esÊ, ½^­tÇ SLº¢Vîë:Uæ¿§ÆFZ§ÏJÇL>ùt¢�‚gúŽ…È§ÿW$…&�÷>à eY©–¿Ñ“e�>ôOô:ý##ú´œtyŸᇲ dªòH«·dñ¬SâB³Ûp@ÇCõP–S£“Z»Ù¨ã¬äRzGþã9É)ÿšBÖQ ]-}¹ãýNñàSç}iû†Q0y³SÓB;§4‘JËxnf¥Ëokºý¢§ƒÎç&µï”1�º íÕ\§y¥k¨za¤?¢ ý÷fºßn<ñ„ÊC@YÍf.Þ}NòÌ]ÜYÎ`OÞÞÏ}�äö7Þ¸=#gÿþóØå¢&qÛ±ú´½ç &›5 ã†%‹^Nwˆ9x4�Êø9î ‘2m}ÞýGòÝKeñ`ÃÜÐCôîX¥¶ò~‹~Ðߥöo)Sr÷à Wêž R¤í;œ+íÌ0%&„Ù›„•Óã+rC­éyÞ¾ÉQei]T˜¹€ï'ŽöÑ’-ª™gîâÇ=‹û LYÈA´$ÄFr n[ÍÙD>ûâšf:Ò×Vr¨•:�9Q¤+í¸Äerz9 äç;z‘Çóƒ¶R ÎåV¯õü~ðÌC™ââö˜Œºô¹�Ý ˜‡¤Ù,ªßA š—Q#Ê?\óß(N÷š¯N¹z�¼†**ù´xŸtð{Ä�`µM÷éÉê=JûÈ›t9�²ñCc#ƒºÒIwìs÷ ”‹-W^¡EÎ-°Š«ÝÜRàè ýH£W¥ëv Plø;=~ZE�¤Ça£AYÑ¢Yh-g¿‡<×ùÞÃxz/2)è=F'ˆgžü~pÛŽé¢pæ‘Þ§ÿÒ÷x-üªh{¸{,-(ë1”Õ웟ËOBgegÞîßE4 7‹ÇÞ\Zn=KjwWSêÛö~8sÓ9Áä=×w�ÜÜ«Ü:ï@�ïÜp@¬ å .}�S¹¡d%ÏÇ“…y‘n‡`ÖÝÐ>1ê2ZÚˆÓmšùwƒÇMä‚»}´ðÆ÷Õ5$�çyîuˆÛÍÑÅRQ=mvg€w;6:¸rvÐAþCk_êÉܼèYª„žƒˆZ "<¯ÇCèÔÚ„öžÀþQ‹<�åžÅ=€8…KC(sÜŽ–x:Ñ�ž§-9ð_I÷™[™lœ§ |³¸DµýY_Œ¿mÔ ”Õ臕{Ä–´3‰žÝà‰Û*\q ªG»X1ôÎ/Ê­çò2g9ˆ«nœ�ô.uûûöRéUÌYV>^áiÎ&ÏÆ%ìk'¹¶}Ïq±q÷Q±iW¶{˜$ï9­Ï‡+5(Q ëÕÂÝÁ§±ð¤_ö,õÔ;ÑÏ·*ÊgC8Âg?ÑßµVÔ2­K±Å®÷nƒË#$˜ôŠ]‘åt ‚çÄà[Ð! ¬F<”„·ñ—µ—¢)•m·}§èx݇åÖsy™3£‡$‰êtvvÒß·wúüòm7¹Ò‹÷—ŠJ~î…‡wa\‚LÛŸ#Ò¨Džºÿ¤8r¼P„Ý�h(-—÷mé(¡Þ0óœÅ:Uî�KÙi­t˜ª/RùÚ©S¤Þä=T‹¬wµÙí]3åö:=wèIiîPu ¯Ø.ê9”ÕdþŠôr¥Xncç=íTeéuŠXýÙdÑï–òÕ�ÇÞ\â.‘qÏëêpvvÒß·—3“œ¡,¥(²¸ëÚ®¢:p[¶¾�š¹¨ÿ8ûHã̼"kËY‹wüû³uîõÄ=|S/ùºËÛôWUåNƒ³¦;ðÀÒv§kŒÅæŠ]·-K)-yóìAÉ-"¬ôýáì$2”Pï5ÈÙiê·¿”Ïöñü½UÅÎ/S'ž³~Ê?æŸÓ»ªêÛöÎøa[¹ëwS0Ù%%ZœÇ<�¢ìÜ£…½tæ"îyûýâÝÒ¶ôãæb‹ãVQ…¶i—Êé×Û&¬ßyÄȽKqÏçØÈ‹^UUÓüÉu e àrêØ¡ÉÂ<|ͬ7®=gýø¿Ì¼¤ÞÈál·#,Uß¶·]«ÈjËNBÃÄ3â˜�êW´_Õ¿µÆÙIÆøÿoæ&ùбü�b‹ýqÊ N5„ž{B¡ÅúBVvAø ¶—M¯×¾u¤Ø-¾ØdÐ¥ÒÕÅ @@YMž½g »lÌË«Õ�ÛíM{ášsÖ�¸çKqöœÉ—‚‡6òÆ[ªCmmï W¶&#ZkÀEýhÐ)ë.ëÓÂV:î"ã!®ÞûfƒJ�^\‰Íñ¶Ó©=)ªw¡ç|¥Äbÿ('·$îã9[$ï™SFLrµjV@ånî¿U4(« V;ã¥Ñî¥*Óú�·Aü×#çNåÊmK§p»¿¬É,7.#�<Ðqu©oÛ wºÑë•ÿ™ ‡'�ìàôž‰‡¸zü­¥Êª�Ã-6û3V»ã{Ø\øˆžãÎb«c©ËåúÛî}'ƒþïÃU’÷œÍc¨pÍàÄ“QYBW¿ Êz€{KóìgãÞÕ§ò-âRðô�ÞþtEõd'½Õ·í…†‹‚Ê™F½òZldà©ÛÇuvq¹¹O«xÿ?–Þûv£ñxNñ8*�ÿ^d±óŒ9.á%8ˆBˇV»ó ¦‰rr‹ÛSY]ºÿåŸÝÙÐRÜnòžë»„‡š÷*²<Õ_æ6¨¨Ö<®ã±SÅçL{ØgÒ4ñÕ+cy¯‹>ç³ì�ýe×k2ÛWß¶. ܦrºsrô#MìôÆŒßnKYŠË_¸j¯|óÈ—õjq¥.2ðŠÜ|Ëÿlg¦Ñ fèé˜Ñ +öÜ=ˆ–H‡ÃÙÄéÒ"].E—\’�Ž+?¯É_ü¸ýœÁóyÌÖGoé]ß$8[•¥·=ƒ~4(ëž�æXN‘»\çíÆ¿Ï/?8ä‚=µ¿Z¸C|4«üìj5�í«oÛ Ú›½¢R–ðÞˆSÐ{ßl0,õ:Yáá|þùñj÷’Ò"BÚ³y�ÀtIˆ îl…Ååçfv¸4‘“W"ÉÛ)Ó¹ì÷}îiA+2r`¢xð†¹ñ1ÁYª*?OÁäw �A@YÏð\Ù%‡X¾~¹õO¾³\lÛs\�’›ˆ©¯@Ó9�IDATO_å?²¶Ô·í­ )c¦V¸>mÞ½øžV# *ãèâÏN§vcN~I•»¾^¸S:_†ñRqsމWupëÕÜf2ª'dIú”Vÿ»¦Q¨K8PU >”lsj¶xâíå"ãPå„ÑâãçFФ„pQÛêÛöÖ6”µËÓ«û!—¦�q8\a©ûs\‹Öd¿_´[ºÔa®x~÷k‡·ÑwO°RfR&ù”‘œC7½I�äNÐÀá@U�úP²§ŠÅóüê^§2¦½xM�N1Xß¶·6! ¬Xò •·Ó2‚n¨«·Ù�ö¼B«óPv¾œ™•'Ñÿõ|_�*‹ ʪœ¡AG“0³3<Ä$éuªS’ÅQÊFî »­¢e&fÀ€Æª Ô§€²•íÄç?nì8RáíÜþ�;µpOiPß¶·6  ¬{ž¬åU´ô¤%�îÁÍ=»Ï]x0UN_r7nžŠs�í93tÊi ®ìßÚ½|·h—Ø{ð”È/´‰¼"+]ZÅßoëëw�Y}Û^h<åi”¨.ÊæúËë×Ð:õm{à\˜)|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€|‚€²bΊV:.P.°o9€ŸC@Y±­ÌÈÊ5áûÖ àçPV@bwEëWn8 jÂùö­óí‹þeE4ñ[E«¿X°eo¨v¼Oñ¾U¡óì‹þe\²s~Eë/ÏMýUT'Þ§xߪÈùöE¢8GNꂬȔQCé¿-ξmgÆ ‘}²H ê– dYUřɼ·R|·hW…·ÓÞµ"mÞý/ ?§ ¨�&I¯ M\Ñm¬Ú|PÜtu1¨{‚hÕ,T¨*’½pqDrn3Éeîóe&™{¨�b»€”1ïOÕ4q·¨e’$>H�{ï= @@y)£ß_® 1XÔ.u§Î»wˆ¨'P§½ˆ§6Žð ð¾Æûœ¨G�¡¬$”¿¡¦¡Ì õ2”•ä>ÐKÒHd+¡º¹÷)Ú·L@}… e$�y··ìR®¡wo€&DZ)0TÏÍ}Â=Ž&~ãq&Óç>°NÔc(À'(y€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OP€OþÿÿhR>dIDAT²–ª”[ *IEND®B`‚DataLab-Platform-SigimaX-0182bf4/doc/images/SigimaX-Banner.svg000066400000000000000000000030621524425144300237570ustar00rootroot00000000000000 SigimaX DataLab-Platform-SigimaX-0182bf4/doc/images/logos/000077500000000000000000000000001524425144300216145ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/images/logos/cea.svg000066400000000000000000000035741524425144300230760ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/doc/images/logos/codra.svg000066400000000000000000000104431524425144300234270ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/doc/images/logos/nlnet.svg000066400000000000000000000110661524425144300234610ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/doc/index.rst000066400000000000000000000066271524425144300211000ustar00rootroot00000000000000SigimaX ======= **SigimaX** is an open-source Python framework for building Qt-based scientific desktop applications. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features. .. figure:: _static/DataLab-Banner.svg :align: center :width: 300 px :class: dark-light no-scaled-link Developed and maintained by the DataLab Platform Developers, **SigimaX** powers the GUI layer of `DataLab `_. .. only:: html and not latex .. grid:: 2 2 4 4 :gutter: 1 2 3 4 .. grid-item-card:: :octicon:`rocket;1em;sd-text-info` User Guide :link: user_guide/index :link-type: doc Installation, overview, and features .. grid-item-card:: :octicon:`code;1em;sd-text-info` Examples :link: ../auto_examples/index :link-type: doc Gallery of examples .. grid-item-card:: :octicon:`book;1em;sd-text-info` API :link: api/index :link-type: doc Reference documentation .. grid-item-card:: :octicon:`gear;1em;sd-text-info` Contributing :link: contributing/index :link-type: doc Getting involved in the project Quick Start ----------- Build a scientific desktop application in three steps: .. code-block:: python from sigimax.app import run from sigimax.config import CONF as Conf, SigimaXOptions, _ from sigimax.mainwindow import SGMXMainWindow from sigimax.widgets.plotdock import DockablePlotWidget from sigimax.config import TypedOptionField from plotpy.constants import PlotType # 1. Custom configuration class MyAppOptions(SigimaXOptions): def __init__(self): super().__init__() self.app_name.set("MyApp") # 2. Custom main window class MyAppMainWindow(SGMXMainWindow): def __init__(self, console=None, hide_on_close=False): Conf.app_name.set("MyApp") super().__init__(console=console, hide_on_close=hide_on_close) dock_widget = DockablePlotWidget(self, PlotType.CURVE) dock, loc = dock_widget.create_dockwidget(_("Plot")) self.addDockWidget(loc, dock) # 3. Launch run(window_class=MyAppMainWindow) SigimaX has been funded by the following stakeholders: .. list-table:: :header-rows: 0 * - |cea_logo| - `CEA `_, the French Alternative Energies and Atomic Energy Commission, is the major investor in DataLab, and is the main contributor to the project. * - |codra_logo| - `CODRA`_, a software engineering and editor firm, has supported DataLab open-source journey since its inception (see `here `_). .. |cea_logo| image:: images/logos/cea.svg :width: 64px :height: 64px :target: https://www.cea.fr :class: dark-light no-scaled-link .. |codra_logo| image:: images/logos/codra.svg :width: 64px :height: 64px :target: https://codra.net :class: dark-light no-scaled-link .. toctree:: :maxdepth: 2 :caption: Contents :hidden: user_guide/index auto_examples/index api/index contributing/index requirements release_notes/index .. _DataLab: https://www.datalab-platform.com .. _CODRA: https://codra.net/ DataLab-Platform-SigimaX-0182bf4/doc/locale/000077500000000000000000000000001524425144300204635ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/000077500000000000000000000000001524425144300210725ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/000077500000000000000000000000001524425144300226575ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/api/000077500000000000000000000000001524425144300234305ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/api/index.po000066400000000000000000000076161524425144300251110ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Public modules:" msgstr "Modules publics :" msgid "API" msgstr "API" msgid "The public Application Programming Interface (API) of SigimaX provides the building blocks for creating scientific desktop applications." msgstr "L'interface de programmation d'application (API) publique de SigimaX fournit les briques de base pour créer des applications de bureau scientifiques." msgid "Module" msgstr "Module" msgid "Purpose" msgstr "Objectif" msgid ":mod:`sigimax.app`" msgstr ":mod:`sigimax.app`" msgid "Application launcher — ``create()`` and ``run()`` entry points for starting a SigimaX-based application with splash screen support." msgstr "Lanceur d'application — points d'entrée ``create()`` et ``run()`` pour démarrer une application basée sur SigimaX avec prise en charge de l'écran de démarrage." msgid ":mod:`sigimax.config`" msgstr ":mod:`sigimax.config`" msgid "Configuration system — ``SigimaXOptions`` singleton (``CONF``), typed option fields (``EnumOptionField``, ``TupleOptionField``, ``FontOptionField``), and translation function ``_()``." msgstr "Système de configuration — singleton ``SigimaXOptions`` (``CONF``), champs d'options typés (``EnumOptionField``, ``TupleOptionField``, ``FontOptionField``), et fonction de traduction ``_()``." msgid ":mod:`sigimax.env`" msgstr ":mod:`sigimax.env`" msgid "Runtime environment — ``SGMXExecEnv`` singleton (``execenv``) for controlling unattended mode, verbosity, demo mode, and screenshots." msgstr "Environnement d'exécution — singleton ``SGMXExecEnv`` (``execenv``) pour contrôler le mode non interactif, la verbosité, le mode démo et les captures d'écran." msgid ":mod:`sigimax.mainwindow`" msgstr ":mod:`sigimax.mainwindow`" msgid "Generic main window — ``SGMXMainWindow`` with customizable menus, toolbars, console, HDF5 workspace, and status bar." msgstr "Fenêtre principale générique — ``SGMXMainWindow`` avec menus, barres d'outils, console, espace de travail HDF5 et barre d'état personnalisables." msgid ":mod:`sigimax.widgets`" msgstr ":mod:`sigimax.widgets`" msgid "Reusable Qt widgets — scientific dialogs (fitting, peak detection, baseline, cursor, delta-X), HDF5 browser, log viewer, wizard, splash screen, status bar, and dockable plot widgets." msgstr "Widgets Qt réutilisables — boîtes de dialogue scientifiques (ajustement, détection de pics, ligne de base, curseur, delta-X), navigateur HDF5, visionneuse de journaux, assistant, écran de démarrage, barre d'état et widgets graphiques ancrables." msgid ":mod:`sigimax.h5`" msgstr ":mod:`sigimax.h5`" msgid "HDF5 I/O — import, read, write, and browse HDF5 files with node factory and data extraction utilities." msgstr "E/S HDF5 — import, lecture, écriture et navigation dans les fichiers HDF5 avec utilitaires de fabrique de nÅ“uds et d'extraction de données." msgid ":mod:`sigimax.adapters_plotpy`" msgstr ":mod:`sigimax.adapters_plotpy`" msgid "PlotPy adapters — converters between Sigima objects (``SignalObj``, ``ImageObj``, ROIs) and PlotPy plot/annotation items." msgstr "Adaptateurs PlotPy — convertisseurs entre les objets Sigima (``SignalObj``, ``ImageObj``, ROI) et les éléments de tracé/annotation PlotPy." msgid ":mod:`sigimax.utils`" msgstr ":mod:`sigimax.utils`" msgid "Utilities — Qt helpers (log management, signal blocking, progress bars), configuration directory resolution, and callback workers." msgstr "Utilitaires — aides Qt (gestion des journaux, blocage de signaux, barres de progression), résolution du répertoire de configuration et workers de rappel." DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/000077500000000000000000000000001524425144300255255ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features/000077500000000000000000000000001524425144300273435ustar00rootroot00000000000000adapters_plotpy_factory.po000066400000000000000000000142611524425144300345710ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." msgid "PlotPy Adapter Factory" msgstr "Fabrique d'adaptateurs PlotPy" msgid "SigimaX converts Sigima objects (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`, ROIs) to/from PlotPy plot items through a small set of adapter classes. Which adapter class is used for a given object is resolved by :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` — a single, overridable indirection point." msgstr "SigimaX convertit les objets Sigima (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`, ROI) vers/depuis des items PlotPy via un petit ensemble de classes d'adaptateurs. La classe d'adaptateur utilisée pour un objet donné est résolue par :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` — un unique point d'indirection surchargeable." msgid "This is useful when a derived application:" msgstr "Ceci est utile lorsqu'une application dérivée :" msgid "adds a **new object type** that needs its own PlotPy rendering, or" msgstr "ajoute un **nouveau type d'objet** qui nécessite son propre rendu PlotPy, ou" msgid "wants to **substitute** one of SigimaX's built-in adapters (e.g. to draw images with a custom colormap policy) without touching SigimaX itself." msgstr "souhaite **remplacer** l'un des adaptateurs intégrés de SigimaX (par exemple pour dessiner des images avec une politique de palette de couleurs personnalisée) sans toucher à SigimaX lui-même." msgid "Importing necessary modules" msgstr "Import des modules nécessaires" msgid "Default resolution" msgstr "Résolution par défaut" msgid ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` asks the *currently active* factory (:func:`~sigimax.adapters_plotpy.factories.get_adapter_factory`) for the right adapter class, then instantiates it. Out of the box, this is a :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` that dispatches on the Sigima object type." msgstr ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` demande à la factory *actuellement active* (:func:`~sigimax.adapters_plotpy.factories.get_adapter_factory`) la bonne classe d'adaptateur, puis l'instancie. Par défaut, il s'agit d'une :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` qui répartit selon le type d'objet Sigima." msgid "Overriding the factory" msgstr "Surcharger la factory" msgid "Subclass :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` and override :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class`, delegating to ``super()`` for the types you don't need to change. Install it with :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` so that every SigimaX component (dock widgets, HDF5 browser preview, ROI editing) picks it up transparently." msgstr "Sous-classez :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` et surchargez :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class`, en déléguant à ``super()`` pour les types que vous n'avez pas besoin de modifier. Installez-la avec :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` afin que chaque composant SigimaX (widgets ancrables, aperçu du navigateur HDF5, édition de ROI) l'utilise de façon transparente." msgid "Summary" msgstr "Résumé" msgid ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` is the single entry point application code should use to go from a Sigima object to a PlotPy adapter" msgstr ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` est l'unique point d'entrée que le code applicatif doit utiliser pour passer d'un objet Sigima à un adaptateur PlotPy" msgid ":func:`~sigimax.adapters_plotpy.factories.get_adapter_factory` / :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` let a derived application install its own factory once, globally" msgstr ":func:`~sigimax.adapters_plotpy.factories.get_adapter_factory` / :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` permettent à une application dérivée d'installer sa propre factory une fois, globalement" msgid "Override :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class` for object-to-item resolution and ``get_adapter_class_for_plot_item()`` for the reverse (item-to-ROI) direction" msgstr "Surchargez :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class` pour la résolution objet-vers-item et ``get_adapter_class_for_plot_item()`` pour le sens inverse (item-vers-ROI)" msgid "Call :func:`~sigimax.adapters_plotpy.factories.reset_adapter_factory` to restore the SigimaX base factory (mostly useful in tests)" msgstr "Appelez :func:`~sigimax.adapters_plotpy.factories.reset_adapter_factory` pour restaurer la factory de base de SigimaX (surtout utile dans les tests)" msgid ":download:`Download Jupyter notebook: adapters_plotpy_factory.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : adapters_plotpy_factory.ipynb `" msgid ":download:`Download Python source code: adapters_plotpy_factory.py `" msgstr ":download:`Télécharger le code source Python : adapters_plotpy_factory.py `" msgid ":download:`Download zipped: adapters_plotpy_factory.zip `" msgstr ":download:`Télécharger l'archive : adapters_plotpy_factory.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features/configuration.po000066400000000000000000000150401524425144300325520ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." msgid "Configuration System" msgstr "Système de configuration" msgid "This example demonstrates SigimaX's configuration system — typed option fields with ``get()``/``set()``/``context()`` API, JSON persistence, and validation." msgstr "Cet exemple illustre le système de configuration de SigimaX — des champs d'options typés avec l'API ``get()``/``set()``/``context()``, la persistance JSON et la validation." msgid "The configuration system is the backbone of any SigimaX-based application. It provides:" msgstr "Le système de configuration est l'épine dorsale de toute application basée sur SigimaX. Il fournit :" msgid "**Type safety**: Options are validated on set" msgstr "**Sûreté de typage** : les options sont validées à l'affectation" msgid "**Context managers**: Temporary overrides that auto-restore" msgstr "**Gestionnaires de contexte** : surcharges temporaires avec restauration automatique" msgid "**Serialization**: JSON round-trip for persistence" msgstr "**Sérialisation** : aller-retour JSON pour la persistance" msgid "**Enum constraints**: Options restricted to specific choices" msgstr "**Contraintes d'énumération** : options restreintes à des choix spécifiques" msgid "Importing necessary modules" msgstr "Import des modules nécessaires" msgid "Creating a custom configuration" msgstr "Création d'une configuration personnalisée" msgid "Subclass :class:`~sigimax.config.SigimaXOptions` and add typed fields." msgstr "Sous-classer :class:`~sigimax.config.SigimaXOptions` et ajouter des champs typés." msgid "Basic get/set operations" msgstr "Opérations get/set de base" msgid "Context manager for temporary overrides" msgstr "Gestionnaire de contexte pour surcharges temporaires" msgid "The ``context()`` method temporarily overrides an option and automatically restores the previous value when leaving the block." msgstr "La méthode ``context()`` surcharge temporairement une option et restaure automatiquement la valeur précédente en sortant du bloc." msgid "Enum validation" msgstr "Validation des énumérations" msgid "``EnumOptionField`` rejects values not in the allowed choices." msgstr "``EnumOptionField`` rejette les valeurs qui ne font pas partie des choix autorisés." msgid "Serialization round-trip" msgstr "Aller-retour de sérialisation" msgid "Options can be serialized to a dictionary (and from there to JSON)." msgstr "Les options peuvent être sérialisées en dictionnaire (puis en JSON)." msgid "Persisting options to a JSON file" msgstr "Persister les options dans un fichier JSON" msgid ":meth:`~sigimax.config.SigimaXOptions.save`/ :meth:`~sigimax.config.SigimaXOptions.load` go one step further than ``to_dict()``/``from_dict()``: they read/write an actual ``options.json`` file. Called without arguments, they resolve a per-application directory under the user's config directory (the same one used by the legacy INI-based system, via :func:`guidata.configtools`). Here we pass an explicit path (a temporary directory) to keep the example self-contained." msgstr ":meth:`~sigimax.config.SigimaXOptions.save`/ :meth:`~sigimax.config.SigimaXOptions.load` vont plus loin que ``to_dict()``/``from_dict()`` : elles lisent/écrivent un véritable fichier ``options.json``. Appelées sans argument, elles résolvent un répertoire propre à l'application sous le répertoire de configuration de l'utilisateur (le même que celui utilisé par l'ancien système basé sur INI, via :func:`guidata.configtools`). Ici, nous passons un chemin explicite (un répertoire temporaire) pour garder l'exemple autonome." msgid "Reset to defaults" msgstr "Réinitialisation aux valeurs par défaut" msgid "Listing all options" msgstr "Lister toutes les options" msgid "``list_options()`` returns the names of all registered option fields." msgstr "``list_options()`` retourne les noms de tous les champs d'options enregistrés." msgid "Summary" msgstr "Résumé" msgid "SigimaX's configuration system provides:" msgstr "Le système de configuration de SigimaX fournit :" msgid "**Typed fields**: ``TypedOptionField`` for int/float/str/bool, ``EnumOptionField`` for constrained choices" msgstr "**Champs typés** : ``TypedOptionField`` pour int/float/str/bool, ``EnumOptionField`` pour les choix contraints" msgid "**Context managers**: ``option.context(value)`` for scoped overrides" msgstr "**Gestionnaires de contexte** : ``option.context(value)`` pour des surcharges avec portée limitée" msgid "**Serialization**: ``to_dict()`` / ``from_dict()`` for in-memory JSON round-trips" msgstr "**Sérialisation** : ``to_dict()`` / ``from_dict()`` pour des allers-retours JSON en mémoire" msgid "**File persistence**: ``save()`` / ``load()`` for JSON files, defaulting to a per-application directory under the user's config directory" msgstr "**Persistance fichier** : ``save()`` / ``load()`` pour des fichiers JSON, avec par défaut un répertoire propre à l'application sous le répertoire de configuration de l'utilisateur" msgid "**Validation**: Type checking and enum constraint enforcement" msgstr "**Validation** : vérification de type et application des contraintes d'énumération" msgid "**Reset**: ``reset_to_defaults()`` to restore initial values" msgstr "**Réinitialisation** : ``reset_to_defaults()`` pour restaurer les valeurs initiales" msgid ":download:`Download Jupyter notebook: configuration.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : configuration.ipynb `" msgid ":download:`Download Python source code: configuration.py `" msgstr ":download:`Télécharger le code source Python : configuration.py `" msgid ":download:`Download zipped: configuration.zip `" msgstr ":download:`Télécharger l'archive : configuration.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features/h5_workspace.po000066400000000000000000000167061524425144300323070ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." msgid "HDF5 Workspace Save/Load" msgstr "Sauvegarde et chargement de l'espace de travail HDF5" msgid "SigimaX provides the plumbing for an HDF5-backed workspace (menu actions, file dialogs, browser) but does **not** know what a derived application's data model looks like. This example shows how to plug your own model in by overriding three extension points on :class:`~sigimax.mainwindow.SGMXMainWindow`:" msgstr "SigimaX fournit la plomberie d'un espace de travail basé sur HDF5 (actions de menu, boîtes de dialogue de fichiers, navigateur) mais ne connaît **pas** à quoi ressemble le modèle de données d'une application dérivée. Cet exemple montre comment y brancher votre propre modèle en surchargeant trois points d'extension de :class:`~sigimax.mainwindow.SGMXMainWindow` :" msgid ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` — serialize your objects when the user chooses *File > Save*" msgstr ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` — sérialise vos objets lorsque l'utilisateur choisit *Fichier > Enregistrer*" msgid "``load_h5_workspace`` (a convention, not a base-class method) — the counterpart used to reload a workspace saved by your own application" msgstr "``load_h5_workspace`` (une convention, pas une méthode de la classe de base) — le pendant utilisé pour recharger un espace de travail enregistré par votre propre application" msgid ":meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` — import data coming from a *generic* (non-SigimaX) HDF5 file, as offered by *File > Browse HDF5 file*" msgstr ":meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` — importe des données provenant d'un fichier HDF5 *générique* (non-SigimaX), comme proposé par *Fichier > Parcourir un fichier HDF5*" msgid "See :doc:`../../user_guide/hdf5_workspace` for the full reference and ``sigimax/tests/hdf5/test_h5_derived_app.py`` for the complete test this example is derived from." msgstr "Voir :doc:`../../user_guide/hdf5_workspace` pour la référence complète et ``sigimax/tests/hdf5/test_h5_derived_app.py`` pour le test complet dont cet exemple est issu." msgid "Importing necessary modules" msgstr "Import des modules nécessaires" msgid "Step 1: A minimal data model" msgstr "Étape 1 : Un modèle de données minimal" msgid "Any object store works, as long as it can serialize/deserialize itself using :class:`guidata.io.HDF5Writer`/:class:`guidata.io.HDF5Reader`. Here we use a plain list of :class:`~sigima.objects.SignalObj`/ :class:`~sigima.objects.ImageObj`, grouped under two HDF5 groups." msgstr "N'importe quel magasin d'objets convient, tant qu'il peut se sérialiser/désérialiser lui-même avec :class:`guidata.io.HDF5Writer`/:class:`guidata.io.HDF5Reader`. Ici, nous utilisons une simple liste de :class:`~sigima.objects.SignalObj`/ :class:`~sigima.objects.ImageObj`, regroupée sous deux groupes HDF5." msgid "Step 2: Override the workspace save/load hooks" msgstr "Étape 2 : Surcharger les points d'entrée de sauvegarde/chargement de l'espace de travail" msgid "``save_h5_workspace`` is the only method the base class calls (from *File > Save*, wired to :meth:`~sigimax.mainwindow.SGMXMainWindow.save_to_h5_file`). ``load_h5_workspace`` is a symmetrical helper of our own — SigimaX does not impose a name or signature for \"load one of *our own* workspace files\" since it depends entirely on the data model." msgstr "``save_h5_workspace`` est la seule méthode que la classe de base appelle (depuis *Fichier > Enregistrer*, reliée à :meth:`~sigimax.mainwindow.SGMXMainWindow.save_to_h5_file`). ``load_h5_workspace`` est un helper symétrique qui nous est propre — SigimaX n'impose ni nom ni signature pour « charger un de *nos propres* fichiers d'espace de travail », car cela dépend entièrement du modèle de données." msgid "Step 3: Round-trip" msgstr "Étape 3 : Aller-retour" msgid "``save_to_h5_file(path)``/``save_h5_workspace(path)`` and ``load_h5_workspace(path)`` accept an explicit path, so they never open a file dialog — this is what makes them usable both interactively (*File* menu) and headlessly (macros, tests, this example)." msgstr "``save_to_h5_file(path)``/``save_h5_workspace(path)`` et ``load_h5_workspace(path)`` acceptent un chemin explicite, si bien qu'elles n'ouvrent jamais de boîte de dialogue — c'est ce qui les rend utilisables à la fois interactivement (menu *Fichier*) et sans interface (macros, tests, cet exemple)." msgid "Summary" msgstr "Résumé" msgid ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` is the single contract point SigimaX relies on for *File > Save* — the base implementation is a documented no-op, override it in your window class" msgstr ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` est l'unique point de contrat sur lequel SigimaX s'appuie pour *Fichier > Enregistrer* — l'implémentation de base est un no-op documenté, à surcharger dans votre classe de fenêtre" msgid "Add a symmetrical ``load_*`` method for reopening your own files; there is no base-class hook to override because the data model is entirely downstream" msgstr "Ajoutez une méthode symétrique ``load_*`` pour rouvrir vos propres fichiers ; il n'existe pas de point d'extension de la classe de base à surcharger car le modèle de données est entièrement en aval" msgid "Override :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` to support importing *generic* (non-SigimaX) HDF5 files through *File > Browse HDF5 file*" msgstr "Surchargez :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` pour prendre en charge l'import de fichiers HDF5 *génériques* (non-SigimaX) via *Fichier > Parcourir un fichier HDF5*" msgid "To add a new node type recognized by the generic HDF5 browser itself (rather than importing raw datasets), subclass :class:`~sigimax.h5.common.BaseNode` and register it with ``sigimax.h5.common.NODE_FACTORY.register(MyNode)``" msgstr "Pour ajouter un nouveau type de nÅ“ud reconnu par le navigateur HDF5 générique lui-même (plutôt que d'importer des jeux de données bruts), sous-classez :class:`~sigimax.h5.common.BaseNode` et enregistrez-le avec ``sigimax.h5.common.NODE_FACTORY.register(MyNode)``" msgid ":download:`Download Jupyter notebook: h5_workspace.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : h5_workspace.ipynb `" msgid ":download:`Download Python source code: h5_workspace.py `" msgstr ":download:`Télécharger le code source Python : h5_workspace.py `" msgid ":download:`Download zipped: h5_workspace.zip `" msgstr ":download:`Télécharger l'archive : h5_workspace.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features/index.po000066400000000000000000000023761524425144300310220ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Various Features" msgstr "Fonctionnalités diverses" msgid ":ref:`sphx_glr_auto_examples_features_configuration.py`" msgstr ":ref:`sphx_glr_auto_examples_features_configuration.py`" msgid ":ref:`sphx_glr_auto_examples_features_plot_widget.py`" msgstr ":ref:`sphx_glr_auto_examples_features_plot_widget.py`" msgid ":ref:`sphx_glr_auto_examples_features_h5_workspace.py`" msgstr ":ref:`sphx_glr_auto_examples_features_h5_workspace.py`" msgid ":ref:`sphx_glr_auto_examples_features_mainwindow_customization.py`" msgstr ":ref:`sphx_glr_auto_examples_features_mainwindow_customization.py`" msgid ":ref:`sphx_glr_auto_examples_features_adapters_plotpy_factory.py`" msgstr ":ref:`sphx_glr_auto_examples_features_adapters_plotpy_factory.py`" msgid ":ref:`sphx_glr_auto_examples_features_remote_control_widgets.py`" msgstr ":ref:`sphx_glr_auto_examples_features_remote_control_widgets.py`" mainwindow_customization.po000066400000000000000000000107401524425144300347720ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." msgid "Main Window Customization" msgstr "Personnalisation de la fenêtre principale" msgid ":class:`~sigimax.mainwindow.SGMXMainWindow` is designed to be subclassed, not configured. This example walks through the four extension points a derived application typically overrides, in isolation:" msgstr ":class:`~sigimax.mainwindow.SGMXMainWindow` est conçue pour être sous-classée, pas configurée. Cet exemple parcourt, isolément, les quatre points d'extension qu'une application dérivée surcharge généralement :" msgid "**Docks** — :meth:`~sigimax.mainwindow.SGMXMainWindow._setup_docks`" msgstr "**Docks** — :meth:`~sigimax.mainwindow.SGMXMainWindow._setup_docks`" msgid "**Menu layout** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_menubar_layout`" msgstr "**Disposition des menus** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_menubar_layout`" msgid "**Actions** — populating menus (custom and standard) with :func:`guidata.qthelpers.create_action`/:func:`guidata.qthelpers.add_actions`" msgstr "**Actions** — remplir les menus (personnalisés et standards) avec :func:`guidata.qthelpers.create_action`/:func:`guidata.qthelpers.add_actions`" msgid "**Status bar** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_extra_status_widgets`" msgstr "**Barre d'état** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_extra_status_widgets`" msgid "See :doc:`../use_cases/full_app` for a complete application built the same way." msgstr "Voir :doc:`../use_cases/full_app` pour une application complète construite de la même manière." msgid "Importing necessary modules" msgstr "Import des modules nécessaires" msgid "Custom main window" msgstr "Fenêtre principale personnalisée" msgid "Each extension point below is independent: override only the ones your application needs." msgstr "Chaque point d'extension ci-dessous est indépendant : ne surchargez que ceux dont votre application a besoin." msgid "Instantiating the window" msgstr "Instanciation de la fenêtre" msgid "Summary" msgstr "Résumé" msgid "``_setup_docks`` / ``_get_menubar_layout`` / ``_get_extra_status_widgets`` return declarative descriptions consumed by the base class — override them instead of poking at Qt internals" msgstr "``_setup_docks`` / ``_get_menubar_layout`` / ``_get_extra_status_widgets`` renvoient des descriptions déclaratives consommées par la classe de base — surchargez-les plutôt que de manipuler les rouages internes de Qt" msgid "``_post_setup`` is where menus (custom or standard) get their actions, once everything else is guaranteed to exist" msgstr "``_post_setup`` est l'endroit où les menus (personnalisés ou standards) reçoivent leurs actions, une fois que tout le reste est garanti d'exister" msgid "Standard menus (``file_menu``, ``view_menu``, ``help_menu``) remain available for derived applications to extend, not just replace" msgstr "Les menus standards (``file_menu``, ``view_menu``, ``help_menu``) restent disponibles pour que les applications dérivées les étendent, et pas seulement les remplacent" msgid ":download:`Download Jupyter notebook: mainwindow_customization.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : mainwindow_customization.ipynb `" msgid ":download:`Download Python source code: mainwindow_customization.py `" msgstr ":download:`Télécharger le code source Python : mainwindow_customization.py `" msgid ":download:`Download zipped: mainwindow_customization.zip `" msgstr ":download:`Télécharger l'archive : mainwindow_customization.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features/plot_widget.po000066400000000000000000000073171524425144300322340ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." msgid "Dockable Plot Widget" msgstr "Widget de graphique ancrable" msgid "This example demonstrates how to use the :class:`~sigimax.widgets.plotdock.DockablePlotWidget` to embed interactive PlotPy curve and image plots in dock widgets." msgstr "Cet exemple montre comment utiliser le :class:`~sigimax.widgets.plotdock.DockablePlotWidget` pour intégrer des graphiques de courbes et d'images PlotPy interactifs dans des widgets ancrables." msgid "The ``DockablePlotWidget`` is a key building block for SigimaX-based applications, providing:" msgstr "Le ``DockablePlotWidget`` est un élément clé pour les applications basées sur SigimaX, fournissant :" msgid "Embedding of PlotPy ``CurvePlot`` or ``ImagePlot`` in a Qt dock widget" msgstr "Intégration de ``CurvePlot`` ou ``ImagePlot`` de PlotPy dans un widget dock Qt" msgid "Configurable dock location (left, right, top, bottom)" msgstr "Emplacement d'ancrage configurable (gauche, droite, haut, bas)" msgid "Optional watermark image" msgstr "Image de filigrane optionnelle" msgid "Automatic integration with the main window's dock system" msgstr "Intégration automatique avec le système d'ancrage de la fenêtre principale" msgid "Importing necessary modules" msgstr "Import des modules nécessaires" msgid "Creating a curve plot widget" msgstr "Création d'un widget de graphique de courbes" msgid "The simplest usage: create a ``DockablePlotWidget`` with ``PlotType.CURVE`` and add some curves using PlotPy's builder." msgstr "L'utilisation la plus simple : créer un ``DockablePlotWidget`` avec ``PlotType.CURVE`` et ajouter des courbes en utilisant le builder de PlotPy." msgid "Summary" msgstr "Résumé" msgid "The ``DockablePlotWidget`` wraps PlotPy's interactive plots into dock widgets that integrate seamlessly with ``SGMXMainWindow`` and any ``QMainWindow``." msgstr "Le ``DockablePlotWidget`` encapsule les graphiques interactifs de PlotPy dans des widgets ancrables qui s'intègrent de manière transparente avec ``SGMXMainWindow`` et tout ``QMainWindow``." msgid "Use ``PlotType.CURVE`` for 1D signal display" msgstr "Utiliser ``PlotType.CURVE`` pour l'affichage de signaux 1D" msgid "Use ``PlotType.IMAGE`` for 2D image display" msgstr "Utiliser ``PlotType.IMAGE`` pour l'affichage d'images 2D" msgid "Call ``get_plot()`` to access the underlying PlotPy plot for adding items" msgstr "Appeler ``get_plot()`` pour accéder au graphique PlotPy sous-jacent et y ajouter des éléments" msgid ":download:`Download Jupyter notebook: plot_widget.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : plot_widget.ipynb `" msgid ":download:`Download Python source code: plot_widget.py `" msgstr ":download:`Télécharger le code source Python : plot_widget.py `" msgid ":download:`Download zipped: plot_widget.zip `" msgstr ":download:`Télécharger l'archive : plot_widget.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" remote_control_widgets.po000066400000000000000000000137731524425144300344200ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/features# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Allez à la fin ` pour télécharger le code complet de l'exemple." msgid "Remote Control Widgets" msgstr "Widgets de contrôle à distance" msgid "This example demonstrates how to use :class:`~sigimax.widgets.connection.ConnectionDialog` and :class:`~sigimax.widgets.objectdialog.GetObjectDialog` to build a minimal remote client for a DataLab-derived application server." msgstr "Cet exemple montre comment utiliser :class:`~sigimax.widgets.connection.ConnectionDialog` et :class:`~sigimax.widgets.objectdialog.GetObjectDialog` pour créer un client distant minimal pour un serveur d'application dérivé de DataLab." msgid "These widgets are generic: they only depend on :class:`sigima.client.SimpleRemoteProxy` (or any proxy exposing the same ``connect``/``get_current_panel``/``get_group_titles_with_object_info`` API), never on a specific application's branding or object model." msgstr "Ces widgets sont génériques : ils ne dépendent que de :class:`sigima.client.SimpleRemoteProxy` (ou de tout proxy exposant la même API ``connect``/``get_current_panel``/``get_group_titles_with_object_info``), jamais de l'image de marque ou du modèle d'objet d'une application spécifique." msgid "To keep this example runnable without a real DataLab-derived application, :class:`sigima.client.stub.DataLabStubServer` is used to simulate one in-process." msgstr "Pour que cet exemple soit exécutable sans une véritable application dérivée de DataLab, :class:`sigima.client.stub.DataLabStubServer` est utilisé pour en simuler une en processus." msgid "Importing necessary modules" msgstr "Importation des modules nécessaires" msgid "Starting a (stub) server and connecting to it" msgstr "Démarrage d'un serveur (stub) et connexion à celui-ci" msgid "In a real client application, ``server`` would not exist: a real DataLab-derived application would already be running, and ``proxy.connect`` would be passed to :class:`~sigimax.widgets.connection.ConnectionDialog` for the user to see the connection progress." msgstr "Dans une véritable application cliente, ``server`` n'existerait pas : une véritable application dérivée de DataLab serait déjà en cours d'exécution, et ``proxy.connect`` serait passé à :class:`~sigimax.widgets.connection.ConnectionDialog` pour que l'utilisateur voie la progression de la connexion." msgid "Populating the server with a couple of signals" msgstr "Peuplement du serveur avec quelques signaux" msgid "``add_signal`` stores the signal on the server but does not attach it to any group by itself: the stub server's default group is wired here directly, through the server instance, to get a non-empty tree below. A real DataLab-derived server groups newly added objects automatically." msgstr "``add_signal`` stocke le signal sur le serveur mais ne l'attache pas à un groupe par lui-même : le groupe par défaut du serveur stub est connecté ici directement, via l'instance du serveur, pour obtenir un arbre non vide ci-dessous. Un véritable serveur dérivé de DataLab groupe automatiquement les objets nouvellement ajoutés." msgid "Picking an object from the server" msgstr "Sélection d'un objet sur le serveur" msgid ":class:`~sigimax.widgets.objectdialog.GetObjectDialog` lists the groups and objects available on the server. Restricting ``panel`` to ``\"signal\"`` hides the panel selector combo box." msgstr ":class:`~sigimax.widgets.objectdialog.GetObjectDialog` liste les groupes et objets disponibles sur le serveur. Restreindre ``panel`` à ``\"signal\"`` masque la combo box du sélecteur de panneau." msgid "Summary" msgstr "Résumé" msgid ":class:`~sigimax.widgets.connection.ConnectionDialog` wraps a blocking ``connect`` callback in a background thread, so the Qt event loop keeps running while the connection is attempted." msgstr ":class:`~sigimax.widgets.connection.ConnectionDialog` encapsule un callback bloquant ``connect`` dans un thread en arrière-plan, de sorte que la boucle d'événements Qt continue de fonctionner pendant que la connexion est tentée." msgid ":class:`~sigimax.widgets.objectdialog.GetObjectDialog` displays the groups/objects reported by :meth:`sigima.client.base.SimpleBaseProxy.get_group_titles_with_object_info`." msgstr ":class:`~sigimax.widgets.objectdialog.GetObjectDialog` affiche les groupes/objets rapportés par :meth:`sigima.client.base.SimpleBaseProxy.get_group_titles_with_object_info`." msgid ":class:`~sigima.client.stub.DataLabStubServer` lets both widgets be exercised headlessly, without a real DataLab-derived application." msgstr ":class:`~sigima.client.stub.DataLabStubServer` permet aux deux widgets d'être testés sans interface graphique, sans une véritable application dérivée de DataLab." msgid ":download:`Download Jupyter notebook: remote_control_widgets.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : remote_control_widgets.ipynb `" msgid ":download:`Download Python source code: remote_control_widgets.py `" msgstr ":download:`Télécharger le code source Python : remote_control_widgets.py `" msgid ":download:`Download zipped: remote_control_widgets.zip `" msgstr ":download:`Télécharger l'archive zip : remote_control_widgets.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/000077500000000000000000000000001524425144300307145ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/index.po000066400000000000000000000010721524425144300323630ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Getting Started" msgstr "Premiers pas" msgid ":ref:`sphx_glr_auto_examples_getting_started_minimal_app.py`" msgstr ":ref:`sphx_glr_auto_examples_getting_started_minimal_app.py`" minimal_app.po000066400000000000000000000115401524425144300334640ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." msgid "Minimal Derived Application" msgstr "Application dérivée minimale" msgid "This example demonstrates the **derivation pattern** — the core concept of SigimaX. In just a few lines of code, you can build a full-featured scientific desktop application with menus, toolbars, console, and status bar." msgstr "Cet exemple illustre le **patron de dérivation** — le concept central de SigimaX. En quelques lignes de code, vous pouvez construire une application scientifique de bureau complète avec menus, barres d'outils, console et barre d'état." msgid "The three-step pattern is:" msgstr "Le patron en trois étapes est :" msgid "**Subclass** :class:`~sigimax.config.SigimaXOptions` for app-specific options" msgstr "**Sous-classer** :class:`~sigimax.config.SigimaXOptions` pour les options spécifiques à l'application" msgid "**Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` for custom UI" msgstr "**Sous-classer** :class:`~sigimax.mainwindow.SGMXMainWindow` pour l'interface personnalisée" msgid "**Call** :func:`~sigimax.app.create` to launch" msgstr "**Appeler** :func:`~sigimax.app.create` pour lancer" msgid "This example creates a minimal \"MyApp\" with a dockable curve plot widget." msgstr "Cet exemple crée une application minimale \"MyApp\" avec un widget de graphique de courbes ancrable." msgid "Importing necessary modules" msgstr "Import des modules nécessaires" msgid "Step 1: Define custom configuration" msgstr "Étape 1 : Définir la configuration personnalisée" msgid "Subclass :class:`~sigimax.config.SigimaXOptions` to add fields specific to your application. Options are typed, validated, and support JSON persistence." msgstr "Sous-classer :class:`~sigimax.config.SigimaXOptions` pour ajouter des champs spécifiques à votre application. Les options sont typées, validées et prennent en charge la persistance JSON." msgid "Step 2: Customize the main window" msgstr "Étape 2 : Personnaliser la fenêtre principale" msgid "Subclass :class:`~sigimax.mainwindow.SGMXMainWindow` to add your own menus, toolbars, and dock widgets." msgstr "Sous-classer :class:`~sigimax.mainwindow.SGMXMainWindow` pour ajouter vos propres menus, barres d'outils et widgets ancrables." msgid "Step 3: Launch the application" msgstr "Étape 3 : Lancer l'application" msgid "Use :func:`~sigimax.app.create` to instantiate the window (without entering the Qt event loop, so sphinx-gallery can capture the screenshot)." msgstr "Utiliser :func:`~sigimax.app.create` pour instancier la fenêtre (sans entrer dans la boucle d'événements Qt, afin que sphinx-gallery puisse capturer la capture d'écran)." msgid "Summary" msgstr "Résumé" msgid "This example showed the minimal derivation pattern:" msgstr "Cet exemple a présenté le patron de dérivation minimal :" msgid "**Configuration**: ``MyAppOptions`` adds typed, validated options" msgstr "**Configuration** : ``MyAppOptions`` ajoute des options typées et validées" msgid "**Main window**: ``MyAppMainWindow`` adds a curve plot dock" msgstr "**Fenêtre principale** : ``MyAppMainWindow`` ajoute un dock de graphique de courbes" msgid "**Launcher**: ``create()`` or ``run()`` starts the application" msgstr "**Lanceur** : ``create()`` ou ``run()`` démarre l'application" msgid "For a production app, use ``run(window_class=MyAppMainWindow)`` instead of ``create()`` — it enters the Qt event loop and shows a splash screen." msgstr "Pour une application en production, utiliser ``run(window_class=MyAppMainWindow)`` au lieu de ``create()`` — cela entre dans la boucle d'événements Qt et affiche un écran d'accueil." msgid ":download:`Download Jupyter notebook: minimal_app.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : minimal_app.ipynb `" msgid ":download:`Download Python source code: minimal_app.py `" msgstr ":download:`Télécharger le code source Python : minimal_app.py `" msgid ":download:`Download zipped: minimal_app.zip `" msgstr ":download:`Télécharger l'archive : minimal_app.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/index.po000066400000000000000000000056711524425144300272050ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Examples" msgstr "Exemples" msgid "This section presents a collection of examples demonstrating the capabilities of SigimaX. Each example is a standalone Python script that showcases specific features or use cases of the SigimaX library." msgstr "Cette section présente une collection d'exemples démontrant les capacités de SigimaX. Chaque exemple est un script Python autonome qui met en avant des fonctionnalités ou des cas d'utilisation spécifiques de la bibliothèque SigimaX." msgid "Of course, some of these examples may seem trivial, but they serve to illustrate how to use various functionalities of SigimaX in a clear and concise manner." msgstr "Bien sûr, certains de ces exemples peuvent sembler triviaux, mais ils servent à illustrer comment utiliser diverses fonctionnalités de SigimaX de manière claire et concise." msgid "These examples are automatically generated when building the documentation, thus ensuring that they are always up-to-date with the latest version of SigimaX." msgstr "Ces exemples sont générés automatiquement lors de la construction de la documentation, garantissant ainsi qu'ils sont toujours fonctionnels avec la dernière version de SigimaX." msgid "Getting Started" msgstr "Premiers pas" msgid ":ref:`sphx_glr_auto_examples_getting_started_minimal_app.py`" msgstr ":ref:`sphx_glr_auto_examples_getting_started_minimal_app.py`" msgid "Various Features" msgstr "Fonctionnalités diverses" msgid ":ref:`sphx_glr_auto_examples_features_configuration.py`" msgstr ":ref:`sphx_glr_auto_examples_features_configuration.py`" msgid ":ref:`sphx_glr_auto_examples_features_plot_widget.py`" msgstr ":ref:`sphx_glr_auto_examples_features_plot_widget.py`" msgid ":ref:`sphx_glr_auto_examples_features_h5_workspace.py`" msgstr ":ref:`sphx_glr_auto_examples_features_h5_workspace.py`" msgid ":ref:`sphx_glr_auto_examples_features_mainwindow_customization.py`" msgstr ":ref:`sphx_glr_auto_examples_features_mainwindow_customization.py`" msgid ":ref:`sphx_glr_auto_examples_features_adapters_plotpy_factory.py`" msgstr ":ref:`sphx_glr_auto_examples_features_adapters_plotpy_factory.py`" msgid ":ref:`sphx_glr_auto_examples_features_remote_control_widgets.py`" msgstr ":ref:`sphx_glr_auto_examples_features_remote_control_widgets.py`" msgid "Use Cases" msgstr "Cas d'usage" msgid ":ref:`sphx_glr_auto_examples_use_cases_full_app.py`" msgstr ":ref:`sphx_glr_auto_examples_use_cases_full_app.py`" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/000077500000000000000000000000001524425144300274775ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/full_app.po000066400000000000000000000104141524425144300316410ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid ":ref:`Go to the end ` to download the full example code." msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." msgid "Full Derived Application" msgstr "Application dérivée complète" msgid "This use case demonstrates a complete derived application built on SigimaX, showcasing:" msgstr "Ce cas d'usage présente une application dérivée complète construite sur SigimaX, illustrant :" msgid "Custom configuration with typed options" msgstr "Configuration personnalisée avec des options typées" msgid "Custom main window with menus, toolbars, and dock widgets" msgstr "Fenêtre principale personnalisée avec menus, barres d'outils et widgets ancrables" msgid "Interactive curve generation using PlotPy" msgstr "Génération interactive de courbes avec PlotPy" msgid "Configuration display via console" msgstr "Affichage de la configuration via la console" msgid "This example mirrors the derivation pattern used by `DataLab `_ — the flagship application built on SigimaX." msgstr "Cet exemple reprend le patron de dérivation utilisé par `DataLab `_ — l'application phare construite sur SigimaX." msgid "Importing necessary modules" msgstr "Import des modules nécessaires" msgid "Step 1: Define the application configuration" msgstr "Étape 1 : Définir la configuration de l'application" msgid "Custom options extend :class:`~sigimax.config.SigimaXOptions` with domain-specific settings." msgstr "Les options personnalisées étendent :class:`~sigimax.config.SigimaXOptions` avec des paramètres spécifiques au domaine." msgid "Step 2: Build the custom main window" msgstr "Étape 2 : Construire la fenêtre principale personnalisée" msgid "Override :class:`~sigimax.mainwindow.SGMXMainWindow` to add domain-specific menus, toolbars, and dock widgets." msgstr "Surcharger :class:`~sigimax.mainwindow.SGMXMainWindow` pour ajouter des menus, barres d'outils et widgets ancrables spécifiques au domaine." msgid "Step 3: Launch and demonstrate" msgstr "Étape 3 : Lancer et démontrer" msgid "Summary" msgstr "Résumé" msgid "This example demonstrated a complete SigimaX-based application with:" msgstr "Cet exemple a présenté une application complète basée sur SigimaX avec :" msgid "**Custom configuration** (``SciAppOptions``) with typed fields" msgstr "**Configuration personnalisée** (``SciAppOptions``) avec des champs typés" msgid "**Custom main window** (``SciAppMainWindow``) with Analysis menu and toolbar" msgstr "**Fenêtre principale personnalisée** (``SciAppMainWindow``) avec menu et barre d'outils d'analyse" msgid "**Interactive plot** via ``DockablePlotWidget``" msgstr "**Graphique interactif** via ``DockablePlotWidget``" msgid "**Status bar** messages on user actions" msgstr "Messages dans la **barre d'état** lors des actions utilisateur" msgid "For a standalone application, replace the ``create()`` call with ``run(window_class=SciAppMainWindow, console=True)`` to enter the Qt event loop." msgstr "Pour une application autonome, remplacer l'appel à ``create()`` par ``run(window_class=SciAppMainWindow, console=True)`` pour entrer dans la boucle d'événements Qt." msgid ":download:`Download Jupyter notebook: full_app.ipynb `" msgstr ":download:`Télécharger le notebook Jupyter : full_app.ipynb `" msgid ":download:`Download Python source code: full_app.py `" msgstr ":download:`Télécharger le code source Python : full_app.py `" msgid ":download:`Download zipped: full_app.zip `" msgstr ":download:`Télécharger l'archive : full_app.zip `" msgid "`Gallery generated by Sphinx-Gallery `_" msgstr "`Galerie générée par Sphinx-Gallery `_" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/index.po000066400000000000000000000010411524425144300311420ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Use Cases" msgstr "Cas d'usage" msgid ":ref:`sphx_glr_auto_examples_use_cases_full_app.py`" msgstr ":ref:`sphx_glr_auto_examples_use_cases_full_app.py`" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/contributing/000077500000000000000000000000001524425144300253665ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/contributing/index.po000066400000000000000000000272001524425144300270360ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Contribute to SigimaX project, the open-source GUI framework for scientific applications" msgstr "Contribuer au projet SigimaX, le framework open-source d'interface graphique pour les applications scientifiques" msgid "SigimaX, contribute, open-source, scientific, GUI, framework, Qt, Python" msgstr "SigimaX, contribuer, open-source, scientifique, interface graphique, framework, Qt, Python" msgid "Contributing" msgstr "Contribuer" msgid "There are many ways to contribute to SigimaX, depending on how much time you have, your experience with open source projects, and your skills." msgstr "Il existe de nombreuses façons de contribuer à SigimaX, selon le temps dont vous disposez, votre expérience avec les projets open-source et vos compétences." msgid "Share your ideas and experiences" msgstr "Partagez vos idées et expériences" msgid ":octicon:`info;1em;sd-text-info` :bdg-success-line:`No coding required`" msgstr ":octicon:`info;1em;sd-text-info` :bdg-success-line:`Aucun codage requis`" msgid "Besides the classic bug reports and feature requests, you can share your ideas and experiences for improving SigimaX. In particular, we are very interested in your feedback on the documentation and tutorials. Moreover, if you have a use case that you would like to share with the community, please let us know." msgstr "En plus des rapports de bogues et des demandes de fonctionnalités classiques, vous pouvez partager vos idées et expériences pour améliorer SigimaX. En particulier, nous sommes très intéressés par vos retours sur la documentation et les tutoriels. De plus, si vous avez un cas d'utilisation que vous souhaitez partager avec la communauté, n'hésitez pas à nous en informer." msgid " Bugs" msgstr " Bugs" msgid "Reporting a bug" msgstr "Signaler une anomalie" msgid " Enhancements" msgstr ":octicon:`light-bulb;1em;sd-text-info` Améliorations" msgid "Suggesting an enhancement" msgstr "Suggérer une amélioration" msgid " Documentation" msgstr " Documentation" msgid "Suggesting a documentation topic" msgstr "Suggérer un sujet de documentation" msgid " Tutorial" msgstr ":octicon:`mortar-board;1em;sd-text-info` Tutoriel" msgid "Suggesting a tutorial topic" msgstr "Suggérer un sujet de tutoriel" msgid "Without coding, you can contribute to SigimaX project by:" msgstr "Sans coder, vous pouvez contribuer au projet SigimaX en :" msgid "`Reporting a bug `_" msgstr "`Signalant une anomalie `_" msgid "`Suggesting an enhancement `_" msgstr "`Suggérant une amélioration `_" msgid "`Suggesting a documentation topic `_" msgstr "`Suggérant un sujet de documentation `_" msgid "`Suggesting a tutorial topic `_" msgstr "`Suggérant un sujet de tutoriel `_" msgid "Share your scientific/technical knowledge" msgstr "Partagez vos connaissances scientifiques/techniques" msgid "Your technical or scientific knowledge is also very valuable to us. You may contribute documentation or tutorials directly. Or, if you want to write a tutorial, we will be happy to help you get started." msgstr "Vos connaissances techniques ou scientifiques nous sont également très précieuses. Vous pouvez contribuer directement à la documentation ou aux tutoriels. Ou, si vous souhaitez rédiger un tutoriel, nous serons heureux de vous aider à démarrer." msgid "Writing documentation" msgstr "Rédiger de la documentation" msgid "Writing a tutorial" msgstr "Rédiger un tutoriel" msgid "Sharing a use case of a derived application" msgstr "Partager un cas d'utilisation d'une application dérivée" msgid "Contribute code" msgstr "Contribuer au code" msgid ":octicon:`info;1em;sd-text-info` :bdg-info-line:`Coding (beginner)` :bdg-warning-line:`Coding (advanced)`" msgstr ":octicon:`info;1em;sd-text-info` :bdg-info-line:`Programmation (débutant)` :bdg-warning-line:`Programmation (avancé)`" msgid "Even if you are not an experienced developer, you can contribute to the project by:" msgstr "Même si vous n'êtes pas un développeur expérimenté, vous pouvez contribuer au projet en :" msgid "Testing new features" msgstr "Testant les nouvelles fonctionnalités" msgid "Writing or improving tests" msgstr "Écrivant ou améliorant les tests" msgid "Reporting and fixing bugs" msgstr "Signalant et corrigeant les bogues" msgid "If you are a developer, you can contribute to the core of the project by fixing bugs or implementing new features." msgstr "Si vous êtes développeur, vous pouvez contribuer au cÅ“ur du projet en corrigeant des bogues ou en implémentant de nouvelles fonctionnalités." msgid "Development setup" msgstr "Configuration du développement" msgid "Clone the repository:" msgstr "Clonez le dépôt :" msgid "Run the tests:" msgstr "Exécutez les tests :" msgid "Format and lint:" msgstr "Formatez et vérifiez :" msgid "Code conventions" msgstr "Conventions de code" msgid "Use ``from __future__ import annotations`` in all modules" msgstr "Utilisez ``from __future__ import annotations`` dans tous les modules" msgid "Define ``__all__`` in all public modules" msgstr "Définissez ``__all__`` dans tous les modules publics" msgid "Wrap UI strings with ``_()`` for internationalization" msgstr "Encapsulez les chaînes d'interface utilisateur avec ``_()`` pour l'internationalisation" msgid "Follow Google-style docstrings" msgstr "Suivez les docstrings de style Google" msgid "Use ``snake_case`` for functions, ``PascalCase`` for classes" msgstr "Utilisez ``snake_case`` pour les fonctions, ``PascalCase`` pour les classes" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/index.po000066400000000000000000000210001524425144300243170ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Contents" msgstr "Table des matières" msgid "SigimaX" msgstr "SigimaX" msgid "**SigimaX** is an open-source Python framework for building Qt-based scientific desktop applications. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features." msgstr "**SigimaX** est un framework Python open-source pour créer des applications de bureau scientifiques basées sur Qt. Il fournit un squelette d'application réutilisable — fenêtre principale, système de configuration, widgets intégrés et infrastructure HDF5 — afin que les développeurs puissent se concentrer sur les fonctionnalités spécifiques à leur domaine." msgid "Developed and maintained by the DataLab Platform Developers, **SigimaX** powers the GUI layer of `DataLab `_." msgstr "Développé et maintenu par les développeurs de la plateforme DataLab, **SigimaX** alimente la couche graphique de `DataLab `_." msgid " User Guide" msgstr ":octicon:`rocket;1em;sd-text-info` Manuel utilisateur" msgid "Installation, overview, and features" msgstr "Installation et fonctionnalités" msgid " Examples" msgstr ":octicon:`code;1em;sd-text-info` Exemples" msgid "Gallery of examples" msgstr "Galerie d'exemples" msgid " API" msgstr " API" msgid "Reference documentation" msgstr "Documentation de référence" msgid " Contributing" msgstr ":octicon:`gear;1em;sd-text-info` Contribuer" msgid "Getting involved in the project" msgstr "S'impliquer dans le projet" msgid "Quick Start" msgstr "Démarrage rapide" msgid "Build a scientific desktop application in three steps:" msgstr "Créez une application de bureau scientifique en trois étapes :" msgid "SigimaX has been funded by the following stakeholders:" msgstr "SigimaX a été financé par les parties prenantes suivantes :" msgid "|cea_logo|" msgstr "|cea_logo|" msgid "cea_logo" msgstr "cea_logo" msgid "`CEA `_, the French Alternative Energies and Atomic Energy Commission, is the major investor in DataLab, and is the main contributor to the project." msgstr "`CEA `_, le Commissariat à l'énergie atomique et aux énergies alternatives, est le principal investisseur de DataLab et le principal contributeur au projet." msgid "|codra_logo|" msgstr "|codra_logo|" msgid "codra_logo" msgstr "codra_logo" msgid "`CODRA`_, a software engineering and editor firm, has supported DataLab open-source journey since its inception (see `here `_)." msgstr "`CODRA`_, une entreprise d'ingénierie logicielle et d'édition, a soutenu le parcours open-source de DataLab depuis ses débuts (voir `ici `_)." DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/release_notes/000077500000000000000000000000001524425144300255075ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/release_notes/index.po000066400000000000000000000014531524425144300271610ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Release notes" msgstr "Notes de version" msgid "This section contains the release notes for all versions of :mod:`sigimax`, documenting new features, improvements, bug fixes, and breaking changes." msgstr "Cette section contient les notes de version pour toutes les versions de :mod:`sigimax`, documentant les nouvelles fonctionnalités, les améliorations, les corrections de bogues et les changements incompatibles." DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/release_notes/release_0.01.po000066400000000000000000000401701524425144300301270ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Version 0.1" msgstr "Version 0.1" msgid "SigimaX Version 0.1.0 (2026-03-04) (pre-release)##" msgstr "SigimaX Version 0.1.0 (04/03/2026) (pré-release)##" msgid "Initial development release — SigimaX is extracted from DataLab as a reusable GUI application framework for scientific computing Qt applications." msgstr "Première version de développement — SigimaX est extrait de DataLab en tant que framework d'application GUI réutilisable pour les applications Qt de calcul scientifique." msgid "Highlights" msgstr "Points forts" msgid "SigimaX provides the generic \"application skeleton\" that any scientific computing Qt application can build upon by subclassing its main window and configuration system. This release contains the full extraction from DataLab, including:" msgstr "SigimaX fournit le squelette applicatif générique sur lequel toute application Qt de calcul scientifique peut s'appuyer en héritant de sa fenêtre principale et de son système de configuration. Cette version contient l'extraction complète depuis DataLab, notamment :" msgid "Generic main window (`SGMXMainWindow`) with customizable menus, toolbars, and console" msgstr "Fenêtre principale générique (`SGMXMainWindow`) avec menus, barres d'outils et console personnalisables" msgid "Configuration system based on Sigima's typed option fields" msgstr "Système de configuration basé sur les champs d'options typés de Sigima" msgid "HDF5 browsing, generic import, and workspace persistence hooks" msgstr "Navigation HDF5, import générique et points d'extension de persistance de l'espace de travail" msgid "Splash screen and application launcher (`create()` / `run()`)" msgstr "Écran de démarrage et lanceur d'application (`create()` / `run()`)" msgid "PlotPy adapters for signal/image plot items" msgstr "Adaptateurs PlotPy pour les éléments de tracé signal/image" msgid "Reusable scientific widgets (fit dialogs, baseline, peak detection, cursor, etc.)" msgstr "Widgets scientifiques réutilisables (dialogues d'ajustement, ligne de base, détection de pics, curseur, etc.)" msgid "Comprehensive test suite with 155+ tests across unit, GUI, and app categories" msgstr "Suite de tests complète avec plus de 155 tests répartis dans les catégories unitaire, GUI et application" msgid "Full Sphinx documentation with API reference and gallery examples" msgstr "Documentation Sphinx complète avec référence API et exemples de galerie" msgid "Complete French translation (189 strings)" msgstr "Traduction française complète (189 chaînes)" msgid "Application framework" msgstr "Framework applicatif" msgid "Implemented `SGMXMainWindow` — a generic main window that derived applications subclass to build their own UI, with customizable menu order, toolbar actions, and console namespace" msgstr "Implémentation de `SGMXMainWindow` — une fenêtre principale générique dont les applications dérivées héritent pour construire leur propre interface, avec un ordre de menus, des actions de barre d'outils et un espace de noms de console personnalisables" msgid "Added `create()` and `run()` application launcher functions in `app.py` with configurable splash screen support (`SplashScreenConfig`)" msgstr "Ajout des fonctions de lancement d'application `create()` et `run()` dans `app.py` avec support configurable de l'écran de démarrage (`SplashScreenConfig`)" msgid "Provided overridable hooks for derived apps: `reset_all()`, `_is_save_enabled()`, `_update_file_menu()`, `_update_view_menu()`, `_about()`" msgstr "Mise à disposition de hooks redéfinissables pour les applications dérivées : `reset_all()`, `_is_save_enabled()`, `_update_file_menu()`, `_update_view_menu()`, `_about()`" msgid "Added generic main toolbar configuration — derived apps can redefine toolbar actions" msgstr "Ajout d'une configuration générique de la barre d'outils principale — les applications dérivées peuvent redéfinir les actions de la barre d'outils" msgid "Added quit action to the file menu" msgstr "Ajout de l'action quitter au menu fichier" msgid "Configuration system" msgstr "Système de configuration" msgid "Implemented typed configuration options inspired by Sigima's non-INI-file config system, with `TypedOptionField`, `EnumOptionField`, `TupleOptionField`, and `FontOptionField`" msgstr "Implémentation d'options de configuration typées inspirées du système de configuration non-INI de Sigima, avec `TypedOptionField`, `EnumOptionField`, `TupleOptionField` et `FontOptionField`" msgid "Added generic application metadata options (`app_name`, `app_version`, `app_logo_path`, `app_desc`, `app_docurl`, `app_homeurl`, `app_supporturl`)" msgstr "Ajout d'options génériques de métadonnées d'application (`app_name`, `app_version`, `app_logo_path`, `app_desc`, `app_docurl`, `app_homeurl`, `app_supporturl`)" msgid "Configuration supports JSON persistence via `save()` / `load()`" msgstr "La configuration supporte la persistance JSON via `save()` / `load()` et la surcharge par variables d'environnement" msgid "Removed `process_isolation_enabled` option (DataLab-specific — the framework only used it cosmetically; the actual mechanism stays in DataLab)" msgstr "Suppression de l'option `process_isolation_enabled` (spécifique à DataLab — le framework ne l'utilisait que de manière cosmétique ; le mécanisme réel reste dans DataLab)" msgid "HDF5 support" msgstr "Support HDF5" msgid "Ported generic HDF5 browsing and dataset import" msgstr "Portage de la navigation HDF5 générique et de l'import de jeux de données" msgid "Added workspace persistence hooks for derived applications; SigimaX does not impose a universal workspace format or serializer" msgstr "Ajout de points d'extension de persistance de l'espace de travail pour les applications dérivées ; SigimaX n'impose pas de format ou de sérialiseur d'espace de travail universel" msgid "Added `import_dataset_from_file()` hook for derived apps to handle application-specific dataset import from HDF5" msgstr "Ajout du hook `import_dataset_from_file()` (sans opération) pour que les applications dérivées puissent gérer l'import de jeux de données spécifiques depuis HDF5" msgid "Ported `H5BrowserDialog` widget for interactive HDF5 file browsing" msgstr "Portage du widget `H5BrowserDialog` pour la navigation interactive dans les fichiers HDF5" msgid "Widgets" msgstr "Widgets" msgid "Ported scientific dialog widgets from DataLab: curve fitting (`fitdialog`), signal baseline selection, signal peak detection, signal cursor, signal delta-X measurement, image background selection" msgstr "Portage des widgets de dialogue scientifique depuis DataLab : ajustement de courbe (`fitdialog`), sélection de ligne de base, détection de pics, curseur de signal, mesure de delta-X, sélection de fond d'image" msgid "Added `DockablePlotWidget` for embedding PlotPy plots in dock widgets, with configurable watermark and dock location via `SigimaXOptions`" msgstr "Ajout de `DockablePlotWidget` pour l'intégration de tracés PlotPy dans des widgets ancrables, avec filigrane et position d'ancrage configurables via `SigimaXOptions`" msgid "Ported status bar widgets: `BaseStatus`, `MemoryStatus`, `ConsoleStatus`" msgstr "Portage des widgets de barre d'état : `BaseStatus`, `MemoryStatus`, `ConsoleStatus`" msgid "Added `Wizard` multi-page dialog widget" msgstr "Ajout du widget de dialogue multi-pages `Wizard`" msgid "Added `LogViewerWindow` for log display" msgstr "Ajout de `LogViewerWindow` pour l'affichage des journaux" msgid "Added `FileViewerWidget` for read-only file viewing" msgstr "Ajout de `FileViewerWidget` pour la consultation de fichiers en lecture seule" msgid "Added `WarningErrorMessageBox` for warning/error display" msgstr "Ajout de `WarningErrorMessageBox` pour l'affichage des avertissements et erreurs" msgid "Added convenience re-exports in `widgets/__init__.py` with `__all__`" msgstr "Ajout de ré-exports pratiques dans `widgets/__init__.py` avec `__all__`" msgid "PlotPy adapters" msgstr "Adaptateurs PlotPy" msgid "Ported `adapters_plotpy` module for converting between Sigima objects (`SignalObj`, `ImageObj`) and PlotPy plot items" msgstr "Portage du module `adapters_plotpy` pour la conversion entre les objets Sigima (`SignalObj`, `ImageObj`) et les éléments de tracé PlotPy" msgid "Added `iterate_metadata_shape_items()` hook on `BaseObjPlotPyAdapter` — a no-op generator that derived apps (DataLab) can override to yield plot items for app-specific metadata entries (geometry results, table results)" msgstr "Ajout du hook `iterate_metadata_shape_items()` sur `BaseObjPlotPyAdapter` — un générateur sans opération que les applications dérivées (DataLab) peuvent redéfinir pour produire des éléments de tracé pour les entrées de métadonnées spécifiques (résultats géométriques, résultats tabulaires)" msgid "Cleaned up commented-out scalar adapter code (`GeometryPlotPyAdapter`, `TableAdapter`) — these stay in DataLab" msgstr "Nettoyage du code d'adaptateur scalaire mis en commentaire (`GeometryPlotPyAdapter`, `TableAdapter`) — ceux-ci restent dans DataLab" msgid "Environment and utilities" msgstr "Environnement et utilitaires" msgid "Implemented `SGMXExecEnv` runtime environment singleton (renamed from DataLab's `DLExecEnv`) with verbosity levels, demo mode, and unattended mode" msgstr "Implémentation du singleton d'environnement d'exécution `SGMXExecEnv` (renommé depuis `DLExecEnv` de DataLab) avec niveaux de verbosité, mode démo et mode sans interaction" msgid "Ported Qt helper utilities (`utils/qthelpers.py`): log file management, signal blocking context manager, stdout/stderr save/restore" msgstr "Portage des utilitaires Qt (`utils/qthelpers.py`) : gestion des fichiers journaux, gestionnaire de contexte pour le blocage des signaux, sauvegarde/restauration de stdout/stderr" msgid "Added local PDF documentation path handling in the Help menu" msgstr "Ajout de la gestion du chemin de documentation PDF locale dans le menu Aide" msgid "Package structure" msgstr "Structure du paquet" msgid "Dissolved `gui/` subpackage — moved `gui/main.py` → `mainwindow.py` (top-level) and `gui/docks.py` → `widgets/plotdock.py` for clearer naming" msgstr "Dissolution du sous-paquet `gui/` — déplacement de `gui/main.py` → `mainwindow.py` (niveau supérieur) et `gui/docks.py` → `widgets/plotdock.py` pour un nommage plus clair" msgid "Added `__all__` declarations in all public modules" msgstr "Ajout des déclarations `__all__` dans tous les modules publics" msgid "Added `from __future__ import annotations` across all modules" msgstr "Ajout de `from __future__ import annotations` dans tous les modules" msgid "Top-level `__init__.py` re-exports `SGMXMainWindow`, `create`, `run` (follows Sigima's pattern)" msgstr "Le fichier `__init__.py` de niveau supérieur ré-exporte `SGMXMainWindow`, `create`, `run` (suit le modèle de Sigima)" msgid "Fixed circular import between `__init__.py` and `config.py` by extracting metadata to `_metadata.py`" msgstr "Correction de l'import circulaire entre `__init__.py` et `config.py` par extraction des métadonnées dans `_metadata.py`" msgid "Removed dead code: commented-out imports, `config_old.py`, DataLab-specific action handler dependencies" msgstr "Suppression du code mort : imports mis en commentaire, `config_old.py`, dépendances spécifiques au gestionnaire d'actions de DataLab" msgid "Testing" msgstr "Tests" msgid "Built comprehensive test suite with 155+ tests organized into subpackages: `config/`, `mainwindow/`, `widgets/`, `hdf5/`, `adapters_plotpy/`, `utils/`" msgstr "Construction d'une suite de tests complète avec plus de 155 tests organisés en sous-paquets : `config/`, `mainwindow/`, `widgets/`, `hdf5/`, `adapters_plotpy/`, `utils/`" msgid "Standardized test file naming to `test_*.py` convention; non-test helpers prefixed with `_`" msgstr "Standardisation du nommage des fichiers de test selon la convention `test_*.py` ; les fichiers auxiliaires non-test sont préfixés par `_`" msgid "Added pytest markers: `@pytest.mark.unit` (pure logic, no Qt), `@pytest.mark.gui` (Qt widget tests), `@pytest.mark.app` (full main window)" msgstr "Ajout de marqueurs pytest : `@pytest.mark.unit` (logique pure, sans Qt), `@pytest.mark.gui` (tests de widgets Qt), `@pytest.mark.app` (fenêtre principale complète)" msgid "Added `--show-windows` flag for visual test validation (offscreen by default)" msgstr "Ajout du drapeau `--show-windows` pour la validation visuelle des tests (hors écran par défaut)" msgid "PlotPy adapter tests cover factory dispatch, make/update item roundtrips, ROI coordinate roundtrips, and annotation integration" msgstr "Les tests des adaptateurs PlotPy couvrent la répartition des fabriques, les allers-retours de création/mise à jour d'éléments, les allers-retours de coordonnées ROI et l'intégration des annotations" msgid "Documentation" msgstr "Documentation" msgid "Added full Sphinx documentation with API reference pages for all public modules (`app`, `config`, `env`, `mainwindow`, `widgets`, `h5`, `adapters_plotpy`, `utils`)" msgstr "Ajout d'une documentation Sphinx complète avec des pages de référence API pour tous les modules publics (`app`, `config`, `env`, `mainwindow`, `widgets`, `h5`, `adapters_plotpy`, `utils`)" msgid "Added Sphinx-Gallery examples: getting started (`minimal_app.py`), features (`configuration.py`, `plot_widget.py`), and use cases (`full_app.py`)" msgstr "Ajout d'exemples Sphinx-Gallery : premiers pas (`minimal_app.py`), fonctionnalités (`configuration.py`, `plot_widget.py`) et cas d'usage (`full_app.py`)" msgid "Added user guide pages: overview, installation, contributing" msgstr "Ajout de pages du manuel utilisateur : aperçu, installation, contribution" msgid "Documentation builds cleanly with `-W` (warnings-as-errors)" msgstr "La documentation se construit sans erreur avec `-W` (avertissements traités comme des erreurs)" msgid "Internationalization" msgstr "Internationalisation" msgid "Added complete French translation of all 189 UI strings (`sigimax/locale/fr/LC_MESSAGES/sigimax.po`)" msgstr "Ajout de la traduction française complète des 189 chaînes de l'interface (`sigimax/locale/fr/LC_MESSAGES/sigimax.po`)" msgid "Translations cover menus, toolbar, HDF5 browser, fit dialogs, signal widgets, status bar, error/warning dialogs, wizard, and about/help" msgstr "Les traductions couvrent les menus, la barre d'outils, le navigateur HDF5, les boîtes de dialogue d'ajustement, les widgets de signaux, la barre d'état, les boîtes de dialogue d'erreur/avertissement, l'assistant et la boîte à propos/aide" msgid "Terminology aligned with DataLab's existing French translations for consistency" msgstr "Terminologie alignée avec les traductions françaises existantes de DataLab pour la cohérence" msgid "Project infrastructure" msgstr "Infrastructure du projet" msgid "Created `pyproject.toml` with Ruff rules (`D202`, `D403`, `RUF022`, Google pydocstyle), pytest config (`--import-mode=importlib`, `filterwarnings`)" msgstr "Création de `pyproject.toml` avec les règles Ruff (`D202`, `D403`, `RUF022`, pydocstyle Google), la configuration pytest (`--import-mode=importlib`, `filterwarnings`)" msgid "Added Sphinx documentation scaffolding (imported from Sigima's structure)" msgstr "Ajout de la structure de documentation Sphinx (importée depuis la structure de Sigima)" msgid "Added copilot instructions (`.github/copilot-instructions.md`)" msgstr "Ajout des instructions Copilot (`.github/copilot-instructions.md`)" msgid "Changed maintainer email to `datalab@codra.fr`" msgstr "Changement de l'adresse e-mail du mainteneur vers `datalab@codra.fr`" msgid "Fixed `run_with_env.py` to substitute `sys.executable` when command starts with `python`, ensuring the correct venv interpreter is used" msgstr "Correction de `run_with_env.py` pour substituer `sys.executable` lorsque la commande commence par `python`, garantissant l'utilisation du bon interpréteur du venv" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/release_notes/release_1.00.po000066400000000000000000000437041524425144300301350ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Version 1.0.0" msgstr "Version 1.0.0" msgid "SigimaX Version 1.0.0" msgstr "SigimaX Version 1.0.0" msgid "Highlights" msgstr "Faits Marquants" msgid "This release turns `SGMXMainWindow` and the SigimaX configuration system into a real extension surface: derived applications (DataLab and others) can now compose menus, docks, status bar widgets, HDF5 import and the whole application lifecycle by overriding small, well-documented hooks instead of duplicating entire methods. It also brings a simplified, more robust configuration option system, new remote-control widgets for connecting to a running DataLab-Kernel instance, and a batch of fixes ported from DataLab's `develop` branch." msgstr "Cette version transforme `SGMXMainWindow` et le système de configuration SigimaX en une véritable surface d'extension : les applications dérivées (DataLab et autres) peuvent désormais composer des menus, des docks, des widgets de barre d'état, l'importation HDF5 et l'ensemble du cycle de vie de l'application en remplaçant de petits hooks bien documentés au lieu de dupliquer des méthodes entières. Elle apporte également un système d'options de configuration simplifié et plus robuste, de nouveaux widgets de télécommande pour se connecter à une instance DataLab-Kernel en cours d'exécution, ainsi qu'un lot de correctifs portés depuis la branche `develop` de DataLab." msgid "Application framework" msgstr "Framework Applicatif" msgid "Made the main window setup sequence an extension point: `setup()` now calls optional `_setup_panels()`, `_setup_docks()` and `_post_setup()` hooks so that derived applications add their panels, docks and final wiring without reimplementing the whole startup sequence" msgstr "Rendu la séquence de configuration de la fenêtre principale un point d'extension : `setup()` appelle désormais les hooks optionnels `_setup_panels()`, `_setup_docks()` et `_post_setup()` afin que les applications dérivées ajoutent leurs panneaux, docks et câblage final sans réimplémenter toute la séquence de démarrage" msgid "Turned the dock registry into an explicit extension point: `_add_dockwidget()` now owns registration in `self.docks`, assigns a stable Qt object name and handles tabification through new `name`, `key` and `tabify_with` arguments, so derived applications no longer register docks by hand" msgstr "Transformé le registre des docks en un point d'extension explicite : `_add_dockwidget()` gère désormais l'enregistrement dans `self.docks`, attribue un nom d'objet Qt stable et gère la tabulation via de nouveaux arguments `name`, `key` et `tabify_with`, de sorte que les applications dérivées n'enregistrent plus les docks manuellement" msgid "Let derived applications compose the menu bar through `_get_menubar_layout()` and extend the Help menu through `_get_help_doc_actions()`, `_get_help_support_actions()` and `_get_help_about_actions()`, instead of reimplementing `_add_menus()` entirely" msgstr "Permettre aux applications dérivées de composer la barre de menus via `_get_menubar_layout()` et d'étendre le menu Aide via `_get_help_doc_actions()`, `_get_help_support_actions()` et `_get_help_about_actions()`, au lieu de réimplémenter entièrement `_add_menus()`" msgid "Let derived applications contribute status bar widgets via `_get_extra_status_widgets()`, inserted between the console and memory status, instead of rebuilding the whole status bar" msgstr "Permettre aux applications dérivées de contribuer des widgets à la barre d'état via `_get_extra_status_widgets()`, insérés entre la console et l'état de la mémoire, au lieu de reconstruire toute la barre d'état" msgid "Exposed a broad set of protected lifecycle hooks (status bar, actions, central widget, menus, console, geometry, window state, toolbar, dock, memory and color-mode steps, plus pre/post setup, post-show and shutdown hooks), all documented and covered by dedicated lifecycle-ordering tests" msgstr "Exposé un large ensemble de hooks de cycle de vie protégés (barre d'état, actions, widget central, menus, console, géométrie, état de la fenêtre, barre d'outils, dock, mémoire et étapes du mode couleur, plus les hooks pré/post configuration, post-affichage et arrêt), tous documentés et couverts par des tests dédiés à l'ordre du cycle de vie" msgid "Fixed the derived main window lifecycle: singleton instances are now created from the requested derived window class, with protected setup running before virtual startup hooks" msgstr "Correction du cycle de vie de la fenêtre principale dérivée : les instances singleton sont désormais créées à partir de la classe de fenêtre dérivée demandée, avec la configuration protégée s'exécutant avant les hooks de démarrage virtuels" msgid "Deferred command-line argument parsing so that the module-level SigimaX execution environment no longer intercepts application-specific options (such as DataLab's `--version`) before the derived environment is initialized" msgstr "Différé l'analyse des arguments de la ligne de commande afin que l'environnement d'exécution SigimaX au niveau du module n'intercepte plus les options spécifiques à l'application (telles que `--version` de DataLab) avant l'initialisation de l'environnement dérivé" msgid "Improved the application window factory to preserve the concrete derived window type, and made splash screen resource loading more robust (direct paths, guidata image path resolution, safe fallback when the configured resource is missing)" msgstr "Amélioration de l'usine de fenêtres d'application pour préserver le type concret de la fenêtre dérivée, et rendu le chargement des ressources de l'écran de démarrage plus robuste (chemins directs, résolution du chemin de l'image guidata, repli sûr lorsque la ressource configurée est manquante)" msgid "Dropped DataLab code that only duplicated unchanged SigimaX main window behavior (tab icon size handling)" msgstr "Suppression du code DataLab qui ne faisait que dupliquer le comportement inchangé de la fenêtre principale SigimaX (gestion de la taille des icônes des onglets)" msgid "Configuration system" msgstr "Système de configuration" msgid "Simplified application configuration state management: removed unused environment-variable synchronization from application options and simplified get/set/context/serialization/reset operations" msgstr "Simplification de la gestion de l'état de configuration de l'application : suppression de la synchronisation des variables d'environnement inutilisée à partir des options de l'application et simplification des opérations get/set/context/serialization/reset" msgid "Added an `option_changed` hook so derived applications can persist option changes without polling the configuration" msgstr "Ajout d'un hook `option_changed` afin que les applications dérivées puissent persister les modifications des options sans interroger la configuration" msgid "Added a storage serialization protocol (`to_storage`/`from_storage`) to option fields, fixing `to_dict`/`from_dict` and `reset_to_defaults` for fields whose getter/setter transform the stored value" msgstr "Ajout d'un protocole de sérialisation de stockage (`to_storage`/`from_storage`) aux champs d'option, correction de `to_dict`/`from_dict` et `reset_to_defaults` pour les champs dont le getter/setter transforme la valeur stockée" msgid "Let option fields declare their own storage metadata (`storage_key`, `runtime`), removing the need for key-mapping tables in consuming applications" msgstr "Permettre aux champs d'option de déclarer leurs propres métadonnées de stockage (`storage_key`, `runtime`), supprimant ainsi le besoin de tables de mappage de clés dans les applications consommatrices" msgid "Moved generic option field types (`ConfigPathOptionField`, `WorkingDirOptionField`, `FormatStringOptionField`, `DataSetOptionField`, `FontOptionField`) from DataLab into `sigimax.config`, and removed the legacy `Section`/`Option` class hierarchy, which had no remaining consumer" msgstr "Déplacement des types de champs d'option génériques (`ConfigPathOptionField`, `WorkingDirOptionField`, `FormatStringOptionField`, `DataSetOptionField`, `FontOptionField`) de DataLab vers `sigimax.config`, et suppression de l'ancienne hiérarchie de classes `Section`/`Option`, qui n'avait plus de consommateur" msgid "Added support for optional defaults on option fields and for standalone option field containers" msgstr "Ajout de la prise en charge des valeurs par défaut optionnelles sur les champs d'option et des conteneurs de champs d'option autonomes" msgid "Fixed option state not being preserved when restored from a temporary context" msgstr "Correction de l'état des options qui n'était pas préservé lors de la restauration à partir d'un contexte temporaire" msgid "Made PlotPy default colors (foreground/background) color-mode aware by reading them at call time, so re-applying defaults after a color mode change now uses the current theme" msgstr "Rendre les couleurs par défaut de PlotPy (avant-plan/arrière-plan) conscientes du mode couleur en les lisant au moment de l'appel, de sorte que la réapplication des valeurs par défaut après un changement de mode couleur utilise désormais le thème actuel" msgid "HDF5 support" msgstr "Prise en charge de HDF5" msgid "Made the HDF5 import contract explicit: `import_h5_file()` is renamed to `import_all_from_h5_file()` (it imports every supported dataset without a dialog), and a single `_handle_imported_objects()` convergence point now receives the imported objects together with the `reset_all` flag, so a derived application is never asked whether to clear the workspace only to have the answer ignored" msgstr "Rendre le contrat d'importation HDF5 explicite : `import_h5_file()` est renommé en `import_all_from_h5_file()` (il importe tous les ensembles de données pris en charge sans boîte de dialogue), et un point de convergence unique `_handle_imported_objects()` reçoit désormais les objets importés avec le drapeau `reset_all`, de sorte qu'une application dérivée n'est jamais invitée à vider l'espace de travail pour que la réponse soit ignorée" msgid "Added `_is_workspace_empty()` and `_get_clear_workspace_message()` hooks so derived applications can control the \"clear workspace\" question and its wording without reimplementing the import orchestrator" msgstr "Ajout des hooks `_is_workspace_empty()` et `_get_clear_workspace_message()` afin que les applications dérivées puissent contrôler la question \"vider l'espace de travail\" et sa formulation sans réimplémenter l'orchestrateur d'importation" msgid "Fixed safe HDF5 workspace contracts: derived applications are now required to implement workspace persistence instead of silently acknowledging a save that wrote no data" msgstr "Correction des contrats de l'espace de travail HDF5 sécurisé : les applications dérivées doivent désormais implémenter la persistance de l'espace de travail au lieu de reconnaître silencieusement une sauvegarde qui n'a écrit aucune donnée" msgid "Ported the latest HDF5 and widget fixes from DataLab's `develop` branch, including a fix for `DIMENSION_LIST`/`REFERENCE` attributes breaking picklability in `h5/common.py`" msgstr "Portage des dernières corrections HDF5 et des widgets depuis la branche `develop` de DataLab, y compris une correction pour les attributs `DIMENSION_LIST`/`REFERENCE` rompant la picklabilité dans `h5/common.py`" msgid "Widgets" msgstr "Widgets" msgid "Added `DataLabSimpleClient` widgets: a connection dialog and an object browsing dialog for remote-controlling a running DataLab-Kernel instance over HTTP ([DataLab-Platform/DataLab#183](https://github.com/DataLab-Platform/DataLab/issues/183))" msgstr "Ajout des widgets `DataLabSimpleClient` : une boîte de dialogue de connexion et une boîte de dialogue de navigation d'objets pour le contrôle à distance d'une instance DataLab-Kernel en cours d'exécution via HTTP ([DataLab-Platform/DataLab#183](https://github.com/DataLab-Platform/DataLab/issues/183))" msgid "Aligned the plot dock's curve statistics with PlotPy: the hand-written, NaN-robust statistics label functions are replaced by PlotPy's own `CurveStatsTool.LABELFUNCS`, and the y-range cursor sum is now a plain sum instead of a trapezoidal integral, matching PlotPy's behavior" msgstr "Alignement des statistiques de courbes du dock de tracé avec PlotPy : les fonctions d'étiquetage des statistiques écrites à la main et robustes aux NaN sont remplacées par les propres `CurveStatsTool.LABELFUNCS` de PlotPy, et la somme du curseur de l'axe y est désormais une somme simple au lieu d'une intégrale trapézoïdale, correspondant au comportement de PlotPy" msgid "Opened up the plot dock for customization: added `SigimaXPlotWidget._customize_image_panels()` to tweak the X/Y cross section panels, and `DockablePlotWidget.PLOTWIDGET_CLASS` to let a derived application select its own plot widget class" msgstr "Ouverture du dock de tracé à la personnalisation : ajout de `SigimaXPlotWidget._customize_image_panels()` pour ajuster les panneaux de sections transversales X/Y, et de `DockablePlotWidget.PLOTWIDGET_CLASS` pour permettre à une application dérivée de sélectionner sa propre classe de widget de tracé" msgid "Fixed signal update for plain `CurveItem`: when a signal's `xydata` has 4 rows but `dx`/`dy` are all `NaN`, `update_item` no longer calls `set_data` with error-bar arguments on a plain curve item" msgstr "Correction de la mise à jour du signal pour un `CurveItem` simple : lorsqu'un `xydata` d'un signal a 4 lignes mais que `dx`/`dy` sont tous `NaN`, `update_item` n'appelle plus `set_data` avec des arguments de barres d'erreur sur un élément de courbe simple" msgid "PlotPy adapters" msgstr "Adaptateurs PlotPy" msgid "Made the adapter factory overridable: introduced `PlotPyAdapterFactory` with `get_adapter_factory()`/`set_adapter_factory()`/`reset_adapter_factory()` (following the existing `get_conf`/`set_conf`/`reset_conf` pattern), so a derived application can substitute an adapter or register an additional object type without forking the whole dispatch chain" msgstr "Rendre la fabrique d'adaptateurs remplaçable : introduction de `PlotPyAdapterFactory` avec `get_adapter_factory()`/`set_adapter_factory()`/`reset_adapter_factory()` (suivant le modèle existant `get_conf`/`set_conf`/`reset_conf`), afin qu'une application dérivée puisse substituer un adaptateur ou enregistrer un type d'objet supplémentaire sans forker toute la chaîne de dispatch" msgid "Promoted DataLab's signal ROI adapters into SigimaX: ROI fills are now clipped to the signal curve instead of covering the whole canvas height, and sibling ROIs cycle through a color palette indexed by ROI title so colors stay stable across deletions" msgstr "Promotion des adaptateurs ROI de signal de DataLab dans SigimaX : les remplissages ROI sont désormais découpés sur la courbe de signal au lieu de couvrir toute la hauteur de la toile, et les ROI frères parcourent une palette de couleurs indexée par le titre ROI afin que les couleurs restent stables lors des suppressions" msgid "Testing" msgstr "Tests" msgid "Added regression tests for the HDF5 and widget fixes ported from DataLab (interactive fit metadata, `WarningErrorMessageBox` construction, config field coverage, HDF5 utility helpers, `DIMENSION_LIST`/`REFERENCE` picklability, `adapters_plotpy.coordutils` rounding)" msgstr "Ajout de tests de régression pour les corrections HDF5 et les widgets portés depuis DataLab (métadonnées d'ajustement interactif, construction de `WarningErrorMessageBox`, couverture des champs de configuration, helpers utilitaires HDF5, sérialisabilité de `DIMENSION_LIST`/`REFERENCE`, arrondi dans `adapters_plotpy.coordutils`)" msgid "Added tests covering lifecycle hook overridability and call order for the main window setup and shutdown sequence" msgstr "Ajout de tests couvrant la possibilité de remplacer les hooks du cycle de vie et l'ordre d'appel pour la séquence de configuration et de fermeture de la fenêtre principale" msgid "Added tests for splash screen resource resolution (registered resources, missing-image fallback, disabled progress messages)" msgstr "Ajout de tests pour la résolution des ressources de l'écran de démarrage (ressources enregistrées, image manquante de repli, messages de progression désactivés)" msgid "Preserved coverage for the legacy HDF5 dataset selector contract" msgstr "Préservation de la couverture pour le contrat du sélecteur de jeux de données HDF5 hérité" msgid "Cleaned up remaining Pylint and pytest warnings (adapter factory dispatch table, import-outside-toplevel/cyclic-import disables, `pytest.warns` usage)" msgstr "Nettoyage des avertissements Pylint et pytest restants (table de dispatch de la fabrique d'adaptateurs, désactivation des import-outside-toplevel/cyclic-import, utilisation de `pytest.warns`)" msgid "Documentation and internationalization" msgstr "Documentation et internationalisation" msgid "Updated and completed the French translation, including fixes to the SVG icon `.po` file format" msgstr "Mise à jour et finalisation de la traduction française, y compris des corrections du format de fichier `.po` des icônes SVG" msgid "Updated the documentation pipeline and refreshed all user guide pages, examples and API references to reflect the simplified configuration API, the new lifecycle hooks and the `DataLabSimpleClient` widgets" msgstr "Mise à jour du pipeline de documentation et actualisation de toutes les pages du guide de l'utilisateur, des exemples et des références API pour refléter l'API de configuration simplifiée, les nouveaux hooks du cycle de vie et les widgets `DataLabSimpleClient`" msgid "Updated the documentation banner" msgstr "Mise à jour de la bannière de documentation" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/requirements.po000066400000000000000000000116271524425144300257510ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "The `sigimax` package requires the following Python modules:" msgstr "Le paquet `sigimax` nécessite les modules Python suivants :" msgid "Name" msgstr "Nom" msgid "Version" msgstr "Version" msgid "Summary" msgstr "Résumé" msgid "Python" msgstr "Python" msgid ">=3.9, <4" msgstr ">=3.9, <4" msgid "Python programming language" msgstr "Langage de programmation Python" msgid "guidata" msgstr "guidata" msgid ">= 3.15.0" msgstr ">= 3.15.0" msgid "Automatic GUI generation for easy dataset editing and display" msgstr "Génération automatique d'interface graphique pour une édition et un affichage facile des jeux de données" msgid "PlotPy" msgstr "PlotPy" msgid ">= 2.11.0" msgstr ">= 2.11.0" msgid "Curve and image plotting tools for Python/Qt applications" msgstr "Outils de traçage de courbes et d'images pour les applications Python/Qt" msgid "psutil" msgstr "psutil" msgid ">= 5.7" msgstr ">= 5.7" msgid "Cross-platform lib for process and system monitoring." msgstr "Bibliothèque multiplateforme pour la surveillance des processus et du système." msgid "Sigima" msgstr "Sigima" msgid ">= 1.2.0" msgstr ">= 1.2.0" msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform." msgstr "Moteur de calcul scientifique pour les signaux 1D et les images 2D, faisant partie de la plateforme open-source DataLab." msgid "Optional modules for GUI support (Qt):" msgstr "Modules facultatifs pour la prise en charge de l'interface graphique (Qt) :" msgid "PyQt5" msgstr "PyQt5" msgid ">= 5.15.6" msgstr ">= 5.15.6" msgid "Python bindings for the Qt cross platform application toolkit" msgstr "Liaisons Python pour l'outil de développement d'applications multiplateforme Qt" msgid "Optional modules for development:" msgstr "Modules facultatifs pour le développement :" msgid "ruff" msgstr "ruff" msgid "An extremely fast Python linter and code formatter, written in Rust." msgstr "Un linter Python extrêmement rapide et un formateur de code, écrit en Rust." msgid "pylint" msgstr "pylint" msgid "python code static checker" msgstr "Analyseur statique de code Python" msgid "Coverage" msgstr "Coverage" msgid "Code coverage measurement for Python" msgstr "Mesure de la couverture du code pour Python" msgid "Optional modules for building the documentation:" msgstr "Modules facultatifs pour la construction de la documentation :" msgid "sphinx" msgstr "sphinx" msgid "Python documentation generator" msgstr "Générateur de documentation Python" msgid "sphinx_intl" msgstr "sphinx_intl" msgid "Sphinx utility that make it easy to translate and to apply translation." msgstr "Utilitaire Sphinx qui facilite la traduction et l'application de la traduction." msgid "sphinx-sitemap" msgstr "sphinx-sitemap" msgid "Sitemap generator for Sphinx" msgstr "Générateur de plan de site pour Sphinx" msgid "myst_parser" msgstr "myst_parser" msgid "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," msgstr "Un analyseur conforme à [CommonMark](https://spec.commonmark.org/) étendu," msgid "myst-nb" msgstr "myst-nb" msgid "A Jupyter Notebook Sphinx reader built on top of the MyST markdown parser." msgstr "Un lecteur Sphinx de notebooks Jupyter construit au-dessus de l'analyseur Markdown MyST." msgid "sphinx_design" msgstr "sphinx_design" msgid "A sphinx extension for designing beautiful, view size responsive web components." msgstr "Une extension Sphinx pour concevoir de beaux composants web réactifs à la taille de la vue." msgid "sphinx_gallery" msgstr "sphinx_gallery" msgid "A Sphinx extension that builds an HTML gallery of examples from any set of Python scripts." msgstr "Une extension Sphinx qui crée une galerie HTML d'exemples à partir de n'importe quel ensemble de scripts Python." msgid "sphinx-copybutton" msgstr "sphinx-copybutton" msgid "Add a copy button to each of your code cells." msgstr "Ajoute un bouton de copie à chacune de vos cellules de code." msgid "pydata-sphinx-theme" msgstr "pydata-sphinx-theme" msgid "Bootstrap-based Sphinx theme from the PyData community" msgstr "Thème Sphinx basé sur Bootstrap de la communauté PyData" msgid "Optional modules for running test suite:" msgstr "Modules facultatifs pour exécuter la suite de tests :" msgid "pytest" msgstr "pytest" msgid "pytest: simple powerful testing with Python" msgstr "pytest : tests simples et puissants avec Python" msgid "pytest-xvfb" msgstr "pytest-xvfb" msgid "A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests." msgstr "Un plugin pytest pour exécuter Xvfb (ou Xephyr/Xvnc) pour les tests." DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/000077500000000000000000000000001524425144300250125ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/features.po000066400000000000000000000347311524425144300272000ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Features" msgstr "Fonctionnalités" msgid "This page provides an organized catalog of SigimaX's key features." msgstr "Cette page fournit un catalogue organisé des principales fonctionnalités de SigimaX." msgid "Application Framework" msgstr "Framework applicatif" msgid "Feature" msgstr "Fonctionnalité" msgid "Description" msgstr "Description" msgid ":class:`~sigimax.mainwindow.SGMXMainWindow`" msgstr ":class:`~sigimax.mainwindow.SGMXMainWindow`" msgid "Generic main window with customizable menus, toolbars, console, and dock widgets. Derived apps subclass this to build their own UI." msgstr "Fenêtre principale générique avec menus, barres d'outils, console et widgets ancrables personnalisables. Les applications dérivées en héritent pour construire leur propre interface." msgid ":func:`~sigimax.app.create`" msgstr ":func:`~sigimax.app.create`" msgid "Instantiate a main window with optional splash screen, console, and size. Returns the window instance for embedding." msgstr "Instancie une fenêtre principale avec écran de démarrage, console et taille optionnels. Retourne l'instance de la fenêtre pour l'intégration." msgid ":func:`~sigimax.app.run`" msgstr ":func:`~sigimax.app.run`" msgid "Create the window and enter the Qt event loop. The standard entry point for standalone applications." msgstr "Crée la fenêtre et entre dans la boucle d'événements Qt. Le point d'entrée standard pour les applications autonomes." msgid ":class:`~sigimax.widgets.splashscreen.SplashScreenConfig`" msgstr ":class:`~sigimax.widgets.splashscreen.SplashScreenConfig`" msgid "Configurable splash screen with image, app name, version, tagline, and optional progress display." msgstr "Écran de démarrage configurable avec image, nom de l'application, version, slogan et affichage optionnel de la progression." msgid "Configuration System" msgstr "Système de configuration" msgid ":class:`~sigimax.config.SigimaXOptions`" msgstr ":class:`~sigimax.config.SigimaXOptions`" msgid "Full configuration singleton (``CONF``) with 20+ typed options covering app metadata, plot defaults, HDF5 settings, and more." msgstr "Singleton de configuration complet (``CONF``) avec plus de 20 options typées couvrant les métadonnées de l'application, les paramètres par défaut des tracés, les paramètres HDF5, et plus encore." msgid "``EnumOptionField``" msgstr "``EnumOptionField``" msgid "Option constrained to a set of string choices, with validation." msgstr "Option contrainte à un ensemble de choix de chaînes de caractères, avec validation." msgid "``TupleOptionField``" msgstr "``TupleOptionField``" msgid "Fixed-length tuple option with type checking." msgstr "Option de tuple de longueur fixe avec vérification de type." msgid "``FontOptionField``" msgstr "``FontOptionField``" msgid "Font option with validation against available system fonts." msgstr "Option de police avec validation par rapport aux polices système disponibles." msgid "JSON persistence" msgstr "Persistance JSON" msgid "``save()`` / ``load()`` methods for configuration persistence." msgstr "Méthodes ``save()`` / ``load()`` pour la persistance de la configuration." msgid "Context managers" msgstr "Gestionnaires de contexte" msgid "Temporary overrides with ``option.context(value)`` pattern." msgstr "Surcharges temporaires avec le patron ``option.context(value)``." msgid "Configuration options reference" msgstr "Référence des options de configuration" msgid "The full list of configuration options available in the ``CONF`` singleton:" msgstr "La liste complète des options de configuration disponibles dans le singleton ``CONF`` :" msgid "HDF5 Workspace" msgstr "Espace de travail HDF5" msgid "Open/Save workspace" msgstr "Ouvrir/Enregistrer l'espace de travail" msgid "File actions and workspace-state handling. Derived applications provide their own workspace serialization." msgstr "Actions de fichier et gestion de l'état de l'espace de travail. Les applications dérivées fournissent leur propre sérialisation de l'espace de travail." msgid ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" msgstr ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" msgid "Interactive HDF5 file browser with tree view, supporting scalar, array, text, and compound datasets." msgstr "Explorateur interactif de fichiers HDF5 avec vue en arbre, supportant les jeux de données scalaires, tableaux, texte et composés." msgid "Import datasets" msgstr "Importer des jeux de données" msgid "``import_dataset_from_file()`` hook for derived apps to handle application-specific HDF5 dataset import." msgstr "Hook ``import_dataset_from_file()`` pour que les applications dérivées gèrent l'import de jeux de données HDF5 spécifiques à l'application." msgid ":class:`~sigimax.h5.H5Importer`" msgstr ":class:`~sigimax.h5.H5Importer`" msgid "Low-level HDF5 import utilities with node factory and data extraction." msgstr "Utilitaires d'import HDF5 bas niveau avec fabrique de nÅ“uds et extraction de données." msgid "See :doc:`hdf5_workspace` for the persistence contract and its complete derived-application example." msgstr "Voir :doc:`hdf5_workspace` pour le contrat de persistance et son exemple complet d'application dérivée." msgid "Scientific Widgets" msgstr "Widgets scientifiques" msgid "Widget" msgstr "Widget" msgid ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" msgstr ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" msgid "Embeds PlotPy plots in dock widgets, with configurable watermark and dock location. Supports ``PlotType.CURVE`` and ``PlotType.IMAGE``." msgstr "Intègre des tracés PlotPy dans des widgets ancrables, avec filigrane et position d'ancrage configurables. Supporte ``PlotType.CURVE`` et ``PlotType.IMAGE``." msgid "Curve fitting dialogs" msgstr "Dialogues d'ajustement de courbe" msgid "Gaussian, polynomial, and custom curve fitting via :mod:`sigimax.widgets.fitdialog`." msgstr "Ajustement de courbe gaussien, polynomial et personnalisé via :mod:`sigimax.widgets.fitdialog`." msgid "Signal peak detection" msgstr "Détection de pics de signal" msgid "Interactive peak detection dialog via :mod:`sigimax.widgets.signalpeak`." msgstr "Dialogue de détection de pics interactif via :mod:`sigimax.widgets.signalpeak`." msgid "Signal baseline selection" msgstr "Sélection de ligne de base" msgid "Baseline selection for background subtraction via :mod:`sigimax.widgets.signalbaseline`." msgstr "Sélection de la ligne de base pour la soustraction du fond via :mod:`sigimax.widgets.signalbaseline`." msgid "Signal cursor" msgstr "Curseur de signal" msgid "Cursor-based value readout via :mod:`sigimax.widgets.signalcursor`." msgstr "Lecture de valeur par curseur via :mod:`sigimax.widgets.signalcursor`." msgid "Signal delta-X" msgstr "Delta-X de signal" msgid "Delta-X measurement between two points via :mod:`sigimax.widgets.signaldeltax`." msgstr "Mesure du delta-X entre deux points via :mod:`sigimax.widgets.signaldeltax`." msgid "Image background" msgstr "Fond d'image" msgid "Image background region selection via :mod:`sigimax.widgets.imagebackground`." msgstr "Sélection de la région de fond d'image via :mod:`sigimax.widgets.imagebackground`." msgid ":class:`~sigimax.widgets.wizard.Wizard`" msgstr ":class:`~sigimax.widgets.wizard.Wizard`" msgid "Multi-page wizard dialog with navigation, validation, and data collection (Next/Back/Finish/Cancel)." msgstr "Dialogue assistant multi-pages avec navigation, validation et collecte de données (Suivant/Précédent/Terminer/Annuler)." msgid ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" msgstr ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" msgid "Log viewer dialog for displaying application log files." msgstr "Dialogue de visualisation des journaux pour l'affichage des fichiers journaux de l'application." msgid ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" msgstr ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" msgid "Warning/error display dialog with traceback support." msgstr "Dialogue d'affichage des avertissements et erreurs avec support des traces d'exécution." msgid "Remote Control Widgets" msgstr "Widgets de contrôle à distance" msgid ":class:`~sigimax.widgets.connection.ConnectionDialog`" msgstr ":class:`~sigimax.widgets.connection.ConnectionDialog`" msgid "Modal dialog showing the progress of a connection attempt to a DataLab-derived application server, via a blocking connect callback run in a background thread." msgstr "Dialogue modal affichant la progression d'une tentative de connexion à un serveur d'application dérivé de DataLab, via un callback de connexion bloquant exécuté dans un thread en arrière-plan." msgid ":class:`~sigimax.widgets.objectdialog.GetObjectDialog`" msgstr ":class:`~sigimax.widgets.objectdialog.GetObjectDialog`" msgid "Dialog to browse and pick a signal or image among the objects available on a connected server." msgstr "Dialogue pour parcourir et sélectionner un signal ou une image parmi les objets disponibles sur un serveur connecté." msgid ":class:`~sigimax.widgets.objectdialog.SimpleObjectTree`" msgstr ":class:`~sigimax.widgets.objectdialog.SimpleObjectTree`" msgid "Tree widget populated from a remote proxy's groups and objects, used internally by ``GetObjectDialog``." msgstr "Widget en arbre peuplé à partir des groupes et objets d'un proxy distant, utilisé en interne par ``GetObjectDialog``." msgid ":mod:`sigimax.utils.svg_icons`" msgstr ":mod:`sigimax.utils.svg_icons`" msgid "Generic signal/image/group icons shared by the widgets above." msgstr "Icônes génériques de signal/image/groupe partagées par les widgets ci-dessus." msgid "These widgets only depend on :class:`sigima.client.SimpleRemoteProxy` (or a compatible proxy), never on a specific application's branding or object model. See :doc:`remote_control` for a complete guide and usage examples." msgstr "Ces widgets ne dépendent que de :class:`sigima.client.SimpleRemoteProxy` (ou d'un proxy compatible), jamais de l'image de marque ou du modèle d'objet d'une application spécifique. Voir :doc:`remote_control` pour un guide complet et des exemples d'utilisation." msgid "Status Bar Widgets" msgstr "Widgets de barre d'état" msgid ":class:`~sigimax.widgets.status.MemoryStatus`" msgstr ":class:`~sigimax.widgets.status.MemoryStatus`" msgid "Displays current memory usage with configurable alarm threshold." msgstr "Affiche l'utilisation mémoire courante avec un seuil d'alarme configurable." msgid ":class:`~sigimax.widgets.status.ConsoleStatus`" msgstr ":class:`~sigimax.widgets.status.ConsoleStatus`" msgid "Console toggle button in the status bar." msgstr "Bouton d'activation/désactivation de la console dans la barre d'état." msgid ":class:`~sigimax.widgets.status.BaseStatus`" msgstr ":class:`~sigimax.widgets.status.BaseStatus`" msgid "Base status bar widget for custom status indicators." msgstr "Widget de barre d'état de base pour les indicateurs d'état personnalisés." msgid "PlotPy Adapters" msgstr "Adaptateurs PlotPy" msgid "Signal/Image adapters" msgstr "Adaptateurs Signal/Image" msgid "Convert between Sigima objects (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`) and PlotPy plot items for display." msgstr "Conversion entre les objets Sigima (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`) et les éléments de tracé PlotPy pour l'affichage." msgid "ROI adapters" msgstr "Adaptateurs ROI" msgid "Convert between Sigima ROI objects and PlotPy annotation items (segment, rectangular, circular, polygonal)." msgstr "Conversion entre les objets ROI Sigima et les éléments d'annotation PlotPy (segment, rectangulaire, circulaire, polygonal)." msgid ":func:`~sigimax.adapters_plotpy.create_adapter_from_object`" msgstr ":func:`~sigimax.adapters_plotpy.create_adapter_from_object`" msgid "Factory function that dispatches to the correct adapter based on object type." msgstr "Fonction fabrique qui redirige vers l'adaptateur correct en fonction du type d'objet." msgid "JSON roundtrips" msgstr "Allers-retours JSON" msgid "``items_to_json()`` / ``json_to_items()`` for serializing plot items." msgstr "``items_to_json()`` / ``json_to_items()`` pour la sérialisation des éléments de tracé." msgid "Runtime Environment" msgstr "Environnement d'exécution" msgid ":class:`~sigimax.env.SGMXExecEnv`" msgstr ":class:`~sigimax.env.SGMXExecEnv`" msgid "Runtime environment singleton (``execenv``) controlling unattended mode, verbosity levels, demo mode, screenshot capture, and delay settings." msgstr "Singleton d'environnement d'exécution (``execenv``) contrôlant le mode sans interaction, les niveaux de verbosité, le mode démo, la capture d'écran et les paramètres de délai." msgid ":class:`~sigimax.env.VerbosityLevels`" msgstr ":class:`~sigimax.env.VerbosityLevels`" msgid "Enum with ``quiet``, ``normal``, and ``debug`` verbosity levels." msgstr "\\u00c9num\\u00e9ration avec les niveaux de verbosit\\u00e9 ``quiet``, ``normal`` et ``debug``." msgid "Command-line arguments" msgstr "Arguments en ligne de commande" msgid "``--unattended``, ``--verbose``, ``--screenshot``, ``--delay``, ``--version``, ``--reset`` parsed automatically on startup." msgstr "``--unattended``, ``--verbose``, ``--screenshot``, ``--delay``, ``--version``, ``--reset`` analysés automatiquement au démarrage." msgid "Context manager" msgstr "Gestionnaire de contexte" msgid "``execenv.context()`` for temporarily overriding environment settings." msgstr "``execenv.context()`` pour surcharger temporairement les paramètres d'environnement." msgid "Internationalization" msgstr "Internationalisation" msgid "SigimaX supports internationalization with gettext:" msgstr "SigimaX supporte l'internationalisation avec gettext :" msgid "All UI strings are wrapped with ``_()`` from :mod:`sigimax.config`" msgstr "Toutes les chaînes de l'interface sont encapsulées avec ``_()`` depuis :mod:`sigimax.config`" msgid "Translations are stored in ``locale/`` (currently English and French)" msgstr "Les traductions sont stockées dans ``locale/`` (actuellement anglais et français)" msgid "Use ``guidata.utils.translations`` CLI to scan and compile translations" msgstr "Utilisez la CLI ``guidata.utils.translations`` pour analyser et compiler les traductions" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/getting_started.po000066400000000000000000000163141524425144300305460ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Getting Started" msgstr "Premiers pas" msgid "This page provides a quick introduction to building applications with SigimaX." msgstr "Cette page fournit une introduction rapide à la construction d'applications avec SigimaX." msgid "SigimaX follows a **three-step derivation pattern**: subclass the configuration, subclass the main window, and launch with ``run()``. This pattern is proven in production — `DataLab `_ is built entirely on it." msgstr "SigimaX suit un **patron de dérivation en trois étapes** : hériter de la configuration, hériter de la fenêtre principale et lancer avec ``run()``. Ce patron est éprouvé en production — `DataLab `_ est entièrement construit dessus." msgid "The Derivation Pattern" msgstr "Le patron de dérivation" msgid "**Step 1 — Subclass** :class:`~sigimax.config.SigimaXOptions` to add application-specific configuration fields:" msgstr "**Étape 1 — Hériter de** :class:`~sigimax.config.SigimaXOptions` pour ajouter des champs de configuration spécifiques à l'application :" msgid "**Step 2 — Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize the user interface:" msgstr "**Étape 2 — Hériter de** :class:`~sigimax.mainwindow.SGMXMainWindow` pour personnaliser l'interface utilisateur :" msgid "**Step 3 — Launch** the application:" msgstr "**Étape 3 — Lancer** l'application :" msgid "That's it. Three classes, three steps — and you have a full-featured scientific desktop application with menus, toolbars, console, HDF5 workspace, and status bar." msgstr "C'est tout. Trois classes, trois étapes — et vous avez une application scientifique de bureau complète avec menus, barres d'outils, console, espace de travail HDF5 et barre d'état." msgid "Key Concepts" msgstr "Concepts clés" msgid "**Configuration system**: Options are typed fields (``TypedOptionField``, ``EnumOptionField``, ``TupleOptionField``) that support ``get()``/``set()``/ ``context()`` API and JSON persistence." msgstr "**Système de configuration** : les options sont des champs typés (``TypedOptionField``, ``EnumOptionField``, ``TupleOptionField``) qui supportent l'API ``get()``/``set()``/ ``context()``, la persistance JSON et la synchronisation avec les variables d'environnement." msgid "**Overridable hooks**: The main window provides hooks that derived apps can override: ``reset_all()``, ``_is_save_enabled()``, ``_update_file_menu()``, ``_update_view_menu()``, ``_about()``, ``_before_setup()``, and ``_after_setup()``. See :doc:`lifecycle` for their ordering and initialization contract." msgstr "**Hooks redéfinissables** : la fenêtre principale fournit des hooks que les applications dérivées peuvent redéfinir : ``reset_all()``, ``_is_save_enabled()``, ``_update_file_menu()``, ``_update_view_menu()``, ``_about()``." msgid "**Built-in features**: HDF5 GUI and generic dataset import; derived applications implement their own workspace persistence. SigimaX also provides an embedded Python console, status bar with memory monitoring, and splash screen support. See :doc:`hdf5_workspace` for the derived-application contract." msgstr "**Fonctionnalités intégrées** : interface HDF5 et import générique de jeux de données ; les applications dérivées implémentent leur propre persistance de l'espace de travail. SigimaX fournit également une console Python intégrée, une barre d'état avec surveillance mémoire et la prise en charge d'un écran de démarrage. Voir :doc:`hdf5_workspace` pour le contrat des applications dérivées." msgid "What's Included" msgstr "Contenu inclus" msgid "SigimaX provides 15+ ready-to-use scientific widgets:" msgstr "SigimaX fournit plus de 15 widgets scientifiques prêts à l'emploi :" msgid "Widget" msgstr "Widget" msgid "Purpose" msgstr "Objectif" msgid ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" msgstr ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" msgid "Embeddable PlotPy plot in dock widgets" msgstr "Tracé PlotPy intégrable dans des widgets ancrables" msgid ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" msgstr ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" msgid "Interactive HDF5 file browser" msgstr "Explorateur interactif de fichiers HDF5" msgid ":class:`~sigimax.widgets.fitdialog`" msgstr ":class:`~sigimax.widgets.fitdialog`" msgid "Curve fitting dialogs (Gaussian, polynomial, etc.)" msgstr "Dialogues d'ajustement de courbe (gaussien, polynomial, etc.)" msgid ":class:`~sigimax.widgets.signalpeak`" msgstr ":class:`~sigimax.widgets.signalpeak`" msgid "Signal peak detection dialog" msgstr "Dialogue de détection de pics de signal" msgid ":class:`~sigimax.widgets.signalbaseline`" msgstr ":class:`~sigimax.widgets.signalbaseline`" msgid "Signal baseline selection dialog" msgstr "Dialogue de sélection de ligne de base" msgid ":class:`~sigimax.widgets.signalcursor`" msgstr ":class:`~sigimax.widgets.signalcursor`" msgid "Signal cursor selection dialog" msgstr "Dialogue de sélection par curseur" msgid ":class:`~sigimax.widgets.signaldeltax`" msgstr ":class:`~sigimax.widgets.signaldeltax`" msgid "Signal delta-X measurement dialog" msgstr "Dialogue de mesure de delta-X" msgid ":class:`~sigimax.widgets.imagebackground`" msgstr ":class:`~sigimax.widgets.imagebackground`" msgid "Image background selection dialog" msgstr "Dialogue de sélection du fond d'image" msgid ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" msgstr ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" msgid "Log viewer dialog" msgstr "Dialogue de visualisation des journaux" msgid ":class:`~sigimax.widgets.wizard.Wizard`" msgstr ":class:`~sigimax.widgets.wizard.Wizard`" msgid "Multi-page wizard dialog" msgstr "Dialogue assistant multi-pages" msgid ":class:`~sigimax.widgets.splashscreen.SigimaXSplashScreen`" msgstr ":class:`~sigimax.widgets.splashscreen.SigimaXSplashScreen`" msgid "Configurable splash screen" msgstr "Écran de démarrage configurable" msgid ":class:`~sigimax.widgets.status.MemoryStatus`" msgstr ":class:`~sigimax.widgets.status.MemoryStatus`" msgid "Status bar memory usage widget" msgstr "Widget de barre d'état d'utilisation mémoire" msgid ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" msgstr ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" msgid "Warning/error display dialog" msgstr "Dialogue d'affichage des avertissements et erreurs" msgid "Next Steps" msgstr "Étapes suivantes" msgid "Browse the :doc:`../auto_examples/index` to see SigimaX in action" msgstr "Parcourez les :doc:`../auto_examples/index` pour voir SigimaX en action" msgid "Read the :doc:`overview` for architecture details" msgstr "Lisez la :doc:`overview` pour les détails de l'architecture" msgid "Dive into the :doc:`/api/index` for complete reference documentation" msgstr "Plongez dans la :doc:`/api/index` pour la documentation de référence complète" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/hdf5_workspace.po000066400000000000000000000163071524425144300302650ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "HDF5 Workspace" msgstr "Espace de travail HDF5" msgid "SigimaX provides the HDF5 user interface and generic dataset import. A derived application owns its workspace model and its workspace file format. SigimaX does not define a universal workspace schema." msgstr "SigimaX fournit l'interface utilisateur HDF5 et l'import générique de jeux de données. Une application dérivée possède son propre modèle d'espace de travail et son propre format de fichier d'espace de travail. SigimaX ne définit pas de schéma d'espace de travail universel." msgid "Responsibilities" msgstr "Responsabilités" msgid "SigimaX provides:" msgstr "SigimaX fournit :" msgid "File, Open/Import, Browse, and Save actions;" msgstr "les actions Fichier, Ouvrir/Importer, Parcourir et Enregistrer ;" msgid "the HDF5 browser and :class:`sigimax.h5.H5Importer` for arbitrary datasets;" msgstr "le navigateur HDF5 et :class:`sigimax.h5.H5Importer` pour des jeux de données arbitraires ;" msgid ":meth:`sigimax.mainwindow.SGMXMainWindow.set_modified`, the save prompt, and the close workflow;" msgstr ":meth:`sigimax.mainwindow.SGMXMainWindow.set_modified`, l'invite d'enregistrement et le flux de fermeture ;" msgid "protected HDF5 hooks for derived applications." msgstr "des points d'extension HDF5 protégés pour les applications dérivées." msgid "A derived application provides:" msgstr "Une application dérivée fournit :" msgid "its data model;" msgstr "son modèle de données ;" msgid "workspace serialization and deserialization;" msgstr "la sérialisation et la désérialisation de l'espace de travail ;" msgid "the mutation points that mark the workspace modified;" msgstr "les points de mutation qui marquent l'espace de travail comme modifié ;" msgid "optional application-specific dataset import." msgstr "l'import de jeux de données spécifique à l'application, optionnel." msgid "A base :class:`~sigimax.mainwindow.SGMXMainWindow` has no data model. Its Save action remains disabled and a direct call to ``save_h5_workspace`` raises :class:`NotImplementedError`. This prevents a successful-looking save from silently discarding a modified workspace." msgstr "Une :class:`~sigimax.mainwindow.SGMXMainWindow` de base n'a pas de modèle de données. Son action Enregistrer reste désactivée et un appel direct à ``save_h5_workspace`` lève :class:`NotImplementedError`. Cela évite qu'un enregistrement apparemment réussi ne rejette silencieusement un espace de travail modifié." msgid "Minimal Derived Application" msgstr "Application dérivée minimale" msgid "The SigimaX test suite contains an executable reference application. It is both an integration test and a complete minimal example: it stores signals and images, imports generic HDF5 datasets, and writes a small workspace format." msgstr "La suite de tests de SigimaX contient une application de référence exécutable. C'est à la fois un test d'intégration et un exemple minimal complet : elle stocke des signaux et des images, importe des jeux de données HDF5 génériques et écrit un petit format d'espace de travail." msgid "Its data model deliberately remains application code. It uses two collections and knows how to serialize them with ``guidata``:" msgstr "Son modèle de données reste délibérément du code applicatif. Elle utilise deux collections et sait les sérialiser avec ``guidata`` :" msgid "The derived window validates paths through the protected hook, writes its model, and only clears the modified flag after a successful write:" msgstr "La fenêtre dérivée valide les chemins via le point d'extension protégé, écrit son modèle, et ne réinitialise l'indicateur modifié qu'après une écriture réussie :" msgid "The matching loader is equally application-specific:" msgstr "Le chargeur correspondant est tout aussi spécifique à l'application :" msgid "Workspace State and Save" msgstr "État de l'espace de travail et enregistrement" msgid "Call ``set_modified(True)`` whenever an application mutation changes the workspace. The framework adds an asterisk to the window title, enables Save for windows that implement ``save_h5_workspace``, and asks for confirmation when the user closes a modified window." msgstr "Appelez ``set_modified(True)`` chaque fois qu'une mutation applicative modifie l'espace de travail. Le framework ajoute un astérisque au titre de la fenêtre, active Enregistrer pour les fenêtres qui implémentent ``save_h5_workspace``, et demande confirmation lorsque l'utilisateur ferme une fenêtre modifiée." msgid "A successful ``save_h5_workspace`` implementation must call ``set_modified(False)`` only after its writer closes without an exception. An exception or a cancelled save leaves the workspace modified, so the close flow remains safe." msgstr "Une implémentation réussie de ``save_h5_workspace`` ne doit appeler ``set_modified(False)`` qu'après que son writer se soit fermé sans exception. Une exception ou un enregistrement annulé laisse l'espace de travail modifié, ce qui garde le flux de fermeture sûr." msgid "Generic Dataset Import" msgstr "Import générique de jeux de données" msgid "``open_h5_files`` and :class:`~sigimax.widgets.h5browser.H5BrowserDialog` are for importing arbitrary HDF5 datasets. They are independent from an application's workspace loader. The reference application connects ``SIG_SEND_OBJECTLIST`` to its model and implements ``import_dataset_from_file`` for programmatic selection of a dataset." msgstr "``open_h5_files`` et :class:`~sigimax.widgets.h5browser.H5BrowserDialog` servent à importer des jeux de données HDF5 arbitraires. Ils sont indépendants du chargeur d'espace de travail d'une application. L'application de référence connecte ``SIG_SEND_OBJECTLIST`` à son modèle et implémente ``import_dataset_from_file`` pour la sélection programmatique d'un jeu de données." msgid "The generic import layer can create :class:`sigima.objects.SignalObj` and :class:`sigima.objects.ImageObj`; a derived application decides how these objects enter its own model." msgstr "La couche d'import générique peut créer des :class:`sigima.objects.SignalObj` et :class:`sigima.objects.ImageObj` ; une application dérivée décide comment ces objets entrent dans son propre modèle." msgid "DataLab" msgstr "DataLab" msgid "DataLab keeps its native workspace format, panel serialization, metadata, ROI, and analysis-result handling. Its ``save_h5_workspace`` override remains the owner of that format. The SigimaX HDF5 GUI and generic import infrastructure do not change DataLab's native HDF5 layout." msgstr "DataLab conserve son format d'espace de travail natif, la sérialisation de ses panneaux, ses métadonnées, ses ROI et la gestion des résultats d'analyse. Sa surcharge de ``save_h5_workspace`` reste propriétaire de ce format. L'interface HDF5 et l'infrastructure d'import générique de SigimaX ne modifient pas la disposition HDF5 native de DataLab." DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/index.po000066400000000000000000000107101524425144300264600ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Contents:" msgstr "Sommaire :" msgid "User Guide" msgstr "Manuel utilisateur" msgid " Installation" msgstr " Installation" msgid "How to install SigimaX" msgstr "Comment installer SigimaX" msgid " Getting Started" msgstr ":octicon:`rocket;1em;sd-text-info` Premiers pas" msgid "Build your first app in minutes" msgstr "Construisez votre première application en quelques minutes" msgid " Overview" msgstr ":octicon:`book;1em;sd-text-info` Aperçu" msgid "Architecture and design philosophy" msgstr "Architecture et philosophie de conception" msgid " Features" msgstr ":octicon:`star;1em;sd-text-info` Fonctionnalités" msgid "Key functionalities" msgstr "Fonctionnalités clés" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/installation.po000066400000000000000000000221371524425144300300600ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Installation" msgstr "Installation" msgid "This section provides instructions on how to install and set up SigimaX, including dependencies and environment configuration." msgstr "Cette section fournit les instructions pour installer et configurer SigimaX, y compris les dépendances et la configuration de l'environnement." msgid "How to install" msgstr "Modes d'installation" msgid "SigimaX is available in several forms:" msgstr "SigimaX est disponible sous plusieurs formes :" msgid "As a Python package, which can be installed using the :ref:`install_pip`." msgstr "En tant que paquet Python, qui peut être installé en utilisant le :ref:`install_pip`." msgid "As a precompiled :ref:`install_wheel`, which can be installed using ``pip``." msgstr "En tant que :ref:`install_wheel` précompilé, qui peut être installé en utilisant ``pip``." msgid "As a :ref:`install_source`, which can be installed from the Git repository." msgstr "En tant que :ref:`install_source`, qui peut être installé depuis le dépôt Git." msgid "Impatient to try the next version of SigimaX? You can also install the latest development version from the main branch of the Git repository. See :ref:`install_development` for more information." msgstr "Impatient d'essayer la prochaine version de SigimaX ? Vous pouvez également installer la dernière version de développement depuis la branche principale du dépôt Git. Voir :ref:`install_development` pour plus d'informations." msgid "Package manager ``pip``" msgstr "Gestionnaire de paquets ``pip``" msgid ":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS`" msgstr ":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS`" msgid "SigimaX's package ``sigimax`` is available on the Python Package Index (PyPI) at: https://pypi.python.org/pypi/sigimax." msgstr "Le paquet ``sigimax`` de SigimaX est disponible sur le Python Package Index (PyPI) à l'adresse : https://pypi.python.org/pypi/sigimax." msgid "Install SigimaX by running:" msgstr "Installez SigimaX en exécutant :" msgid "If you already have a previous version of SigimaX installed, you can upgrade it by running the same command with the ``--upgrade`` option:" msgstr "Si vous avez déjà une version précédente de SigimaX installée, vous pouvez la mettre à jour en exécutant la même commande avec l'option ``--upgrade`` :" msgid "Wheel package" msgstr "Paquet Wheel" msgid "On any operating system, using pip and the Wheel package is the easiest way to install SigimaX on an existing Python distribution:" msgstr "Sur tout système d'exploitation, utiliser pip et le paquet Wheel est le moyen le plus simple d'installer SigimaX sur une distribution Python existante :" msgid "Source package" msgstr "Paquet source" msgid "Installing SigimaX directly from the source package may be done using ``pip``:" msgstr "L'installation de SigimaX directement depuis le paquet source peut être effectuée en utilisant ``pip`` :" msgid "Or, if you prefer, you can install it in editable mode from the root directory of the source package:" msgstr "Ou, si vous préférez, vous pouvez l'installer en mode éditable depuis le répertoire racine du paquet source :" msgid "Development version" msgstr "Version de développement" msgid "If you want to try the latest development version of SigimaX, you can install it directly from the main branch of the Git repository." msgstr "Si vous souhaitez essayer la dernière version de développement de SigimaX, vous pouvez l'installer directement depuis la branche principale du dépôt Git." msgid "The first time you install SigimaX from the Git repository, enter the following command:" msgstr "La première fois que vous installez SigimaX depuis le dépôt Git, entrez la commande suivante :" msgid "Then, if at some point you want to upgrade to the latest version, run:" msgstr "Ensuite, si à un moment donné vous souhaitez mettre à jour vers la dernière version, exécutez :" msgid "If dependencies have changed, you may need to execute the same command without the ``--no-deps`` option." msgstr "Si les dépendances ont changé, vous pourriez avoir besoin d'exécuter la même commande sans l'option ``--no-deps``." msgid "Dependencies" msgstr "Dépendances" msgid "The `sigimax` package requires the following Python modules:" msgstr "Le paquet `sigimax` nécessite les modules Python suivants :" msgid "Name" msgstr "Nom" msgid "Version" msgstr "Version" msgid "Summary" msgstr "Description" msgid "Python" msgstr "Python" msgid ">=3.9, <4" msgstr ">=3.9, <4" msgid "Python programming language" msgstr "Langage de programmation Python" msgid "guidata" msgstr "guidata" msgid ">= 3.15.0" msgstr ">= 3.15.0" msgid "Automatic GUI generation for easy dataset editing and display" msgstr "Génération automatique d'interface graphique pour une édition et un affichage facile des jeux de données" msgid "PlotPy" msgstr "PlotPy" msgid ">= 2.11.0" msgstr ">= 2.11.0" msgid "Curve and image plotting tools for Python/Qt applications" msgstr "Outils de tracé de courbes et d'images pour les applications Python/Qt" msgid "psutil" msgstr "psutil" msgid ">= 5.7" msgstr ">= 5.7" msgid "Cross-platform lib for process and system monitoring." msgstr "Bibliothèque multiplateforme pour la surveillance des processus et du système." msgid "Sigima" msgstr "Sigima" msgid ">= 1.2.0" msgstr ">= 1.2.0" msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform." msgstr "Moteur de calcul scientifique pour les signaux 1D et les images 2D, partie de la plateforme open-source DataLab." msgid "Optional modules for GUI support (Qt):" msgstr "Modules optionnels pour le support de l'interface graphique (Qt) :" msgid "PyQt5" msgstr "PyQt5" msgid ">= 5.15.6" msgstr ">= 5.15.6" msgid "Python bindings for the Qt cross platform application toolkit" msgstr "Liaisons Python pour la boîte à outils d'applications multiplateformes Qt" msgid "Optional modules for development:" msgstr "Modules optionnels pour le développement :" msgid "ruff" msgstr "ruff" msgid "An extremely fast Python linter and code formatter, written in Rust." msgstr "Un linter et formateur de code Python extrêmement rapide, écrit en Rust." msgid "pylint" msgstr "pylint" msgid "python code static checker" msgstr "Vérificateur statique de code Python" msgid "Coverage" msgstr "Coverage" msgid "Code coverage measurement for Python" msgstr "Mesure de la couverture de code pour Python" msgid "Optional modules for building the documentation:" msgstr "Modules optionnels pour la construction de la documentation :" msgid "sphinx" msgstr "sphinx" msgid "Python documentation generator" msgstr "Générateur de documentation Python" msgid "sphinx_intl" msgstr "sphinx_intl" msgid "Sphinx utility that make it easy to translate and to apply translation." msgstr "Utilitaire Sphinx qui facilite la traduction et l'application des traductions." msgid "sphinx-sitemap" msgstr "sphinx-sitemap" msgid "Sitemap generator for Sphinx" msgstr "Générateur de plan de site pour Sphinx" msgid "myst_parser" msgstr "myst_parser" msgid "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," msgstr "Un analyseur étendu conforme à [CommonMark](https://spec.commonmark.org/)," msgid "myst-nb" msgstr "myst-nb" msgid "A Jupyter Notebook Sphinx reader built on top of the MyST markdown parser." msgstr "Un lecteur Sphinx de Jupyter Notebook construit au-dessus de l'analyseur Markdown MyST." msgid "sphinx_design" msgstr "sphinx_design" msgid "A sphinx extension for designing beautiful, view size responsive web components." msgstr "Une extension Sphinx pour concevoir de beaux composants web adaptatifs." msgid "sphinx_gallery" msgstr "sphinx_gallery" msgid "A Sphinx extension that builds an HTML gallery of examples from any set of Python scripts." msgstr "Une extension Sphinx qui construit une galerie HTML d'exemples à partir d'un ensemble de scripts Python." msgid "sphinx-copybutton" msgstr "sphinx-copybutton" msgid "Add a copy button to each of your code cells." msgstr "Ajoute un bouton de copie à chacune de vos cellules de code." msgid "pydata-sphinx-theme" msgstr "pydata-sphinx-theme" msgid "Bootstrap-based Sphinx theme from the PyData community" msgstr "Thème Sphinx basé sur Bootstrap de la communauté PyData" msgid "Optional modules for running test suite:" msgstr "Modules optionnels pour l'exécution de la suite de tests :" msgid "pytest" msgstr "pytest" msgid "pytest: simple powerful testing with Python" msgstr "pytest : tests simples et puissants avec Python" msgid "pytest-xvfb" msgstr "pytest-xvfb" msgid "A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests." msgstr "Un plugin pytest pour exécuter Xvfb (ou Xephyr/Xvnc) pour les tests." DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/lifecycle.po000066400000000000000000000105221524425144300273110ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Main-Window Lifecycle" msgstr "Cycle de vie de la fenêtre principale" msgid "SigimaX creates a derived main window in a predictable sequence. Use the protected hooks instead of relying on incidental constructor ordering." msgstr "SigimaX crée une fenêtre principale dérivée selon une séquence prévisible. Utilisez les points d'extension protégés plutôt que de vous fier à l'ordre accidentel du constructeur." msgid "Initialization Order" msgstr "Ordre d'initialisation" msgid "When a :class:`~sigimax.mainwindow.SGMXMainWindow` is created, SigimaX:" msgstr "Lorsqu'une :class:`~sigimax.mainwindow.SGMXMainWindow` est créée, SigimaX :" msgid "initializes its own non-virtual state;" msgstr "initialise son propre état non virtuel ;" msgid "calls ``_before_setup(console)``;" msgstr "appelle ``_before_setup(console)`` ;" msgid "applies the color mode through ``_update_color_mode(startup=True)``;" msgstr "applique le mode de couleur via ``_update_color_mode(startup=True)`` ;" msgid "creates the generic status bar, actions, central widget, menus, and restored window state;" msgstr "crée la barre d'état générique, les actions, le widget central, les menus et restaure l'état de la fenêtre ;" msgid "calls ``_after_setup(console)``; and" msgstr "appelle ``_after_setup(console)`` ; et" msgid "restores the window geometry." msgstr "restaure la géométrie de la fenêtre." msgid "Use ``_before_setup`` for derived state that another protected hook may need during startup. Use ``_after_setup`` for work that requires generic widgets such as actions, menus, or the status bar." msgstr "Utilisez ``_before_setup`` pour l'état dérivé dont un autre point d'extension protégé pourrait avoir besoin au démarrage. Utilisez ``_after_setup`` pour les travaux nécessitant les widgets génériques tels que les actions, les menus ou la barre d'état." msgid "The HDF5 reference application follows this rule by creating its data model in ``_before_setup``:" msgstr "L'application de référence HDF5 suit cette règle en créant son modèle de données dans ``_before_setup`` :" msgid "Singleton Access" msgstr "Accès au singleton" msgid "Call ``MyMainWindow.get_instance()`` on the concrete derived class. It returns the current instance of that class or creates one of the same class. This keeps the base framework from accidentally constructing ``SGMXMainWindow`` when an application requested its own window subclass." msgstr "Appelez ``MyMainWindow.get_instance()`` sur la classe dérivée concrète. Elle retourne l'instance courante de cette classe ou en crée une de la même classe. Cela évite que le framework de base ne construise accidentellement ``SGMXMainWindow`` alors qu'une application avait demandé sa propre sous-classe de fenêtre." msgid "Shutdown Order" msgstr "Ordre d'arrêt" msgid "For a modified workspace, the close flow asks the user to save first. A successful application save clears the modified state; cancellation or failure leaves it set and stops the close. Once closing proceeds, SigimaX calls ``_close_managed_widgets()``, ``_cleanup_before_reset()``, ``reset_all()``, ``_save_pos_size_and_state()``, and ``_cleanup_after_state_save()`` in that order." msgstr "Pour un espace de travail modifié, le flux de fermeture demande d'abord à l'utilisateur d'enregistrer. Un enregistrement applicatif réussi efface l'état modifié ; une annulation ou un échec le laisse actif et arrête la fermeture. Une fois la fermeture engagée, SigimaX appelle ``_close_managed_widgets()``, ``_cleanup_before_reset()``, ``reset_all()``, ``_save_pos_size_and_state()``, puis ``_cleanup_after_state_save()``, dans cet ordre." msgid "Derived applications should use these hooks for their own managed resources and call ``super()`` when preserving the generic behavior is required." msgstr "Les applications dérivées doivent utiliser ces points d'extension pour leurs propres ressources gérées et appeler ``super()`` lorsque la préservation du comportement générique est requise." DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/overview.po000066400000000000000000000243461524425144300272310ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Overview" msgstr "Vue d'ensemble" msgid "What is SigimaX?" msgstr "Qu'est-ce que SigimaX ?" msgid "**SigimaX** is an open-source Python framework for building Qt-based scientific desktop applications. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features instead of boilerplate." msgstr "**SigimaX** est un framework Python open-source pour la construction d'applications de bureau scientifiques basées sur Qt. Il fournit un squelette d'application réutilisable — fenêtre principale, système de configuration, widgets intégrés et infrastructure HDF5 — afin que les développeurs puissent se concentrer sur les fonctionnalités spécifiques à leur domaine plutôt que sur le code répétitif." msgid "SigimaX is extracted from `DataLab `_, a mature open-source platform for scientific signal and image processing, and powers its GUI layer." msgstr "SigimaX est extrait de `DataLab `_, une plateforme open-source mature pour le traitement scientifique de signaux et d'images, et alimente sa couche d'interface graphique." msgid "Why SigimaX?" msgstr "Pourquoi SigimaX ?" msgid "Building a scientific desktop application from scratch typically requires:" msgstr "Construire une application de bureau scientifique à partir de zéro nécessite généralement :" msgid "A main window with menus, toolbars, dock widgets, and status bar" msgstr "Une fenêtre principale avec des menus, des barres d'outils, des widgets ancrables et une barre d'état" msgid "A configuration system with typed options, persistence, and defaults" msgstr "Un système de configuration avec des options typées, de la persistance et des valeurs par défaut" msgid "HDF5 file management (industry standard for scientific data)" msgstr "La gestion de fichiers HDF5 (standard industriel pour les données scientifiques)" msgid "An embedded Python console for scripting and debugging" msgstr "Une console Python intégrée pour le scripting et le débogage" msgid "Specialized dialogs for signal/image analysis (fitting, peak detection, etc.)" msgstr "Des dialogues spécialisés pour l'analyse de signaux/images (ajustement, détection de pics, etc.)" msgid "Production-grade features: splash screen, memory monitoring, log viewer" msgstr "Des fonctionnalités de qualité production : écran de démarrage, surveillance mémoire, visualiseur de journaux" msgid "SigimaX provides all of these as a **subclassable framework**, so derived applications only need to add their domain-specific logic." msgstr "SigimaX fournit tout cela sous forme d'un **framework dérivable par sous-classement**, de sorte que les applications dérivées n'ont qu'à ajouter leur logique spécifique au domaine." msgid "Position in the Stack" msgstr "Position dans la pile logicielle" msgid "SigimaX sits between the low-level libraries (PlotPy, guidata, PythonQwt) and end-user applications:" msgstr "SigimaX se situe entre les bibliothèques de bas niveau (PlotPy, guidata, PythonQwt) et les applications utilisateur :" msgid "Layer" msgstr "Couche" msgid "Project" msgstr "Projet" msgid "Role" msgstr "Rôle" msgid "End-user apps" msgstr "Applications utilisateur" msgid "DataLab" msgstr "DataLab" msgid "Signal/image processing GUI application" msgstr "Application d'interface graphique de traitement de signaux et d'images" msgid "GUI framework" msgstr "Framework d'interface graphique" msgid "**SigimaX**" msgstr "**SigimaX**" msgid "Reusable application skeleton" msgstr "Squelette d'application réutilisable" msgid "Computation" msgstr "Calcul" msgid "Sigima" msgstr "Sigima" msgid "Headless scientific computing (signals & images)" msgstr "Calcul scientifique sans interface graphique (signaux et images)" msgid "Plotting" msgstr "Tracé" msgid "PlotPy + PythonQwt" msgstr "PlotPy + PythonQwt" msgid "Interactive plot widgets" msgstr "Widgets de tracé interactifs" msgid "GUI toolkit" msgstr "Boîte à outils d'interface graphique" msgid "guidata" msgstr "guidata" msgid "Dataset/parameter framework with automatic GUI generation" msgstr "Framework de jeux de données/paramètres avec génération automatique d'interface graphique" msgid "Foundation" msgstr "Fondation" msgid "NumPy + SciPy + Qt" msgstr "NumPy + SciPy + Qt" msgid "Core scientific and GUI libraries" msgstr "Bibliothèques scientifiques et d'interface graphique fondamentales" msgid "Architecture" msgstr "Architecture" msgid "Core Modules" msgstr "Modules principaux" msgid "Module" msgstr "Module" msgid "Purpose" msgstr "Fonction" msgid ":mod:`sigimax.app`" msgstr ":mod:`sigimax.app`" msgid "Application launcher — ``create()`` and ``run()`` functions" msgstr "Lanceur d'application — fonctions ``create()`` et ``run()``" msgid ":mod:`sigimax.config`" msgstr ":mod:`sigimax.config`" msgid "Configuration system — ``SigimaXOptions``, ``CONF`` singleton, option fields" msgstr "Système de configuration — ``SigimaXOptions``, singleton ``CONF``, champs d'options" msgid ":mod:`sigimax.env`" msgstr ":mod:`sigimax.env`" msgid "Runtime environment — ``SGMXExecEnv``, verbosity levels, unattended mode" msgstr "Environnement d'exécution — ``SGMXExecEnv``, niveaux de verbosité, mode non interactif" msgid ":mod:`sigimax.mainwindow`" msgstr ":mod:`sigimax.mainwindow`" msgid "``SGMXMainWindow`` — generic main window with menus, console, HDF5 workspace" msgstr "``SGMXMainWindow`` — fenêtre principale générique avec menus, console, espace de travail HDF5" msgid "Design Philosophy" msgstr "Philosophie de conception" msgid "SigimaX separates the **generic application skeleton** from **domain-specific logic**. Derived applications follow a three-step pattern:" msgstr "SigimaX s\\u00e9pare le **squelette g\\u00e9n\\u00e9rique de l'application** de la **logique sp\\u00e9cifique au domaine**. Les applications d\\u00e9riv\\u00e9es suivent un processus en trois \\u00e9tapes :" msgid "**Subclass** :class:`~sigimax.config.SigimaXOptions` to add application-specific configuration fields" msgstr "**Sous-classer** :class:`~sigimax.config.SigimaXOptions` pour ajouter des champs de configuration sp\\u00e9cifiques \\u00e0 l'application" msgid "**Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize menus, toolbars, and dock widgets" msgstr "**Sous-classer** :class:`~sigimax.mainwindow.SGMXMainWindow` pour personnaliser les menus, les barres d'outils et les widgets ancrables" msgid "**Call** :func:`~sigimax.app.run` to launch the application with splash screen support" msgstr "**Appeler** :func:`~sigimax.app.run` pour lancer l'application avec le support de l'\\u00e9cran de d\\u00e9marrage" msgid "This architecture is proven in production: `DataLab `_ is built entirely on this pattern." msgstr "Cette architecture est \\u00e9prouv\\u00e9e en production : `DataLab `_ est enti\\u00e8rement construit sur ce mod\\u00e8le." msgid "Configuration System" msgstr "Syst\\u00e8me de configuration" msgid "The configuration system uses typed option fields that support:" msgstr "Le syst\\u00e8me de configuration utilise des champs d'options typ\\u00e9s qui prennent en charge :" msgid "**Type safety**: ``TypedOptionField`` (int, str, bool, float), ``EnumOptionField`` (constrained choices), ``TupleOptionField`` (fixed-length tuples), ``FontOptionField`` (font validation)" msgstr "**S\\u00e9curit\\u00e9 de typage** : ``TypedOptionField`` (int, str, bool, float), ``EnumOptionField`` (choix contraints), ``TupleOptionField`` (tuples de longueur fixe), ``FontOptionField`` (validation de police)" msgid "**Persistence**: JSON save/load with ``save()`` and ``load()`` methods" msgstr "**Persistance** : sauvegarde/chargement JSON avec les m\\u00e9thodes ``save()`` et ``load()``" msgid "**Context managers**: Temporary overrides with ``option.context(value)``" msgstr "**Gestionnaires de contexte** : surcharges temporaires avec ``option.context(value)``" msgid "**Defaults**: Built-in reset-to-defaults mechanism" msgstr "**Valeurs par d\\u00e9faut** : m\\u00e9canisme int\\u00e9gr\\u00e9 de r\\u00e9initialisation aux valeurs par d\\u00e9faut" msgid "What SigimaX Provides vs What Stays in Derived Apps" msgstr "Ce que SigimaX fournit vs ce qui reste dans les applications dérivées" msgid "**In SigimaX**" msgstr "**Dans SigimaX**" msgid "**Stays in derived apps (e.g. DataLab)**" msgstr "**Reste dans les applications dérivées (par ex. DataLab)**" msgid "Configuration system" msgstr "Système de configuration" msgid "Signal/Image panels, processors" msgstr "Panneaux Signal/Image, processeurs" msgid "Generic main window" msgstr "Fenêtre principale générique" msgid "Action handler, plugin system" msgstr "Gestionnaire d'actions, système de plugins" msgid "Dockable plot widgets" msgstr "Widgets de tracé ancrables" msgid "Remote control (XML-RPC, Web API)" msgstr "Contrôle à distance (XML-RPC, API Web)" msgid "HDF5 I/O + browser" msgstr "E/S HDF5 + navigateur" msgid "Macro editor, new-object dialogs" msgstr "Éditeur de macros, dialogues de création d'objets" msgid "Scientific dialogs (fit, baseline, peak, cursor…)" msgstr "Dialogues scientifiques (ajustement, ligne de base, pic, curseur…)" msgid "Application-specific UI and processing" msgstr "Interface utilisateur et traitements spécifiques à l'application" msgid "Log viewer, status bar, splash screen, wizard" msgstr "Visualiseur de journaux, barre d'état, écran de démarrage, assistant" msgid "Object model, plot handler" msgstr "Modèle d'objets, gestionnaire de tracé" msgid "PlotPy adapters" msgstr "Adaptateurs PlotPy" msgid "Processor registration pattern" msgstr "Patron d'enregistrement des processeurs" msgid "Environment/exec utilities" msgstr "Utilitaires d'environnement/exécution" msgid "Tour/tutorial features" msgstr "Fonctionnalités de visite guidée/tutoriel" DataLab-Platform-SigimaX-0182bf4/doc/locale/fr/LC_MESSAGES/user_guide/remote_control.po000066400000000000000000000176751524425144300304250ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) 2025, DataLab Platform Developers # This file is distributed under the same license as the SigimaX package. # FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "Remote Control Widgets" msgstr "Widgets de contrôle à distance" msgid "SigimaX provides ready-to-use Qt widgets to build a *remote client* application: a separate process that connects to a running DataLab-derived application server and exchanges signals/images with it, using :class:`sigima.client.SimpleRemoteProxy` (or any proxy exposing the same ``connect``/``get_current_panel``/``get_group_titles_with_object_info`` API)." msgstr "SigimaX fournit des widgets Qt prêts à l'emploi pour créer une application *client distant* : un processus séparé qui se connecte à un serveur d'application dérivé de DataLab en cours d'exécution et échange des signaux/images avec celui-ci, en utilisant :class:`sigima.client.SimpleRemoteProxy` (ou tout proxy exposant la même API ``connect``/``get_current_panel``/``get_group_titles_with_object_info``)." msgid "These widgets originate from the (now unmaintained) ``cdlclient`` / `DataLab Simple Client `_ package. They have been ported into SigimaX, generalized (no DataLab-specific branding hardcoded), and adapted to the current :mod:`sigima.client` API." msgstr "Ces widgets proviennent du package (aujourd'hui non maintenu) ``cdlclient`` / `DataLab Simple Client `_. Ils ont été portés dans SigimaX, généralisés (aucun branding spécifique à DataLab codé en dur) et adaptés à l'API actuelle :mod:`sigima.client`." msgid "Responsibilities" msgstr "Responsabilités" msgid "SigimaX provides:" msgstr "SigimaX fournit :" msgid ":class:`~sigimax.widgets.connection.ConnectionDialog`, a small modal dialog showing the progress of a (possibly slow or failing) connection attempt;" msgstr ":class:`~sigimax.widgets.connection.ConnectionDialog`, une petite boîte de dialogue modale affichant la progression d'une tentative de connexion (éventuellement lente ou échouant) ;" msgid ":class:`~sigimax.widgets.objectdialog.GetObjectDialog` and :class:`~sigimax.widgets.objectdialog.SimpleObjectTree`, a dialog and tree widget to browse and pick a signal or image among the objects currently available on the connected server;" msgstr ":class:`~sigimax.widgets.objectdialog.GetObjectDialog` et :class:`~sigimax.widgets.objectdialog.SimpleObjectTree`, une boîte de dialogue et un widget en arbre pour parcourir et sélectionner un signal ou une image parmi les objets actuellement disponibles sur le serveur connecté ;" msgid "generic icons for signals, images and groups (:mod:`sigimax.utils.svg_icons`)." msgstr "Icônes génériques pour les signaux, images et groupes (:mod:`sigimax.utils.svg_icons`)." msgid "A derived (client) application provides:" msgstr "Une application dérivée (client) fournit :" msgid "the actual :class:`sigima.client.SimpleRemoteProxy` (or compatible proxy) instance and its connection parameters;" msgstr "L'instance réelle de :class:`sigima.client.SimpleRemoteProxy` (ou un proxy compatible) et ses paramètres de connexion ;" msgid "what to do with the object selected in :class:`~sigimax.widgets.objectdialog.GetObjectDialog` (e.g. retrieve its data with :meth:`~sigima.client.base.SimpleBaseProxy.get_object`); the widgets never modify the connection or the remote objects themselves." msgstr "Ce qu'il faut faire avec l'objet sélectionné dans :class:`~sigimax.widgets.objectdialog.GetObjectDialog` (par exemple récupérer ses données avec :meth:`~sigima.client.base.SimpleBaseProxy.get_object`) ; les widgets ne modifient jamais la connexion ni les objets distants eux-mêmes." msgid "Connecting to a Server" msgstr "Connexion à un serveur" msgid ":class:`~sigimax.widgets.connection.ConnectionDialog` wraps a *blocking* connect callback (typically :meth:`sigima.client.remote.SimpleRemoteProxy.connect`) in a background thread, so the Qt event loop keeps running while the connection is attempted:" msgstr ":class:`~sigimax.widgets.connection.ConnectionDialog` encapsule un rappel de connexion *bloquant* (typiquement :meth:`sigima.client.remote.SimpleRemoteProxy.connect`) dans un thread en arrière-plan, de sorte que la boucle d'événements Qt continue de fonctionner pendant que la connexion est tentée :" msgid "The dialog is generic: it does not assume any application branding. A derived application may pass its own window icon and/or a banner pixmap:" msgstr "La boîte de dialogue est générique : elle ne suppose aucun branding d'application. Une application dérivée peut passer sa propre icône de fenêtre et/ou un pixmap de bannière :" msgid "Picking a Remote Object" msgstr "Sélection d'un objet distant" msgid "Once connected, :class:`~sigimax.widgets.objectdialog.GetObjectDialog` lists the groups and objects (signals and/or images) currently available on the server, using :meth:`sigima.client.base.SimpleBaseProxy.get_group_titles_with_object_info`:" msgstr "Une fois connecté, :class:`~sigimax.widgets.objectdialog.GetObjectDialog` liste les groupes et objets (signaux et/ou images) actuellement disponibles sur le serveur, en utilisant :meth:`sigima.client.base.SimpleBaseProxy.get_group_titles_with_object_info` :" msgid "Passing ``panel=None`` (the default) lets the user switch between the signal and image panels from a combo box inside the dialog. Passing ``panel=\"signal\"`` or ``panel=\"image\"`` restricts the dialog to a single panel and hides the combo box." msgstr "Passer ``panel=None`` (par défaut) permet à l'utilisateur de basculer entre les panneaux signal et image à partir d'une combo box à l'intérieur de la boîte de dialogue. Passer ``panel=\"signal\"`` ou ``panel=\"image\"`` restreint la boîte de dialogue à un seul panneau et masque la combo box." msgid "Testing Without a Real Server" msgstr "Test sans serveur réel" msgid ":mod:`sigima.client.stub` provides a lightweight in-process XML-RPC server (:class:`~sigima.client.stub.DataLabStubServer`) that implements enough of the protocol to exercise these widgets headlessly, without launching a real DataLab-derived application. This is how SigimaX's own test suite validates :class:`~sigimax.widgets.connection.ConnectionDialog` and :class:`~sigimax.widgets.objectdialog.GetObjectDialog`:" msgstr ":mod:`sigima.client.stub` fournit un serveur XML-RPC léger en processus (:class:`~sigima.client.stub.DataLabStubServer`) qui implémente suffisamment du protocole pour tester ces widgets sans tête, sans lancer une véritable application dérivée de DataLab. C'est ainsi que la suite de tests de SigimaX valide :class:`~sigimax.widgets.connection.ConnectionDialog` et :class:`~sigimax.widgets.objectdialog.GetObjectDialog` :" msgid "``SimpleRemoteProxy.add_signal`` stores the signal on the stub server unconditionally, but does not attach it to any group. The example above wires the stub server's default group directly (through the ``DataLabStubServer`` instance) to get a deterministic, non-empty tree for the test. A real DataLab-derived server groups newly added objects automatically." msgstr "``SimpleRemoteProxy.add_signal`` stocke le signal sur le serveur stub de manière inconditionnelle, mais ne l'attache à aucun groupe. L'exemple ci-dessus connecte directement le groupe par défaut du serveur stub (via l'instance ``DataLabStubServer``) pour obtenir un arbre déterministe et non vide pour le test. Un véritable serveur dérivé de DataLab groupe automatiquement les objets nouvellement ajoutés." msgid "See also the :ref:`sphx_glr_auto_examples_features_remote_control_widgets.py` example for a runnable, standalone script." msgstr "Voir également l'exemple :ref:`sphx_glr_auto_examples_features_remote_control_widgets.py` pour un script autonome exécutable." DataLab-Platform-SigimaX-0182bf4/doc/release_notes/000077500000000000000000000000001524425144300220545ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/release_notes/index.rst000066400000000000000000000003721524425144300237170ustar00rootroot00000000000000Release notes ============= This section contains the release notes for all versions of :mod:`sigimax`, documenting new features, improvements, bug fixes, and breaking changes. .. toctree:: :maxdepth: 1 :glob: :reversed: release_* DataLab-Platform-SigimaX-0182bf4/doc/release_notes/release_0.01.md000066400000000000000000000152571524425144300244660ustar00rootroot00000000000000# Version 0.1 # ## SigimaX Version 0.1.0 (2026-03-04) (pre-release)## Initial development release — SigimaX is extracted from DataLab as a reusable GUI application framework for scientific computing Qt applications. ### Highlights ### SigimaX provides the generic "application skeleton" that any scientific computing Qt application can build upon by subclassing its main window and configuration system. This release contains the full extraction from DataLab, including: * Generic main window (`SGMXMainWindow`) with customizable menus, toolbars, and console * Configuration system based on Sigima's typed option fields * HDF5 browsing, generic import, and workspace persistence hooks * Splash screen and application launcher (`create()` / `run()`) * PlotPy adapters for signal/image plot items * Reusable scientific widgets (fit dialogs, baseline, peak detection, cursor, etc.) * Comprehensive test suite with 155+ tests across unit, GUI, and app categories * Full Sphinx documentation with API reference and gallery examples * Complete French translation (189 strings) ### Application framework ### * Implemented `SGMXMainWindow` — a generic main window that derived applications subclass to build their own UI, with customizable menu order, toolbar actions, and console namespace * Added `create()` and `run()` application launcher functions in `app.py` with configurable splash screen support (`SplashScreenConfig`) * Provided overridable hooks for derived apps: `reset_all()`, `_is_save_enabled()`, `_update_file_menu()`, `_update_view_menu()`, `_about()` * Added generic main toolbar configuration — derived apps can redefine toolbar actions * Added quit action to the file menu ### Configuration system ### * Implemented typed configuration options inspired by Sigima's non-INI-file config system, with `TypedOptionField`, `EnumOptionField`, `TupleOptionField`, and `FontOptionField` * Added generic application metadata options (`app_name`, `app_version`, `app_logo_path`, `app_desc`, `app_docurl`, `app_homeurl`, `app_supporturl`) * Configuration supports JSON persistence via `save()` / `load()` * Removed `process_isolation_enabled` option (DataLab-specific — the framework only used it cosmetically; the actual mechanism stays in DataLab) ### HDF5 support ### * Ported generic HDF5 browsing and dataset import * Added workspace persistence hooks for derived applications; SigimaX does not impose a universal workspace format or serializer * Added `import_dataset_from_file()` hook for derived apps to handle application-specific dataset import from HDF5 * Ported `H5BrowserDialog` widget for interactive HDF5 file browsing ### Widgets ### * Ported scientific dialog widgets from DataLab: curve fitting (`fitdialog`), signal baseline selection, signal peak detection, signal cursor, signal delta-X measurement, image background selection * Added `DockablePlotWidget` for embedding PlotPy plots in dock widgets, with configurable watermark and dock location via `SigimaXOptions` * Ported status bar widgets: `BaseStatus`, `MemoryStatus`, `ConsoleStatus` * Added `Wizard` multi-page dialog widget * Added `LogViewerWindow` for log display * Added `FileViewerWidget` for read-only file viewing * Added `WarningErrorMessageBox` for warning/error display * Added convenience re-exports in `widgets/__init__.py` with `__all__` ### PlotPy adapters ### * Ported `adapters_plotpy` module for converting between Sigima objects (`SignalObj`, `ImageObj`) and PlotPy plot items * Added `iterate_metadata_shape_items()` hook on `BaseObjPlotPyAdapter` — a no-op generator that derived apps (DataLab) can override to yield plot items for app-specific metadata entries (geometry results, table results) * Cleaned up commented-out scalar adapter code (`GeometryPlotPyAdapter`, `TableAdapter`) — these stay in DataLab ### Environment and utilities ### * Implemented `SGMXExecEnv` runtime environment singleton (renamed from DataLab's `DLExecEnv`) with verbosity levels, demo mode, and unattended mode * Ported Qt helper utilities (`utils/qthelpers.py`): log file management, signal blocking context manager, stdout/stderr save/restore * Added local PDF documentation path handling in the Help menu ### Package structure ### * Dissolved `gui/` subpackage — moved `gui/main.py` → `mainwindow.py` (top-level) and `gui/docks.py` → `widgets/plotdock.py` for clearer naming * Added `__all__` declarations in all public modules * Added `from __future__ import annotations` across all modules * Top-level `__init__.py` re-exports `SGMXMainWindow`, `create`, `run` (follows Sigima's pattern) * Fixed circular import between `__init__.py` and `config.py` by extracting metadata to `_metadata.py` * Removed dead code: commented-out imports, `config_old.py`, DataLab-specific action handler dependencies ### Testing ### * Built comprehensive test suite with 155+ tests organized into subpackages: `config/`, `mainwindow/`, `widgets/`, `hdf5/`, `adapters_plotpy/`, `utils/` * Standardized test file naming to `test_*.py` convention; non-test helpers prefixed with `_` * Added pytest markers: `@pytest.mark.unit` (pure logic, no Qt), `@pytest.mark.gui` (Qt widget tests), `@pytest.mark.app` (full main window) * Added `--show-windows` flag for visual test validation (offscreen by default) * PlotPy adapter tests cover factory dispatch, make/update item roundtrips, ROI coordinate roundtrips, and annotation integration ### Documentation ### * Added full Sphinx documentation with API reference pages for all public modules (`app`, `config`, `env`, `mainwindow`, `widgets`, `h5`, `adapters_plotpy`, `utils`) * Added Sphinx-Gallery examples: getting started (`minimal_app.py`), features (`configuration.py`, `plot_widget.py`), and use cases (`full_app.py`) * Added user guide pages: overview, installation, contributing * Documentation builds cleanly with `-W` (warnings-as-errors) ### Internationalization ### * Added complete French translation of all 189 UI strings (`sigimax/locale/fr/LC_MESSAGES/sigimax.po`) * Translations cover menus, toolbar, HDF5 browser, fit dialogs, signal widgets, status bar, error/warning dialogs, wizard, and about/help * Terminology aligned with DataLab's existing French translations for consistency ### Project infrastructure ### * Created `pyproject.toml` with Ruff rules (`D202`, `D403`, `RUF022`, Google pydocstyle), pytest config (`--import-mode=importlib`, `filterwarnings`) * Added Sphinx documentation scaffolding (imported from Sigima's structure) * Added copilot instructions (`.github/copilot-instructions.md`) * Changed maintainer email to `datalab@codra.fr` * Fixed `run_with_env.py` to substitute `sys.executable` when command starts with `python`, ensuring the correct venv interpreter is used DataLab-Platform-SigimaX-0182bf4/doc/release_notes/release_1.00.md000066400000000000000000000172701524425144300244630ustar00rootroot00000000000000# Version 1.0.0 # ## SigimaX Version 1.0.0 ## ### Highlights ### This release turns `SGMXMainWindow` and the SigimaX configuration system into a real extension surface: derived applications (DataLab and others) can now compose menus, docks, status bar widgets, HDF5 import and the whole application lifecycle by overriding small, well-documented hooks instead of duplicating entire methods. It also brings a simplified, more robust configuration option system, new remote-control widgets for connecting to a running DataLab-Kernel instance, and a batch of fixes ported from DataLab's `develop` branch. ### Application framework ### * Made the main window setup sequence an extension point: `setup()` now calls optional `_setup_panels()`, `_setup_docks()` and `_post_setup()` hooks so that derived applications add their panels, docks and final wiring without reimplementing the whole startup sequence * Turned the dock registry into an explicit extension point: `_add_dockwidget()` now owns registration in `self.docks`, assigns a stable Qt object name and handles tabification through new `name`, `key` and `tabify_with` arguments, so derived applications no longer register docks by hand * Let derived applications compose the menu bar through `_get_menubar_layout()` and extend the Help menu through `_get_help_doc_actions()`, `_get_help_support_actions()` and `_get_help_about_actions()`, instead of reimplementing `_add_menus()` entirely * Let derived applications contribute status bar widgets via `_get_extra_status_widgets()`, inserted between the console and memory status, instead of rebuilding the whole status bar * Exposed a broad set of protected lifecycle hooks (status bar, actions, central widget, menus, console, geometry, window state, toolbar, dock, memory and color-mode steps, plus pre/post setup, post-show and shutdown hooks), all documented and covered by dedicated lifecycle-ordering tests * Fixed the derived main window lifecycle: singleton instances are now created from the requested derived window class, with protected setup running before virtual startup hooks * Deferred command-line argument parsing so that the module-level SigimaX execution environment no longer intercepts application-specific options (such as DataLab's `--version`) before the derived environment is initialized * Improved the application window factory to preserve the concrete derived window type, and made splash screen resource loading more robust (direct paths, guidata image path resolution, safe fallback when the configured resource is missing) * Dropped DataLab code that only duplicated unchanged SigimaX main window behavior (tab icon size handling) ### Configuration system ### * Simplified application configuration state management: removed unused environment-variable synchronization from application options and simplified get/set/context/serialization/reset operations * Added an `option_changed` hook so derived applications can persist option changes without polling the configuration * Added a storage serialization protocol (`to_storage`/`from_storage`) to option fields, fixing `to_dict`/`from_dict` and `reset_to_defaults` for fields whose getter/setter transform the stored value * Let option fields declare their own storage metadata (`storage_key`, `runtime`), removing the need for key-mapping tables in consuming applications * Moved generic option field types (`ConfigPathOptionField`, `WorkingDirOptionField`, `FormatStringOptionField`, `DataSetOptionField`, `FontOptionField`) from DataLab into `sigimax.config`, and removed the legacy `Section`/`Option` class hierarchy, which had no remaining consumer * Added support for optional defaults on option fields and for standalone option field containers * Fixed option state not being preserved when restored from a temporary context * Made PlotPy default colors (foreground/background) color-mode aware by reading them at call time, so re-applying defaults after a color mode change now uses the current theme ### HDF5 support ### * Made the HDF5 import contract explicit: `import_h5_file()` is renamed to `import_all_from_h5_file()` (it imports every supported dataset without a dialog), and a single `_handle_imported_objects()` convergence point now receives the imported objects together with the `reset_all` flag, so a derived application is never asked whether to clear the workspace only to have the answer ignored * Added `_is_workspace_empty()` and `_get_clear_workspace_message()` hooks so derived applications can control the "clear workspace" question and its wording without reimplementing the import orchestrator * Fixed safe HDF5 workspace contracts: derived applications are now required to implement workspace persistence instead of silently acknowledging a save that wrote no data * Ported the latest HDF5 and widget fixes from DataLab's `develop` branch, including a fix for `DIMENSION_LIST`/`REFERENCE` attributes breaking picklability in `h5/common.py` ### Widgets ### * Added `DataLabSimpleClient` widgets: a connection dialog and an object browsing dialog for remote-controlling a running DataLab-Kernel instance over HTTP ([DataLab-Platform/DataLab#183](https://github.com/DataLab-Platform/DataLab/issues/183)) * Aligned the plot dock's curve statistics with PlotPy: the hand-written, NaN-robust statistics label functions are replaced by PlotPy's own `CurveStatsTool.LABELFUNCS`, and the y-range cursor sum is now a plain sum instead of a trapezoidal integral, matching PlotPy's behavior * Opened up the plot dock for customization: added `SigimaXPlotWidget._customize_image_panels()` to tweak the X/Y cross section panels, and `DockablePlotWidget.PLOTWIDGET_CLASS` to let a derived application select its own plot widget class * Fixed signal update for plain `CurveItem`: when a signal's `xydata` has 4 rows but `dx`/`dy` are all `NaN`, `update_item` no longer calls `set_data` with error-bar arguments on a plain curve item ### PlotPy adapters ### * Made the adapter factory overridable: introduced `PlotPyAdapterFactory` with `get_adapter_factory()`/`set_adapter_factory()`/`reset_adapter_factory()` (following the existing `get_conf`/`set_conf`/`reset_conf` pattern), so a derived application can substitute an adapter or register an additional object type without forking the whole dispatch chain * Promoted DataLab's signal ROI adapters into SigimaX: ROI fills are now clipped to the signal curve instead of covering the whole canvas height, and sibling ROIs cycle through a color palette indexed by ROI title so colors stay stable across deletions ### Testing ### * Added regression tests for the HDF5 and widget fixes ported from DataLab (interactive fit metadata, `WarningErrorMessageBox` construction, config field coverage, HDF5 utility helpers, `DIMENSION_LIST`/`REFERENCE` picklability, `adapters_plotpy.coordutils` rounding) * Added tests covering lifecycle hook overridability and call order for the main window setup and shutdown sequence * Added tests for splash screen resource resolution (registered resources, missing-image fallback, disabled progress messages) * Preserved coverage for the legacy HDF5 dataset selector contract * Cleaned up remaining Pylint and pytest warnings (adapter factory dispatch table, import-outside-toplevel/cyclic-import disables, `pytest.warns` usage) ### Documentation and internationalization ### * Updated and completed the French translation, including fixes to the SVG icon `.po` file format * Updated the documentation pipeline and refreshed all user guide pages, examples and API references to reflect the simplified configuration API, the new lifecycle hooks and the `DataLabSimpleClient` widgets * Updated the documentation banner DataLab-Platform-SigimaX-0182bf4/doc/requirements.rst000066400000000000000000000051061524425144300225030ustar00rootroot00000000000000The `sigimax` package requires the following Python modules: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - Python - >=3.9, <4 - Python programming language * - guidata - >= 3.15.0 - Automatic GUI generation for easy dataset editing and display * - PlotPy - >= 2.11.0 - Curve and image plotting tools for Python/Qt applications * - psutil - >= 5.7 - Cross-platform lib for process and system monitoring. * - Sigima - >= 1.2.0 - Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform. Optional modules for GUI support (Qt): .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - PyQt5 - >= 5.15.6 - Python bindings for the Qt cross platform application toolkit Optional modules for development: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - ruff - - An extremely fast Python linter and code formatter, written in Rust. * - pylint - - python code static checker * - Coverage - - Code coverage measurement for Python Optional modules for building the documentation: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - sphinx - - Python documentation generator * - sphinx_intl - - Sphinx utility that make it easy to translate and to apply translation. * - sphinx-sitemap - - Sitemap generator for Sphinx * - myst_parser - - An extended [CommonMark](https://spec.commonmark.org/) compliant parser, * - myst-nb - - A Jupyter Notebook Sphinx reader built on top of the MyST markdown parser. * - sphinx_design - - A sphinx extension for designing beautiful, view size responsive web components. * - sphinx_gallery - - A Sphinx extension that builds an HTML gallery of examples from any set of Python scripts. * - sphinx-copybutton - - Add a copy button to each of your code cells. * - pydata-sphinx-theme - - Bootstrap-based Sphinx theme from the PyData community Optional modules for running test suite: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - pytest - - pytest: simple powerful testing with Python * - pytest-xvfb - - A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests.DataLab-Platform-SigimaX-0182bf4/doc/user_guide/000077500000000000000000000000001524425144300213575ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/doc/user_guide/features.rst000066400000000000000000000167351524425144300237430ustar00rootroot00000000000000.. _features: Features ======== This page provides an organized catalog of SigimaX's key features. Application Framework --------------------- .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Feature - Description * - :class:`~sigimax.mainwindow.SGMXMainWindow` - Generic main window with customizable menus, toolbars, console, and dock widgets. Derived apps subclass this to build their own UI. * - :func:`~sigimax.app.create` - Instantiate a main window with optional splash screen, console, and size. Returns the window instance for embedding. * - :func:`~sigimax.app.run` - Create the window and enter the Qt event loop. The standard entry point for standalone applications. * - :class:`~sigimax.widgets.splashscreen.SplashScreenConfig` - Configurable splash screen with image, app name, version, tagline, and optional progress display. Configuration System -------------------- .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Feature - Description * - :class:`~sigimax.config.SigimaXOptions` - Full configuration singleton (``CONF``) with 20+ typed options covering app metadata, plot defaults, HDF5 settings, and more. * - ``EnumOptionField`` - Option constrained to a set of string choices, with validation. * - ``TupleOptionField`` - Fixed-length tuple option with type checking. * - ``FontOptionField`` - Font option with validation against available system fonts. * - JSON persistence - ``save()`` / ``load()`` methods for configuration persistence. * - Context managers - Temporary overrides with ``option.context(value)`` pattern. .. admonition:: Configuration options reference The full list of configuration options available in the ``CONF`` singleton: .. options-table:: HDF5 Workspace -------------- .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Feature - Description * - Open/Save workspace - File actions and workspace-state handling. Derived applications provide their own workspace serialization. * - :class:`~sigimax.widgets.h5browser.H5BrowserDialog` - Interactive HDF5 file browser with tree view, supporting scalar, array, text, and compound datasets. * - Import datasets - ``import_dataset_from_file()`` hook for derived apps to handle application-specific HDF5 dataset import. * - :class:`~sigimax.h5.H5Importer` - Low-level HDF5 import utilities with node factory and data extraction. See :doc:`hdf5_workspace` for the persistence contract and its complete derived-application example. Scientific Widgets ------------------ .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Widget - Description * - :class:`~sigimax.widgets.plotdock.DockablePlotWidget` - Embeds PlotPy plots in dock widgets, with configurable watermark and dock location. Supports ``PlotType.CURVE`` and ``PlotType.IMAGE``. * - Curve fitting dialogs - Gaussian, polynomial, and custom curve fitting via :mod:`sigimax.widgets.fitdialog`. * - Signal peak detection - Interactive peak detection dialog via :mod:`sigimax.widgets.signalpeak`. * - Signal baseline selection - Baseline selection for background subtraction via :mod:`sigimax.widgets.signalbaseline`. * - Signal cursor - Cursor-based value readout via :mod:`sigimax.widgets.signalcursor`. * - Signal delta-X - Delta-X measurement between two points via :mod:`sigimax.widgets.signaldeltax`. * - Image background - Image background region selection via :mod:`sigimax.widgets.imagebackground`. * - :class:`~sigimax.widgets.wizard.Wizard` - Multi-page wizard dialog with navigation, validation, and data collection (Next/Back/Finish/Cancel). * - :class:`~sigimax.widgets.logviewer.LogViewerWindow` - Log viewer dialog for displaying application log files. * - :class:`~sigimax.widgets.warningerror.WarningErrorMessageBox` - Warning/error display dialog with traceback support. Remote Control Widgets ---------------------- .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Widget - Description * - :class:`~sigimax.widgets.connection.ConnectionDialog` - Modal dialog showing the progress of a connection attempt to a DataLab-derived application server, via a blocking connect callback run in a background thread. * - :class:`~sigimax.widgets.objectdialog.GetObjectDialog` - Dialog to browse and pick a signal or image among the objects available on a connected server. * - :class:`~sigimax.widgets.objectdialog.SimpleObjectTree` - Tree widget populated from a remote proxy's groups and objects, used internally by ``GetObjectDialog``. * - :mod:`sigimax.utils.svg_icons` - Generic signal/image/group icons shared by the widgets above. These widgets only depend on :class:`sigima.client.SimpleRemoteProxy` (or a compatible proxy), never on a specific application's branding or object model. See :doc:`remote_control` for a complete guide and usage examples. Status Bar Widgets ------------------ .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Widget - Description * - :class:`~sigimax.widgets.status.MemoryStatus` - Displays current memory usage with configurable alarm threshold. * - :class:`~sigimax.widgets.status.ConsoleStatus` - Console toggle button in the status bar. * - :class:`~sigimax.widgets.status.BaseStatus` - Base status bar widget for custom status indicators. PlotPy Adapters --------------- .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Feature - Description * - Signal/Image adapters - Convert between Sigima objects (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`) and PlotPy plot items for display. * - ROI adapters - Convert between Sigima ROI objects and PlotPy annotation items (segment, rectangular, circular, polygonal). * - :func:`~sigimax.adapters_plotpy.create_adapter_from_object` - Factory function that dispatches to the correct adapter based on object type. * - JSON roundtrips - ``items_to_json()`` / ``json_to_items()`` for serializing plot items. Runtime Environment ------------------- .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Feature - Description * - :class:`~sigimax.env.SGMXExecEnv` - Runtime environment singleton (``execenv``) controlling unattended mode, verbosity levels, demo mode, screenshot capture, and delay settings. * - :class:`~sigimax.env.VerbosityLevels` - Enum with ``quiet``, ``normal``, and ``debug`` verbosity levels. * - Command-line arguments - ``--unattended``, ``--verbose``, ``--screenshot``, ``--delay``, ``--version``, ``--reset`` parsed automatically on startup. * - Context manager - ``execenv.context()`` for temporarily overriding environment settings. Internationalization -------------------- SigimaX supports internationalization with gettext: - All UI strings are wrapped with ``_()`` from :mod:`sigimax.config` - Translations are stored in ``locale/`` (currently English and French) - Use ``guidata.utils.translations`` CLI to scan and compile translations DataLab-Platform-SigimaX-0182bf4/doc/user_guide/getting_started.rst000066400000000000000000000112261524425144300253020ustar00rootroot00000000000000.. _getting_started: Getting Started =============== This page provides a quick introduction to building applications with SigimaX. SigimaX follows a **three-step derivation pattern**: subclass the configuration, subclass the main window, and launch with ``run()``. This pattern is proven in production — `DataLab `_ is built entirely on it. The Derivation Pattern ---------------------- **Step 1 — Subclass** :class:`~sigimax.config.SigimaXOptions` to add application-specific configuration fields: .. code-block:: python from sigimax.config import TypedOptionField from sigimax.config import EnumOptionField, SigimaXOptions class MyAppOptions(SigimaXOptions): def __init__(self): super().__init__() self.app_name.set("MyApp") self.greeting = TypedOptionField( self, "greeting", default="Hello!", expected_type=str, description="Startup message", ) self.unit_system = EnumOptionField( self, "unit_system", default="metric", choices=["metric", "imperial"], description="Default units", ) **Step 2 — Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize the user interface: .. code-block:: python from sigimax.config import CONF as Conf, _ from sigimax.mainwindow import SGMXMainWindow from sigimax.widgets.plotdock import DockablePlotWidget from plotpy.constants import PlotType class MyAppMainWindow(SGMXMainWindow): def __init__(self, console=None, hide_on_close=False): Conf.app_name.set("MyApp") super().__init__(console=console, hide_on_close=hide_on_close) # Add a dockable curve plot self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) dock, loc = self.curve_dock.create_dockwidget(_("Curve Viewer")) self.addDockWidget(loc, dock) **Step 3 — Launch** the application: .. code-block:: python from sigimax.app import run run(window_class=MyAppMainWindow) That's it. Three classes, three steps — and you have a full-featured scientific desktop application with menus, toolbars, console, HDF5 workspace, and status bar. Key Concepts ^^^^^^^^^^^^ - **Configuration system**: Options are typed fields (``TypedOptionField``, ``EnumOptionField``, ``TupleOptionField``) that support ``get()``/``set()``/ ``context()`` API and JSON persistence. - **Overridable hooks**: The main window provides hooks that derived apps can override: ``reset_all()``, ``_is_save_enabled()``, ``_update_file_menu()``, ``_update_view_menu()``, ``_about()``, ``_before_setup()``, and ``_after_setup()``. See :doc:`lifecycle` for their ordering and initialization contract. - **Built-in features**: HDF5 GUI and generic dataset import; derived applications implement their own workspace persistence. SigimaX also provides an embedded Python console, status bar with memory monitoring, and splash screen support. See :doc:`hdf5_workspace` for the derived-application contract. What's Included --------------- SigimaX provides 15+ ready-to-use scientific widgets: .. list-table:: :header-rows: 1 :align: left :widths: 30 70 * - Widget - Purpose * - :class:`~sigimax.widgets.plotdock.DockablePlotWidget` - Embeddable PlotPy plot in dock widgets * - :class:`~sigimax.widgets.h5browser.H5BrowserDialog` - Interactive HDF5 file browser * - :class:`~sigimax.widgets.fitdialog` - Curve fitting dialogs (Gaussian, polynomial, etc.) * - :class:`~sigimax.widgets.signalpeak` - Signal peak detection dialog * - :class:`~sigimax.widgets.signalbaseline` - Signal baseline selection dialog * - :class:`~sigimax.widgets.signalcursor` - Signal cursor selection dialog * - :class:`~sigimax.widgets.signaldeltax` - Signal delta-X measurement dialog * - :class:`~sigimax.widgets.imagebackground` - Image background selection dialog * - :class:`~sigimax.widgets.logviewer.LogViewerWindow` - Log viewer dialog * - :class:`~sigimax.widgets.wizard.Wizard` - Multi-page wizard dialog * - :class:`~sigimax.widgets.splashscreen.SigimaXSplashScreen` - Configurable splash screen * - :class:`~sigimax.widgets.status.MemoryStatus` - Status bar memory usage widget * - :class:`~sigimax.widgets.warningerror.WarningErrorMessageBox` - Warning/error display dialog Next Steps ---------- - Browse the :doc:`../auto_examples/index` to see SigimaX in action - Read the :doc:`overview` for architecture details - Dive into the :doc:`/api/index` for complete reference documentation DataLab-Platform-SigimaX-0182bf4/doc/user_guide/hdf5_workspace.rst000066400000000000000000000065761524425144300250330ustar00rootroot00000000000000HDF5 Workspace ============== SigimaX provides the HDF5 user interface and generic dataset import. A derived application owns its workspace model and its workspace file format. SigimaX does not define a universal workspace schema. Responsibilities ---------------- SigimaX provides: - File, Open/Import, Browse, and Save actions; - the HDF5 browser and :class:`sigimax.h5.H5Importer` for arbitrary datasets; - :meth:`sigimax.mainwindow.SGMXMainWindow.set_modified`, the save prompt, and the close workflow; - protected HDF5 hooks for derived applications. A derived application provides: - its data model; - workspace serialization and deserialization; - the mutation points that mark the workspace modified; - optional application-specific dataset import. A base :class:`~sigimax.mainwindow.SGMXMainWindow` has no data model. Its Save action remains disabled and a direct call to ``save_h5_workspace`` raises :class:`NotImplementedError`. This prevents a successful-looking save from silently discarding a modified workspace. Minimal Derived Application --------------------------- The SigimaX test suite contains an executable reference application. It is both an integration test and a complete minimal example: it stores signals and images, imports generic HDF5 datasets, and writes a small workspace format. Its data model deliberately remains application code. It uses two collections and knows how to serialize them with ``guidata``: .. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py :language: python :pyobject: SimpleObjectStore The derived window validates paths through the protected hook, writes its model, and only clears the modified flag after a successful write: .. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py :language: python :pyobject: DerivedAppWindow.save_h5_workspace The matching loader is equally application-specific: .. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py :language: python :pyobject: DerivedAppWindow.load_h5_workspace Workspace State and Save ------------------------ Call ``set_modified(True)`` whenever an application mutation changes the workspace. The framework adds an asterisk to the window title, enables Save for windows that implement ``save_h5_workspace``, and asks for confirmation when the user closes a modified window. A successful ``save_h5_workspace`` implementation must call ``set_modified(False)`` only after its writer closes without an exception. An exception or a cancelled save leaves the workspace modified, so the close flow remains safe. Generic Dataset Import ---------------------- ``open_h5_files`` and :class:`~sigimax.widgets.h5browser.H5BrowserDialog` are for importing arbitrary HDF5 datasets. They are independent from an application's workspace loader. The reference application connects ``SIG_SEND_OBJECTLIST`` to its model and implements ``import_dataset_from_file`` for programmatic selection of a dataset. The generic import layer can create :class:`sigima.objects.SignalObj` and :class:`sigima.objects.ImageObj`; a derived application decides how these objects enter its own model. DataLab ------- DataLab keeps its native workspace format, panel serialization, metadata, ROI, and analysis-result handling. Its ``save_h5_workspace`` override remains the owner of that format. The SigimaX HDF5 GUI and generic import infrastructure do not change DataLab's native HDF5 layout. DataLab-Platform-SigimaX-0182bf4/doc/user_guide/index.rst000066400000000000000000000017151524425144300232240ustar00rootroot00000000000000User Guide ========== .. only:: html and not latex .. grid:: 2 2 4 4 :gutter: 1 2 3 4 .. grid-item-card:: :octicon:`download;1em;sd-text-info` Installation :link: installation :link-type: doc How to install SigimaX .. grid-item-card:: :octicon:`rocket;1em;sd-text-info` Getting Started :link: getting_started :link-type: doc Build your first app in minutes .. grid-item-card:: :octicon:`book;1em;sd-text-info` Overview :link: overview :link-type: doc Architecture and design philosophy .. grid-item-card:: :octicon:`star;1em;sd-text-info` Features :link: features :link-type: doc Key functionalities .. toctree:: :maxdepth: 2 :caption: Contents: installation getting_started hdf5_workspace remote_control lifecycle overview features DataLab-Platform-SigimaX-0182bf4/doc/user_guide/installation.rst000066400000000000000000000057411524425144300246210ustar00rootroot00000000000000.. _installation: Installation ============ This section provides instructions on how to install and set up SigimaX, including dependencies and environment configuration. How to install -------------- SigimaX is available in several forms: - As a Python package, which can be installed using the :ref:`install_pip`. - As a precompiled :ref:`install_wheel`, which can be installed using ``pip``. - As a :ref:`install_source`, which can be installed from the Git repository. .. seealso:: Impatient to try the next version of SigimaX? You can also install the latest development version from the main branch of the Git repository. See :ref:`install_development` for more information. .. _install_pip: Package manager ``pip`` ^^^^^^^^^^^^^^^^^^^^^^^ :octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` SigimaX's package ``sigimax`` is available on the Python Package Index (PyPI) at: https://pypi.python.org/pypi/sigimax. Install SigimaX by running: .. code-block:: console $ pip install sigimax .. note:: If you already have a previous version of SigimaX installed, you can upgrade it by running the same command with the ``--upgrade`` option: .. code-block:: console $ pip install --upgrade sigimax .. _install_wheel: Wheel package ^^^^^^^^^^^^^ :octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` On any operating system, using pip and the Wheel package is the easiest way to install SigimaX on an existing Python distribution: .. code-block:: console $ pip install --upgrade sigimax-0.1.0-py2.py3-none-any.whl .. _install_source: Source package ^^^^^^^^^^^^^^ :octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` Installing SigimaX directly from the source package may be done using ``pip``: .. code-block:: console $ pip install --upgrade sigimax-0.1.0.tar.gz Or, if you prefer, you can install it in editable mode from the root directory of the source package: .. code-block:: console $ pip install -e . .. _install_development: Development version ^^^^^^^^^^^^^^^^^^^ :octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` If you want to try the latest development version of SigimaX, you can install it directly from the main branch of the Git repository. The first time you install SigimaX from the Git repository, enter the following command: .. code-block:: console $ pip install git+https://github.com/DataLab-Platform/SigimaX.git Then, if at some point you want to upgrade to the latest version, run: .. code-block:: console $ pip install --force-reinstall --no-deps git+https://github.com/DataLab-Platform/SigimaX.git .. note:: If dependencies have changed, you may need to execute the same command without the ``--no-deps`` option. Dependencies ------------ .. include:: ../requirements.rst DataLab-Platform-SigimaX-0182bf4/doc/user_guide/lifecycle.rst000066400000000000000000000036221524425144300240530ustar00rootroot00000000000000Main-Window Lifecycle ===================== SigimaX creates a derived main window in a predictable sequence. Use the protected hooks instead of relying on incidental constructor ordering. Initialization Order -------------------- When a :class:`~sigimax.mainwindow.SGMXMainWindow` is created, SigimaX: 1. initializes its own non-virtual state; 2. calls ``_before_setup(console)``; 3. applies the color mode through ``_update_color_mode(startup=True)``; 4. creates the generic status bar, actions, central widget, menus, and restored window state; 5. calls ``_after_setup(console)``; and 6. restores the window geometry. Use ``_before_setup`` for derived state that another protected hook may need during startup. Use ``_after_setup`` for work that requires generic widgets such as actions, menus, or the status bar. The HDF5 reference application follows this rule by creating its data model in ``_before_setup``: .. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py :language: python :pyobject: DerivedAppWindow._before_setup Singleton Access ---------------- Call ``MyMainWindow.get_instance()`` on the concrete derived class. It returns the current instance of that class or creates one of the same class. This keeps the base framework from accidentally constructing ``SGMXMainWindow`` when an application requested its own window subclass. Shutdown Order -------------- For a modified workspace, the close flow asks the user to save first. A successful application save clears the modified state; cancellation or failure leaves it set and stops the close. Once closing proceeds, SigimaX calls ``_close_managed_widgets()``, ``_cleanup_before_reset()``, ``reset_all()``, ``_save_pos_size_and_state()``, and ``_cleanup_after_state_save()`` in that order. Derived applications should use these hooks for their own managed resources and call ``super()`` when preserving the generic behavior is required.DataLab-Platform-SigimaX-0182bf4/doc/user_guide/overview.rst000066400000000000000000000150061524425144300237610ustar00rootroot00000000000000.. _overview: Overview ======== What is SigimaX? ----------------- **SigimaX** is an open-source Python framework for building Qt-based scientific desktop applications. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features instead of boilerplate. SigimaX is extracted from `DataLab `_, a mature open-source platform for scientific signal and image processing, and powers its GUI layer. Why SigimaX? ------------- Building a scientific desktop application from scratch typically requires: - A main window with menus, toolbars, dock widgets, and status bar - A configuration system with typed options, persistence, and defaults - HDF5 file management (industry standard for scientific data) - An embedded Python console for scripting and debugging - Specialized dialogs for signal/image analysis (fitting, peak detection, etc.) - Production-grade features: splash screen, memory monitoring, log viewer SigimaX provides all of these as a **subclassable framework**, so derived applications only need to add their domain-specific logic. Position in the Stack --------------------- SigimaX sits between the low-level libraries (PlotPy, guidata, PythonQwt) and end-user applications: .. code-block:: text End-user apps (DataLab, custom scientific apps) ↓ subclass / configure SigimaX â†� THIS PROJECT (framework layer) ↓ depends on Sigima (computation) + PlotPy + guidata + PythonQwt ↓ NumPy / SciPy / Qt .. list-table:: :header-rows: 1 :align: left * - Layer - Project - Role * - End-user apps - DataLab - Signal/image processing GUI application * - GUI framework - **SigimaX** - Reusable application skeleton * - Computation - Sigima - Headless scientific computing (signals & images) * - Plotting - PlotPy + PythonQwt - Interactive plot widgets * - GUI toolkit - guidata - Dataset/parameter framework with automatic GUI generation * - Foundation - NumPy + SciPy + Qt - Core scientific and GUI libraries Architecture ------------ .. code-block:: text sigimax/ ├── app.py # Application launcher (create / run) ├── config.py # Configuration system (SigimaXOptions, CONF singleton) ├── env.py # Runtime environment (verbosity, unattended mode) ├── mainwindow.py # SGMXMainWindow (generic main window) ├── widgets/ # Reusable Qt widgets │ ├── plotdock.py # DockablePlotWidget │ ├── splashscreen.py # Configurable splash screen │ ├── h5browser.py # HDF5 file browser │ ├── logviewer.py # Log viewer dialog │ ├── status.py # Status bar widgets (memory, console) │ ├── fitdialog.py # Curve fitting dialogs │ ├── signalpeak.py # Signal peak detection │ ├── signalbaseline.py # Signal baseline selection │ ├── signalcursor.py # Signal cursor selection │ ├── signaldeltax.py # Signal delta-X measurement │ ├── wizard.py # Multi-page wizard dialog │ └── ... # File dialogs, warning/error boxes ├── h5/ # HDF5 I/O (read/write/import) ├── adapters_plotpy/ # Converters between PlotPy/guidata and Sigima objects ├── utils/ # Qt helpers, config dir resolution ├── data/ # Icons, resources └── locale/ # Translations (EN, FR) Core Modules ^^^^^^^^^^^^ .. list-table:: :header-rows: 1 :align: left * - Module - Purpose * - :mod:`sigimax.app` - Application launcher — ``create()`` and ``run()`` functions * - :mod:`sigimax.config` - Configuration system — ``SigimaXOptions``, ``CONF`` singleton, option fields * - :mod:`sigimax.env` - Runtime environment — ``SGMXExecEnv``, verbosity levels, unattended mode * - :mod:`sigimax.mainwindow` - ``SGMXMainWindow`` — generic main window with menus, console, HDF5 workspace Design Philosophy ----------------- SigimaX separates the **generic application skeleton** from **domain-specific logic**. Derived applications follow a three-step pattern: 1. **Subclass** :class:`~sigimax.config.SigimaXOptions` to add application-specific configuration fields 2. **Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize menus, toolbars, and dock widgets 3. **Call** :func:`~sigimax.app.run` to launch the application with splash screen support This architecture is proven in production: `DataLab `_ is built entirely on this pattern. Configuration System ^^^^^^^^^^^^^^^^^^^^ The configuration system uses typed option fields that support: - **Type safety**: ``TypedOptionField`` (int, str, bool, float), ``EnumOptionField`` (constrained choices), ``TupleOptionField`` (fixed-length tuples), ``FontOptionField`` (font validation) - **Persistence**: JSON save/load with ``save()`` and ``load()`` methods - **Context managers**: Temporary overrides with ``option.context(value)`` - **Defaults**: Built-in reset-to-defaults mechanism .. code-block:: python from sigimax.config import CONF as Conf # Get/set options colormap = Conf.ima_def_colormap.get() Conf.ima_def_colormap.set("gray") # Context manager for temporary overrides with Conf.fft_shift_enabled.context(False): # FFT shift disabled in this block ... What SigimaX Provides vs What Stays in Derived Apps ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. list-table:: :header-rows: 1 :align: left :widths: 50 50 * - **In SigimaX** - **Stays in derived apps (e.g. DataLab)** * - Configuration system - Signal/Image panels, processors * - Generic main window - Action handler, plugin system * - Dockable plot widgets - Remote control (XML-RPC, Web API) * - HDF5 I/O + browser - Macro editor, new-object dialogs * - Scientific dialogs (fit, baseline, peak, cursor…) - Application-specific UI and processing * - Log viewer, status bar, splash screen, wizard - Object model, plot handler * - PlotPy adapters - Processor registration pattern * - Environment/exec utilities - Tour/tutorial features DataLab-Platform-SigimaX-0182bf4/doc/user_guide/remote_control.rst000066400000000000000000000110431524425144300251430ustar00rootroot00000000000000Remote Control Widgets ====================== SigimaX provides ready-to-use Qt widgets to build a *remote client* application: a separate process that connects to a running DataLab-derived application server and exchanges signals/images with it, using :class:`sigima.client.SimpleRemoteProxy` (or any proxy exposing the same ``connect``/``get_current_panel``/``get_group_titles_with_object_info`` API). These widgets originate from the (now unmaintained) ``cdlclient`` / `DataLab Simple Client `_ package. They have been ported into SigimaX, generalized (no DataLab-specific branding hardcoded), and adapted to the current :mod:`sigima.client` API. Responsibilities ---------------- SigimaX provides: - :class:`~sigimax.widgets.connection.ConnectionDialog`, a small modal dialog showing the progress of a (possibly slow or failing) connection attempt; - :class:`~sigimax.widgets.objectdialog.GetObjectDialog` and :class:`~sigimax.widgets.objectdialog.SimpleObjectTree`, a dialog and tree widget to browse and pick a signal or image among the objects currently available on the connected server; - generic icons for signals, images and groups (:mod:`sigimax.utils.svg_icons`). A derived (client) application provides: - the actual :class:`sigima.client.SimpleRemoteProxy` (or compatible proxy) instance and its connection parameters; - what to do with the object selected in :class:`~sigimax.widgets.objectdialog.GetObjectDialog` (e.g. retrieve its data with :meth:`~sigima.client.base.SimpleBaseProxy.get_object`); the widgets never modify the connection or the remote objects themselves. Connecting to a Server ----------------------- :class:`~sigimax.widgets.connection.ConnectionDialog` wraps a *blocking* connect callback (typically :meth:`sigima.client.remote.SimpleRemoteProxy.connect`) in a background thread, so the Qt event loop keeps running while the connection is attempted: .. code-block:: python from guidata.qthelpers import qt_app_context from sigima.client import SimpleRemoteProxy from sigimax.widgets.connection import ConnectionDialog proxy = SimpleRemoteProxy(autoconnect=False) with qt_app_context(): dlg = ConnectionDialog(proxy.connect) if dlg.exec(): print(f"Connected on port {proxy.port}") else: print(f"Connection failed: {dlg.get_error_message()}") The dialog is generic: it does not assume any application branding. A derived application may pass its own window icon and/or a banner pixmap: .. code-block:: python dlg = ConnectionDialog(proxy.connect, icon=my_icon, banner=my_banner_pixmap) Picking a Remote Object ------------------------ Once connected, :class:`~sigimax.widgets.objectdialog.GetObjectDialog` lists the groups and objects (signals and/or images) currently available on the server, using :meth:`sigima.client.base.SimpleBaseProxy.get_group_titles_with_object_info`: .. code-block:: python from sigimax.widgets.objectdialog import GetObjectDialog with qt_app_context(): dlg = GetObjectDialog(parent, proxy, panel="signal") if dlg.exec(): uuid = dlg.get_current_object_uuid() obj_data = proxy.get_object(uuid) Passing ``panel=None`` (the default) lets the user switch between the signal and image panels from a combo box inside the dialog. Passing ``panel="signal"`` or ``panel="image"`` restricts the dialog to a single panel and hides the combo box. Testing Without a Real Server ------------------------------ :mod:`sigima.client.stub` provides a lightweight in-process XML-RPC server (:class:`~sigima.client.stub.DataLabStubServer`) that implements enough of the protocol to exercise these widgets headlessly, without launching a real DataLab-derived application. This is how SigimaX's own test suite validates :class:`~sigimax.widgets.connection.ConnectionDialog` and :class:`~sigimax.widgets.objectdialog.GetObjectDialog`: .. literalinclude:: ../../sigimax/tests/widgets/test_object_dialog.py :language: python :pyobject: _connected_proxy_with_signals .. note:: ``SimpleRemoteProxy.add_signal`` stores the signal on the stub server unconditionally, but does not attach it to any group. The example above wires the stub server's default group directly (through the ``DataLabStubServer`` instance) to get a deterministic, non-empty tree for the test. A real DataLab-derived server groups newly added objects automatically. See also the :ref:`sphx_glr_auto_examples_features_remote_control_widgets.py` example for a runnable, standalone script. DataLab-Platform-SigimaX-0182bf4/pyproject.toml000066400000000000000000000102001524425144300213640ustar00rootroot00000000000000# SigimaX setup configuration file [project] name = "sigimax" authors = [{ name = "Pierre Raybaut", email = "p.raybaut@codra.fr" }] maintainers = [ { name = "DataLab Platform Developers", email = "datalab@codra.fr" }, ] description = "Reusable GUI components and application utilities, part of the DataLab platform for signal and image processing" readme = "README.md" license = "BSD-3-Clause" license-files = ["LICENSE"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "Intended Audience :: End Users/Desktop", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows :: Windows 7", "Operating System :: Microsoft :: Windows :: Windows 8", "Operating System :: Microsoft :: Windows :: Windows 10", "Operating System :: Microsoft :: Windows :: Windows 11", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Image Processing", "Topic :: Scientific/Engineering :: Human Machine Interfaces", "Topic :: Scientific/Engineering :: Visualization", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Widget Sets", ] requires-python = ">=3.9, <4" dependencies = [ "guidata >= 3.15.0", "PlotPy >= 2.11.0", "psutil >= 5.7", "Sigima >= 1.2.0", ] dynamic = ["version"] [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project.urls] Homepage = "https://github.com/DataLab-Platform/SigimaX/" Documentation = "https://sigimax.readthedocs.io/" [project.optional-dependencies] qt = ["PyQt5 >= 5.15.6"] dev = ["ruff", "pylint", "Coverage"] doc = [ "sphinx", "sphinx_intl", "sphinx-sitemap", "myst_parser", "myst-nb", "sphinx_design", "sphinx_gallery", "sphinx-copybutton", "pydata-sphinx-theme", ] test = ["pytest", "pytest-xvfb"] [tool.setuptools.packages.find] include = ["sigimax*"] [tool.setuptools.package-data] "*" = [ "*.pdf", "*.png", "*.svg", "*.mo", "*.txt", "*.json", "*_test.h5", "*.npy", "*.dcm", "*.scor-data", "*.tiff", "*.jpg", "*.sif", "*.csv", "*.mat", "*.js", "*.css", "*.html", "*.buildinfo", "*.inv", ] [tool.setuptools.dynamic] version = { attr = "sigimax._metadata.__version__" } [tool.pytest.ini_options] addopts = "sigimax --import-mode=importlib" filterwarnings = [ "ignore::DeprecationWarning:pandas.*", "ignore::DeprecationWarning:scipy.*", "ignore::DeprecationWarning:skimage.*", ] markers = [ "validation: mark a test as a validation test (ground truth or analytical)", "unit: pure logic test, no Qt application context needed", "app: requires full application context (SGMXMainWindow)", "gui: requires visible Qt window (use --show-windows)", ] [tool.ruff] exclude = [".git", ".vscode", "build", "dist", "*.ipynb"] line-length = 88 # Same as Black. indent-width = 4 # Same as Black. target-version = "py39" # Assume Python 3.9. [tool.ruff.lint] # all rules can be found here: https://beta.ruff.rs/docs/rules/ select = ["D202", "D403", "E", "F", "I", "NPY201", "RUF022", "W"] ignore = [ "E203", # space before : (needed for how black formats slicing) ] [tool.ruff.format] quote-style = "double" # Like Black, use double quotes for strings. indent-style = "space" # Like Black, indent with spaces, rather than tabs. skip-magic-trailing-comma = false # Like Black, respect magic trailing commas. line-ending = "auto" # Like Black, automatically detect the appropriate line ending. [tool.ruff.lint.pydocstyle] convention = "google" [tool.ruff.lint.per-file-ignores] "doc/*" = ["E402"] DataLab-Platform-SigimaX-0182bf4/requirements.txt000066400000000000000000000003621524425144300217440ustar00rootroot00000000000000Coverage PlotPy >= 2.11.0 PyQt5 >= 5.15.6 Sigima >= 1.2.0 guidata >= 3.15.0 myst-nb myst_parser psutil >= 5.7 pydata-sphinx-theme pylint pytest pytest-xvfb ruff sphinx sphinx-copybutton sphinx-sitemap sphinx_design sphinx_gallery sphinx_intl DataLab-Platform-SigimaX-0182bf4/scripts/000077500000000000000000000000001524425144300201465ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/scripts/ci_release_helpers.py000066400000000000000000000076061524425144300243460ustar00rootroot00000000000000# -*- coding: utf-8 -*- """ CI helpers used by the GitHub release workflows. Subcommands ----------- ``check-tag`` Compare a release tag (``vX.Y.Z`` or ``vX.Y.Z-rcN``) against ``sigimax.__version__``. Fails with exit code 1 on mismatch. ``release-notes`` Print the contents of ``doc/release_notes/release_..md`` matching the given tag/version to stdout (or to ``--output`` file). Fails with exit code 1 if the file does not exist. These commands are intentionally dependency-free (stdlib only) so they can run on a minimal Python install at the very start of a CI job. """ from __future__ import annotations import argparse import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent RELEASE_NOTES_DIR = REPO_ROOT / "doc" / "release_notes" TAG_RE = re.compile( r"^v(?P\d+)\.(?P\d+)\.(?P\d+)(?P
[-.][\w.]+)?$"
)


def _parse_tag(tag: str) -> tuple[int, int, int, str]:
    """Return ``(major, minor, patch, pre)`` from ``vX.Y.Z[-preN]``."""
    match = TAG_RE.match(tag)
    if not match:
        raise SystemExit(
            f"Tag {tag!r} does not match the expected pattern vMAJOR.MINOR.PATCH[-preN]"
        )
    return (
        int(match["major"]),
        int(match["minor"]),
        int(match["patch"]),
        match["pre"] or "",
    )


def _read_sigimax_version() -> str:
    metadata = REPO_ROOT / "sigimax" / "_metadata.py"
    text = metadata.read_text(encoding="utf-8")
    match = re.search(r'^__version__\s*=\s*"([^"]+)"', text, re.MULTILINE)
    if not match:
        raise SystemExit(f"Could not find __version__ in {metadata}")
    return match.group(1)


def cmd_check_tag(args: argparse.Namespace) -> int:
    major, minor, patch, _pre = _parse_tag(args.tag)
    tag_version = f"{major}.{minor}.{patch}"
    code_version = _read_sigimax_version()
    # Drop any local pre-release suffix in the code version for comparison.
    code_base = re.split(r"[-+]", code_version, maxsplit=1)[0]
    if tag_version != code_base:
        print(
            f"::error::Tag version {tag_version!r} does not match "
            f"sigimax.__version__ {code_version!r}",
            file=sys.stderr,
        )
        return 1
    print(f"Tag {args.tag} matches sigimax.__version__={code_version}")
    return 0


def _release_notes_path(major: int, minor: int) -> Path:
    return RELEASE_NOTES_DIR / f"release_{major}.{minor:02d}.md"


def cmd_release_notes(args: argparse.Namespace) -> int:
    major, minor, _patch, _pre = _parse_tag(args.tag)
    path = _release_notes_path(major, minor)
    if not path.is_file():
        print(
            f"::error::Release notes file not found: {path.relative_to(REPO_ROOT)}",
            file=sys.stderr,
        )
        return 1
    content = path.read_text(encoding="utf-8")
    if args.output:
        Path(args.output).write_text(content, encoding="utf-8")
        print(f"Wrote {args.output}")
    else:
        # Force UTF-8 on stdout so the script works on Windows consoles too.
        sys.stdout.buffer.write(content.encode("utf-8"))
    return 0


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[1])
    sub = parser.add_subparsers(dest="cmd", required=True)

    p_check = sub.add_parser(
        "check-tag", help="Validate tag against sigimax.__version__"
    )
    p_check.add_argument("tag", help="Release tag, e.g. v1.3.0 or v1.3.0-rc1")
    p_check.set_defaults(func=cmd_check_tag)

    p_notes = sub.add_parser(
        "release-notes", help="Print release notes file for the given tag"
    )
    p_notes.add_argument("tag", help="Release tag, e.g. v1.3.0")
    p_notes.add_argument(
        "-o", "--output", help="Optional output file (default: stdout)"
    )
    p_notes.set_defaults(func=cmd_release_notes)

    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
DataLab-Platform-SigimaX-0182bf4/scripts/reinstall_dev.py000066400000000000000000000066671524425144300233720ustar00rootroot00000000000000# script/reinstall_dev.py
"""
Reinstall multiple local libraries in editable mode for development.

Workflow:
  1) Try to uninstall all target libraries in one command (ignore errors if some are not
  installed).
  2) Reinstall each library in editable mode from a sibling folder: ../.

This script uses the same Python interpreter that runs it (sys.executable),
so pip operations happen in the same environment (e.g., your active venv).
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
import sysconfig
from typing import Iterable

# Absolute path to the Python executable that runs this script.
# Using sys.executable ensures pip targets the same environment (e.g., your venv).
PY = sys.executable


def run(cmd: list[str]) -> None:
    """
    Echo and execute a subprocess command.
    Raises:
        subprocess.CalledProcessError: if the command returns a non-zero exit code.
    """
    print("$", " ".join(cmd), flush=True)
    subprocess.check_call(cmd)


def uninstall_many(packages: Iterable[str]) -> None:
    """
    Attempt to uninstall multiple packages at once.
    If uninstall fails (e.g., a package is not installed), we keep going.

    Args:
        packages: An iterable of package names (pip distribution names).
    """
    pkgs = list(packages)
    if not pkgs:
        return
    try:
        run([PY, "-m", "pip", "uninstall", "-y", *pkgs])
    except subprocess.CalledProcessError as e:
        # Continue even if the uninstall step fails (for one or more packages)
        print(f"[WARN] Uninstall returned {e.returncode} — continuing...", flush=True)


def install_editable_many(packages: Iterable[str]) -> None:
    """
    Install each package in editable mode from ../.

    Assumes your project layout has sibling folders one level up, e.g.:
      ../guidata

    Args:
        packages: An iterable of package names (also used as directory names).
    """
    for pkg in packages:
        run([PY, "-m", "pip", "install", "-e", f"../{pkg}"])


def remove_residual_dirs(packages: Iterable[str]) -> None:
    """
    Force remove residual package directories from site-packages.
    This mimics: rm -rf .venv/Lib/site-packages/
    to ensure a clean slate before reinstalling.
    """
    # Locates site-packages, e.g. .venv/Lib/site-packages
    site_packages = sysconfig.get_path("purelib")

    for pkg in packages:
        target = os.path.join(site_packages, pkg)
        if os.path.isdir(target):
            print(f"Removing residual directory: {target}", flush=True)
            try:
                shutil.rmtree(target)
            except OSError as e:
                print(f"[WARN] Failed to remove {target}: {e}", flush=True)


def reinstall_packages(packages: list[str]) -> None:
    """
    High-level orchestration for many packages:
      - Uninstall all of them (ignore failures)
      - Force remove residual directories from site-packages
      - Install each in editable mode
    """
    # 1) Uninstall (ignore if not installed)
    uninstall_many(packages)

    # 2) Force remove residual directories (essential for clean reinstall)
    remove_residual_dirs(packages)

    # 3) Editable installs
    install_editable_many(packages)


if __name__ == "__main__":
    # â­� Fixed, editable local libraries to manage (edit as needed)
    PACKAGES = ["guidata", "plotpy", "sigima"]

    print("� Reinstalling editable packages:", ", ".join(PACKAGES))
    reinstall_packages(PACKAGES)
DataLab-Platform-SigimaX-0182bf4/scripts/run_with_env.py000066400000000000000000000170451524425144300232360ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""Run a command with environment variables loaded from a .env file.

This script automatically detects the best Python interpreter to use:

1. ``PYTHON`` variable in ``.env`` file (e.g. for WinPython distributions)
2. ``WINPYDIRBASE`` variable (legacy WinPython base directory)
3. ``VENV_DIR`` variable (explicit virtual environment directory)
4. A local virtual environment (``.venv*`` directory in the project root)
5. Falls back to ``sys.executable`` (the Python that launched this script)

This ensures that VS Code tasks always use the correct Python environment
regardless of which interpreter is configured globally or in VS Code.
"""

from __future__ import annotations

import glob
import os
import subprocess
import sys
from pathlib import Path


def _find_venv_python(project_root: Path) -> str | None:
    """Find a Python executable in a ``.venv*`` directory.

    Searches for directories matching ``.venv*`` in the project root and
    returns the first valid Python executable found.

    Args:
        project_root: The root directory of the project.

    Returns:
        Absolute path to the venv Python executable, or None if not found.
    """
    # Sort to prefer ".venv" over ".venv-xyz" etc.
    venv_dirs = sorted(glob.glob(str(project_root / ".venv*")))
    for venv_dir in venv_dirs:
        venv_path = Path(venv_dir)
        if not venv_path.is_dir():
            continue
        result = _get_venv_python(venv_path)
        if result:
            return result
    return None


def _get_venv_python(venv_dir: Path) -> str | None:
    """Get the Python executable from a specific venv directory.

    Args:
        venv_dir: Path to the virtual environment directory.

    Returns:
        Absolute path to the Python executable, or None if not found.
    """
    if not venv_dir.is_dir():
        return None
    # Windows: Scripts/python.exe — Unix: bin/python
    candidates = [
        venv_dir / "Scripts" / "python.exe",
        venv_dir / "bin" / "python",
    ]
    for candidate in candidates:
        if candidate.is_file():
            # Keep the venv-local executable path without resolving symlinks:
            # on Linux/WSL, ``bin/python`` is often a symlink to a global
            # interpreter (e.g. /usr/bin/python3.x). Resolving it would lose
            # venv context and site-packages selection.
            return str(candidate.absolute())
    return None


def resolve_python(project_root: Path) -> str:
    """Resolve the best Python interpreter for the project.

    Priority order:

    1. ``PYTHON`` environment variable (set in ``.env`` or externally)
    2. ``WINPYDIRBASE`` environment variable (legacy WinPython base directory)
    3. ``VENV_DIR`` environment variable (explicit venv directory)
    4. ``.venv*`` directory in *project_root* (auto-discovery)
    5. ``sys.executable`` (the interpreter running this script)

    Args:
        project_root: The root directory of the project.

    Returns:
        Absolute path to the Python executable to use.
    """
    # 1. Explicit PYTHON variable (e.g. WinPython distribution)
    python_env = os.environ.get("PYTHON")
    if python_env:
        python_path = Path(python_env)
        if python_path.is_file():
            # Do not resolve symlinks for the same reason as in
            # ``_get_venv_python``.
            resolved = str(python_path.absolute())
            print(f"  � Using PYTHON from .env: {resolved}")
            return resolved
        print(f"  ⚠�  PYTHON variable set but not found: {python_env}")

    # 2. Legacy WINPYDIRBASE variable (WinPython distribution)
    winpy_base = os.environ.get("WINPYDIRBASE")
    if winpy_base and Path(winpy_base).is_dir():
        # Search for python.exe in the WinPython directory structure
        # Patterns: python-3.11.5.amd64/python.exe (old) or python/python.exe (new)
        for pattern in ("python-*/python.exe", "python/python.exe"):
            for candidate in sorted(Path(winpy_base).glob(pattern)):
                if candidate.is_file():
                    resolved = str(candidate.absolute())
                    print(f"  � Using WINPYDIRBASE (legacy): {resolved}")
                    return resolved
        # Also try direct python.exe in the base directory
        direct = Path(winpy_base) / "python.exe"
        if direct.is_file():
            resolved = str(direct.absolute())
            print(f"  � Using WINPYDIRBASE (legacy): {resolved}")
            return resolved
        print(f"  ⚠�  WINPYDIRBASE set but no Python found in: {winpy_base}")

    # 3. Explicit VENV_DIR variable (e.g. for multiple local venvs)
    venv_dir_env = os.environ.get("VENV_DIR")
    if venv_dir_env:
        venv_dir = Path(venv_dir_env)
        if not venv_dir.is_absolute():
            venv_dir = project_root / venv_dir
        venv_python = _get_venv_python(venv_dir)
        if venv_python:
            print(f"  � Using VENV_DIR from .env: {venv_python}")
            return venv_python
        print(f"  ⚠�  VENV_DIR set but no Python found in: {venv_dir}")

    # 4. Auto-discover local venv
    venv_python = _find_venv_python(project_root)
    if venv_python:
        print(f"  � Using venv Python: {venv_python}")
        return venv_python

    # 5. Fallback
    print(f"  � Using caller Python: {sys.executable}")
    return sys.executable


def load_env_file(env_path: str | None = None) -> None:
    """Load environment variables from a .env file."""
    if env_path is None:
        env_path = Path.cwd() / ".env"
    if not Path(env_path).is_file():
        raise FileNotFoundError(f"Environment file not found: {env_path}")
    print(f"Loading environment variables from: {env_path}")
    with open(env_path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            value = value.strip().strip('"').strip("'")
            os.environ[key.strip()] = value
            print(f"  Loaded variable: {key.strip()}={value}")


def execute_command(command: list[str], python_exe: str) -> int:
    """Execute a command, replacing ``python`` placeholders.

    Any argument that is the bare word ``python`` or that points to a Python
    executable (checked via filename) is replaced by *python_exe* so that the
    subprocess uses the resolved interpreter rather than the global one.

    Args:
        command: The command and its arguments.
        python_exe: The resolved Python interpreter path.

    Returns:
        The subprocess exit code.
    """
    resolved: list[str] = []
    for arg in command:
        if arg.lower() == "python" or (
            Path(arg).name.lower().startswith("python")
            and Path(arg).is_file()
            and arg.lower() != python_exe.lower()
        ):
            resolved.append(python_exe)
        else:
            resolved.append(arg)
    print("Executing command:")
    print(" ".join(resolved))
    print("")
    result = subprocess.call(resolved)
    print(f"Process exited with code {result}")
    return result


def main() -> None:
    """Main function to load environment variables and execute a command."""
    if len(sys.argv) < 2:
        print("Usage: python run_with_env.py  [args ...]")
        sys.exit(1)
    print("� Running with environment variables")
    project_root = Path.cwd()
    load_env_file()
    python_exe = resolve_python(project_root)
    return execute_command(sys.argv[1:], python_exe)


if __name__ == "__main__":
    main()
DataLab-Platform-SigimaX-0182bf4/sigimax/000077500000000000000000000000001524425144300201205ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/__init__.py000066400000000000000000000017661524425144300222430ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
SigimaX
=======

SigimaX is a generic app components library based on Python
scientific libraries (such as NumPy, SciPy or scikit-image) and Qt graphical
user interfaces (thanks to `PlotPyStack`_ libraries).

It helps building scientific computing applications by providing a set of GUI modules.

.. _PlotPyStack: https://github.com/PlotPyStack
"""

__all__ = [
    "SGMXMainWindow",
    "create",
    "run",
]

from sigimax._metadata import (  # noqa: F401
    __docurl__,
    __homeurl__,
    __supporturl__,
    __version__,
)
from sigimax.app import create, run
from sigimax.mainwindow import SGMXMainWindow

# --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools
# ---                 to retrieve data and translation files paths
#
# Dear (Debian, RPM, ...) package makers, please feel free to customize the
# following path to module's data (images) and translations:
DATAPATH = LOCALEPATH = ""
DataLab-Platform-SigimaX-0182bf4/sigimax/_metadata.py000066400000000000000000000023521524425144300224130ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
SigimaX package metadata
=========================

This module centralizes package-level metadata (version, URLs) in a single
file that has **no internal imports**.

Why a separate module?
~~~~~~~~~~~~~~~~~~~~~~

SigimaX is a framework library whose ``__init__.py`` re-exports key symbols
(``SGMXMainWindow``, ``create``, ``run``) for convenience.  Those re-exports
pull in heavy submodules (``sigimax.app``, ``sigimax.mainwindow``) which
eventually import ``sigimax.config``.  If ``sigimax.config`` were to import
metadata back from ``sigimax.__init__``, a circular import chain would form::

    sigimax → sigimax.mainwindow → sigimax.widgets.* → sigimax.config → sigimax

By placing the metadata here, both ``__init__.py`` and ``config.py`` can
import it without triggering the cycle.  This is the same pattern used by
projects like Flask and setuptools-scm that need a rich ``__init__.py``
alongside internal access to version information.
"""

__version__ = "1.0.2"
__docurl__ = "https://sigimax.readthedocs.io/"
__homeurl__ = "https://github.com/DataLab-Platform/SigimaX"
__supporturl__ = "https://github.com/DataLab-Platform/SigimaX/issues/new/choose"
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/000077500000000000000000000000001524425144300233325ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/__init__.py000066400000000000000000000052261524425144300254500ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
Adapters for PlotPy
===================

The :mod:`sigimax.adapters_plotpy` package provides adapters for
PlotPy to integrate with SigimaX's data model and GUI.

Each Sigima object (:class:`~sigima.objects.SignalObj`,
:class:`~sigima.objects.ImageObj`, ROI classes) is converted to/from PlotPy
plot items by a dedicated adapter class. :class:`PlotPyAdapterFactory`
resolves which adapter class to use for a given object; derived applications
subclass the factory to override or extend this resolution and install it
with :func:`set_adapter_factory`.

.. autoclass:: PlotPyAdapterFactory
    :members:
.. autofunction:: get_adapter_factory
.. autofunction:: set_adapter_factory
.. autofunction:: reset_adapter_factory
.. autofunction:: create_adapter_from_object
.. autoclass:: SignalObjPlotPyAdapter
    :members:
.. autoclass:: ImageObjPlotPyAdapter
    :members:
.. autoclass:: SegmentROIPlotPyAdapter
    :members:
.. autoclass:: SignalROIPlotPyAdapter
    :members:
.. autoclass:: RectangularROIPlotPyAdapter
    :members:
.. autoclass:: CircularROIPlotPyAdapter
    :members:
.. autoclass:: PolygonalROIPlotPyAdapter
    :members:
.. autofunction:: items_to_json
.. autofunction:: json_to_items
.. autofunction:: plotitem_to_singleroi
.. autofunction:: singleroi_to_plotitem
.. autofunction:: configure_roi_item
"""

from __future__ import annotations

from .base import items_to_json, json_to_items
from .converters import (
    create_adapter_from_object,
    plotitem_to_singleroi,
    singleroi_to_plotitem,
)
from .factories import (
    PlotPyAdapterFactory,
    get_adapter_factory,
    reset_adapter_factory,
    set_adapter_factory,
)
from .objects.base import TypePlotItem
from .objects.image import (
    ImageObjPlotPyAdapter,
)
from .objects.signal import CURVESTYLES, SignalObjPlotPyAdapter
from .roi.base import TypeROIItem, configure_roi_item
from .roi.image import (
    CircularROIPlotPyAdapter,
    PolygonalROIPlotPyAdapter,
    RectangularROIPlotPyAdapter,
)
from .roi.signal import SegmentROIPlotPyAdapter, SignalROIPlotPyAdapter

__all__ = [
    "CURVESTYLES",
    "CircularROIPlotPyAdapter",
    "ImageObjPlotPyAdapter",
    "PlotPyAdapterFactory",
    "PolygonalROIPlotPyAdapter",
    "RectangularROIPlotPyAdapter",
    "SegmentROIPlotPyAdapter",
    "SignalObjPlotPyAdapter",
    "SignalROIPlotPyAdapter",
    "TypePlotItem",
    "TypeROIItem",
    "configure_roi_item",
    "create_adapter_from_object",
    "get_adapter_factory",
    "items_to_json",
    "json_to_items",
    "plotitem_to_singleroi",
    "reset_adapter_factory",
    "set_adapter_factory",
    "singleroi_to_plotitem",
]
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/annotations.py000066400000000000000000000074251524425144300262510ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
Annotation Adapter for PlotPy Integration
-----------------------------------------

This module bridges Sigima's format-agnostic annotation storage with PlotPy's
plot item system. It handles bidirectional conversion between:
- Sigima: list[dict] (JSON-serializable)
- PlotPy: list[AnnotatedShape] (plot items)
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

from guidata.io import JSONReader, JSONWriter
from plotpy.io import load_items, save_items

if TYPE_CHECKING:
    from plotpy.items import AnnotatedShape
    from sigima.objects.base import BaseObj


class PlotPyAnnotationAdapter:
    """Adapter for converting between Sigima annotations and PlotPy items.

    This class provides the bridge between Sigima's generic annotation storage
    (list of dicts) and PlotPy's specific plot item format.

    Example:
        >>> from sigima.objects.signal.creation import create_signal
        >>> obj = create_signal("Test")
        >>> adapter = PlotPyAnnotationAdapter(obj)
        >>>
        >>> # Add PlotPy items
        >>> from plotpy.items import AnnotatedRectangle
        >>> rect = AnnotatedRectangle(0, 0, 10, 10)
        >>> adapter.add_items([rect])
        >>>
        >>> # Retrieve as PlotPy items
        >>> items = adapter.get_items()
        >>> len(items)
        1
    """

    def __init__(self, obj: BaseObj):
        """Initialize adapter with an object.

        Args:
            obj: Signal or image object with annotation support
        """
        self.obj = obj

    def get_items(self) -> list[AnnotatedShape]:
        """Get annotations as PlotPy items.

        Returns:
            List of PlotPy annotation items

        Notes:
            This method deserializes the JSON data stored in the object using
            PlotPy's load_items() function.
        """
        annotations = self.obj.get_annotations()
        if not annotations:
            return []

        items = []
        for ann_dict in annotations:
            # Each annotation dict should contain PlotPy's JSON serialization
            if "plotpy_json" in ann_dict:
                try:
                    json_str = ann_dict["plotpy_json"]
                    for item in load_items(JSONReader(json_str)):
                        items.append(item)
                except (json.JSONDecodeError, ValueError, KeyError):
                    # Skip invalid items
                    continue

        return items

    def set_items(self, items: list[AnnotatedShape]) -> None:
        """Set annotations from PlotPy items.

        Args:
            items: List of PlotPy annotation items

        Notes:
            This method serializes PlotPy items to JSON using PlotPy's
            save_items() function and stores them in the Sigima format.
        """
        if not items:
            self.obj.clear_annotations()
            return

        # Convert PlotPy items to our annotation format
        annotations = []
        for item in items:
            writer = JSONWriter(None)
            save_items(writer, [item])
            ann_dict = {
                "type": "plotpy_item",
                "item_class": item.__class__.__name__,
                "plotpy_json": writer.get_json(),
            }
            annotations.append(ann_dict)

        self.obj.set_annotations(annotations)

    def add_items(self, items: list[AnnotatedShape]) -> None:
        """Add PlotPy items to existing annotations.

        Args:
            items: List of PlotPy annotation items to add
        """
        current_items = self.get_items()
        current_items.extend(items)
        self.set_items(current_items)

    def clear(self) -> None:
        """Clear all annotations."""
        self.obj.clear_annotations()
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/base.py000066400000000000000000000053541524425144300246250ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Base Module
--------------------------
"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...

from __future__ import annotations

import json
from typing import TYPE_CHECKING

from guidata.io import JSONReader, JSONWriter
from plotpy.io import load_items, save_items
from plotpy.items import (
    AbstractLabelItem,
    AnnotatedSegment,
    AnnotatedShape,
)

if TYPE_CHECKING:
    from plotpy.items import AbstractShape
    from plotpy.styles import AnnotationParam


def config_annotated_shape(
    item: AnnotatedShape,
    fmt: str,
    lbl: bool,
    section: str | None = None,
    option: str | None = None,
    show_computations: bool | None = None,
):
    """Configurate annotated shape

    Args:
        item: Annotated shape item
        fmt: Format string
        lbl: Show label
        section: Shape style section (e.g. "plot")
        option: Shape style option (e.g. "shape/drag")
        show_computations: Show computations
    """
    param: AnnotationParam = item.annotationparam
    param.format = fmt
    param.show_label = lbl
    if show_computations is not None:
        param.show_computations = show_computations

    if isinstance(item, AnnotatedSegment):
        item.label.labelparam.anchor = "T"
        item.label.labelparam.update_item(item.label)

    param.update_item(item)
    if section is not None and option is not None:
        item.set_style(section, option)


# TODO: [P3] Move this function as a method of plot items in PlotPy
def set_plot_item_editable(
    item: AbstractShape | AbstractLabelItem | AnnotatedShape, state
):
    """Set plot item editable state

    Args:
        item: Plot item
        state: State
    """
    item.set_movable(state)
    item.set_resizable(state and not isinstance(item, AbstractLabelItem))
    item.set_rotatable(state and not isinstance(item, AbstractLabelItem))
    item.set_readonly(not state)
    item.set_selectable(state)


def items_to_json(items: list) -> str | None:
    """Convert plot items to JSON string

    Args:
        items: list of plot items

    Returns:
        JSON string or None if items is empty
    """
    if items:
        writer = JSONWriter(None)
        save_items(writer, items)
        return writer.get_json(indent=4)
    return None


def json_to_items(json_str: str | None) -> list:
    """Convert JSON string to plot items

    Args:
        json_str: JSON string or None

    Returns:
        List of plot items
    """
    items = []
    if json_str:
        try:
            for item in load_items(JSONReader(json_str)):
                items.append(item)
        except json.decoder.JSONDecodeError:
            pass
    return items
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/converters.py000066400000000000000000000035571524425144300261100ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Converters
-------------------------
"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...

from __future__ import annotations

from typing import TYPE_CHECKING

from plotpy.items import (
    AnnotatedCircle,
    AnnotatedPolygon,
    AnnotatedRectangle,
    AnnotatedXRange,
)
from sigima.objects import (
    CircularROI,
    PolygonalROI,
    RectangularROI,
    SegmentROI,
)

from sigimax.adapters_plotpy.factories import (
    create_adapter_from_object,
    get_adapter_factory,
)

if TYPE_CHECKING:
    from sigima.objects import ImageObj, SignalObj


def plotitem_to_singleroi(
    plot_item: AnnotatedXRange
    | AnnotatedRectangle
    | AnnotatedCircle
    | AnnotatedPolygon,
    obj: SignalObj | ImageObj | None = None,
) -> SegmentROI | RectangularROI | CircularROI | PolygonalROI:
    """Create a single ROI from the given PlotPy item to integrate with SigimaX

    Args:
        plot_item: The PlotPy item for which to create a single ROI
        obj: Optional signal or image object for coordinate rounding

    Returns:
        A single ROI instance
    """
    adapter = get_adapter_factory().get_adapter_class_for_plot_item(plot_item)
    return adapter.from_plot_item(plot_item, obj)


def singleroi_to_plotitem(
    roi: SegmentROI | RectangularROI | CircularROI | PolygonalROI,
    obj: SignalObj | ImageObj,
) -> AnnotatedXRange | AnnotatedRectangle | AnnotatedCircle | AnnotatedPolygon:
    """Create a PlotPy item from the given single ROI to integrate with SigimaX

    Args:
        roi: The single ROI for which to create a PlotPy item
        obj: The object (signal or image) associated with the ROI

    Returns:
        A PlotPy item instance
    """
    adapter = create_adapter_from_object(roi)
    return adapter.to_plot_item(obj)
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/coordutils.py000066400000000000000000000131421524425144300260740ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
ROI Coordinate Utilities
=========================

This module provides utility functions for rounding ROI coordinates to appropriate
precision based on the sampling characteristics of signals and images.

These functions are used when converting interactive PlotPy shapes to ROI objects
to ensure coordinates are displayed with reasonable precision.
"""

from __future__ import annotations

import numpy as np
from sigima.objects import ImageObj, ROI1DParam, ROI2DParam, SignalObj


def round_signal_coords(
    obj: SignalObj, coords: list[float], precision_factor: float = 0.1
) -> list[float]:
    """Round signal coordinates to appropriate precision based on sampling period.

    Rounds to a fraction of the median sampling period to avoid excessive decimal
    places while maintaining reasonable precision.

    Args:
        obj: signal object
        coords: coordinates to round
        precision_factor: fraction of sampling period to use as rounding precision.
            Default is 0.1 (1/10th of sampling period).

    Returns:
        Rounded coordinates
    """
    if len(obj.x) < 2:
        # Cannot compute sampling period, return coords as-is
        return coords
    # Compute median sampling period
    sampling_period = float(np.median(np.diff(obj.x)))
    if sampling_period == 0:
        # Avoid division by zero for constant x arrays
        return coords
    # Round to specified fraction of sampling period
    precision = sampling_period * precision_factor
    # Determine number of decimal places
    if precision > 0:
        decimals = max(0, int(-np.floor(np.log10(precision))))
        return [round(c, decimals) for c in coords]
    return coords


def round_image_coords(
    obj: ImageObj, coords: list[float], precision_factor: float = 0.1
) -> list[float]:
    """Round image coordinates to appropriate precision based on pixel spacing.

    Rounds to a fraction of the pixel spacing to avoid excessive decimal places
    while maintaining reasonable precision. Uses separate precision for X and Y.

    Args:
        obj: image object
        coords: flat list of coordinates [x0, y0, x1, y1, ...] to round
        precision_factor: fraction of pixel spacing to use as rounding precision.
            Default is 0.1 (1/10th of pixel spacing).

    Returns:
        Rounded coordinates

    Raises:
        ValueError: if coords does not contain an even number of elements
    """
    if len(coords) % 2 != 0:
        raise ValueError("coords must contain an even number of elements (x, y pairs).")
    if len(coords) == 0:
        return coords

    rounded = list(coords)
    if obj.is_uniform_coords:
        # Use dx, dy for uniform coordinates
        precision_x = abs(obj.dx) * precision_factor
        precision_y = abs(obj.dy) * precision_factor
    else:
        # Compute average spacing for non-uniform coordinates
        if len(obj.xcoords) > 1:
            avg_dx = float(np.mean(np.abs(np.diff(obj.xcoords))))
            precision_x = avg_dx * precision_factor
        else:
            precision_x = 0
        if len(obj.ycoords) > 1:
            avg_dy = float(np.mean(np.abs(np.diff(obj.ycoords))))
            precision_y = avg_dy * precision_factor
        else:
            precision_y = 0

    # Round X coordinates (even indices)
    if precision_x > 0:
        decimals_x = max(0, int(-np.floor(np.log10(precision_x))))
        for i in range(0, len(rounded), 2):
            rounded[i] = round(rounded[i], decimals_x)

    # Round Y coordinates (odd indices)
    if precision_y > 0:
        decimals_y = max(0, int(-np.floor(np.log10(precision_y))))
        for i in range(1, len(rounded), 2):
            rounded[i] = round(rounded[i], decimals_y)

    return rounded


def round_signal_roi_param(
    obj: SignalObj, param: ROI1DParam, precision_factor: float = 0.1
) -> None:
    """Round signal ROI parameter coordinates in-place.

    Args:
        obj: signal object
        param: ROI parameter to round (modified in-place)
        precision_factor: fraction of sampling period to use as rounding precision
    """
    coords = round_signal_coords(obj, [param.xmin, param.xmax], precision_factor)
    param.xmin, param.xmax = coords


def round_image_roi_param(
    obj: ImageObj, param: ROI2DParam, precision_factor: float = 0.1
) -> None:
    """Round image ROI parameter coordinates in-place.

    Args:
        obj: image object
        param: ROI parameter to round (modified in-place)
        precision_factor: fraction of pixel spacing to use as rounding precision
    """
    if param.geometry == "rectangle":
        # Round x0, y0, dx, dy
        x0, y0, x1, y1 = param.x0, param.y0, param.x0 + param.dx, param.y0 + param.dy
        coords = round_image_coords(obj, [x0, y0, x1, y1], precision_factor)
        param.x0, param.y0 = coords[0], coords[1]
        # Round dx and dy to avoid floating-point errors in subtraction
        dx_dy_rounded = round_image_coords(
            obj, [coords[2] - coords[0], coords[3] - coords[1]], precision_factor
        )
        param.dx = dx_dy_rounded[0]
        param.dy = dx_dy_rounded[1]
    elif param.geometry == "circle":
        # Round xc, yc, r
        coords = round_image_coords(obj, [param.xc, param.yc], precision_factor)
        param.xc, param.yc = coords
        # Round radius using X precision
        r_rounded = round_image_coords(obj, [param.r, 0], precision_factor)[0]
        param.r = r_rounded
    elif param.geometry == "polygon":
        # Round polygon points
        rounded = round_image_coords(obj, param.points.tolist(), precision_factor)
        param.points = np.array(rounded)
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/factories.py000066400000000000000000000144321524425144300256670ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Factories
------------------------
"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...

from __future__ import annotations

__all__ = [
    "PlotPyAdapterFactory",
    "create_adapter_from_object",
    "get_adapter_factory",
    "reset_adapter_factory",
    "set_adapter_factory",
]


class PlotPyAdapterFactory:
    """Resolve the PlotPy adapter class associated with an object or plot item.

    A derived application subclasses this factory to substitute its own
    adapters or to support additional object types, then installs it with
    :func:`set_adapter_factory` so that SigimaX components use it too.
    """

    def get_adapter_class(self, object_to_adapt) -> type:
        """Return the adapter class for the given object.

        Args:
            object_to_adapt: The object to adapt (signal, image or ROI)

        Returns:
            The adapter class (instantiated with the object as sole argument)

        Raises:
            TypeError: If the object type is not supported
        """
        # pylint: disable=import-outside-toplevel,cyclic-import
        from sigima.objects import (
            CircularROI,
            ImageObj,
            ImageROI,
            PolygonalROI,
            RectangularROI,
            SegmentROI,
            SignalObj,
            SignalROI,
        )

        from sigimax.adapters_plotpy.objects.image import ImageObjPlotPyAdapter
        from sigimax.adapters_plotpy.objects.signal import SignalObjPlotPyAdapter
        from sigimax.adapters_plotpy.roi.image import (
            CircularROIPlotPyAdapter,
            ImageROIPlotPyAdapter,
            PolygonalROIPlotPyAdapter,
            RectangularROIPlotPyAdapter,
        )
        from sigimax.adapters_plotpy.roi.signal import (
            SegmentROIPlotPyAdapter,
            SignalROIPlotPyAdapter,
        )

        # Order matters: check more specific types (e.g. SegmentROI) before
        # their more generic bases (e.g. SignalROI) where applicable.
        type_to_adapter: tuple[tuple[type, type], ...] = (
            (SignalObj, SignalObjPlotPyAdapter),
            (SignalROI, SignalROIPlotPyAdapter),
            (SegmentROI, SegmentROIPlotPyAdapter),
            (ImageObj, ImageObjPlotPyAdapter),
            (RectangularROI, RectangularROIPlotPyAdapter),
            (CircularROI, CircularROIPlotPyAdapter),
            (PolygonalROI, PolygonalROIPlotPyAdapter),
            (ImageROI, ImageROIPlotPyAdapter),
        )
        for obj_type, adapter_class in type_to_adapter:
            if isinstance(object_to_adapt, obj_type):
                return adapter_class
        raise TypeError(f"Unsupported object type: {type(object_to_adapt)}")

    def get_adapter_class_for_plot_item(self, plot_item) -> type:
        """Return the single-ROI adapter class matching the given PlotPy item.

        Args:
            plot_item: The PlotPy item to convert back into a single ROI

        Returns:
            The single-ROI adapter class

        Raises:
            TypeError: If the plot item type is not supported
        """
        # pylint: disable=import-outside-toplevel
        from plotpy.items import (
            AnnotatedCircle,
            AnnotatedPolygon,
            AnnotatedRectangle,
            AnnotatedXRange,
        )

        from sigimax.adapters_plotpy.roi.image import (
            CircularROIPlotPyAdapter,
            PolygonalROIPlotPyAdapter,
            RectangularROIPlotPyAdapter,
        )
        from sigimax.adapters_plotpy.roi.signal import SegmentROIPlotPyAdapter

        if isinstance(plot_item, AnnotatedXRange):
            return SegmentROIPlotPyAdapter
        if isinstance(plot_item, AnnotatedRectangle):
            return RectangularROIPlotPyAdapter
        if isinstance(plot_item, AnnotatedCircle):
            return CircularROIPlotPyAdapter
        if isinstance(plot_item, AnnotatedPolygon):
            return PolygonalROIPlotPyAdapter
        raise TypeError(f"Unsupported PlotPy item type: {type(plot_item)}")

    def create_adapter(self, object_to_adapt):
        """Create an adapter instance for the given object.

        Args:
            object_to_adapt: The object to adapt (signal, image or ROI)

        Returns:
            An adapter instance
        """
        return self.get_adapter_class(object_to_adapt)(object_to_adapt)


#: The currently active adapter factory. Defaults to the SigimaX base factory;
#: a derived application installs its own via :func:`set_adapter_factory`. All
#: SigimaX modules resolve adapters through :func:`get_adapter_factory` (never
#: by binding the base factory directly), so that a derived application's
#: adapters are honoured transparently regardless of import order.
_active_factory: PlotPyAdapterFactory = PlotPyAdapterFactory()


def get_adapter_factory() -> PlotPyAdapterFactory:
    """Return the currently active adapter factory.

    Returns:
        The active factory (the SigimaX base by default, or the one installed
         by a derived application via :func:`set_adapter_factory`).
    """
    return _active_factory


def set_adapter_factory(factory: PlotPyAdapterFactory) -> None:
    """Install a derived application's adapter factory as the active one.

    Args:
        factory: The factory to activate (typically a subclass of
         :class:`PlotPyAdapterFactory`).

    Raises:
        TypeError: If the factory is not a :class:`PlotPyAdapterFactory`.
    """
    if not isinstance(factory, PlotPyAdapterFactory):
        raise TypeError(
            "Cannot install adapter factory: expected a PlotPyAdapterFactory "
            f"instance, got {type(factory)}"
        )
    global _active_factory  # pylint: disable=global-statement
    _active_factory = factory


def reset_adapter_factory() -> None:
    """Restore the SigimaX base adapter factory as the active one."""
    global _active_factory  # pylint: disable=global-statement
    _active_factory = PlotPyAdapterFactory()


def create_adapter_from_object(object_to_adapt):
    """Create an adapter for the given object to integrate with PlotPy

    Args:
        object_to_adapt: The object to adapt (signal, image, ROI, or scalar result)

    Returns:
        An adapter instance
    """
    return get_adapter_factory().create_adapter(object_to_adapt)
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/objects/000077500000000000000000000000001524425144300247635ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/objects/__init__.py000066400000000000000000000000001524425144300270620ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/objects/base.py000066400000000000000000000162611524425144300262550ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Base Object Module
---------------------------------
"""

from __future__ import annotations

import abc
from typing import (
    TYPE_CHECKING,
    Any,
    Generic,
    TypeVar,
)

from guidata.dataset import update_dataset
from plotpy.items import (
    AnnotatedShape,
)
from sigima.objects.base import (
    ROI_KEY,
    TypeObj,
)

from sigimax.adapters_plotpy.annotations import PlotPyAnnotationAdapter
from sigimax.adapters_plotpy.base import (
    config_annotated_shape,
    set_plot_item_editable,
)
from sigimax.config import get_conf

if TYPE_CHECKING:
    from plotpy.items import CurveItem, MaskedXYImageItem
    from sigima.config import OptionField

TypePlotItem = TypeVar("TypePlotItem", bound="CurveItem | MaskedXYImageItem")


class BaseObjPlotPyAdapter(Generic[TypeObj, TypePlotItem]):
    """Object (signal/image) plot item adapter class"""

    DEFAULT_FMT = "s"  # This is overriden in children classes

    @property
    def conf_format(self) -> OptionField:
        """Config option field holding the numeric format string.

        Overridden in the image adapter to return the image-specific field.
        Resolved at runtime via ``get_conf()`` so the active (possibly
        derived-application) configuration is honoured.
        """
        return get_conf().sig_format

    def __init__(self, obj: TypeObj) -> None:
        """Initialize the adapter with the object.

        Args:
            obj: object (signal/image)
        """
        self.obj = obj
        # An empty format string in the configuration acts as a sentinel meaning
        # "use the adapter's type-appropriate DEFAULT_FMT".
        self.__default_options = {
            "format": "%" + (self.conf_format.get() or self.DEFAULT_FMT),
            "showlabel": get_conf().show_label.get(),
        }
        self.annotation_adapter = PlotPyAnnotationAdapter(obj)

    def get_obj_option(self, name: str) -> Any:
        """Get object option value.
        Args:
            name: option name

        Returns:
            Option value
        """
        default = self.__default_options[name]
        return self.obj.get_metadata_option(name, default)

    @abc.abstractmethod
    def make_item(self, update_from: TypePlotItem | None = None) -> TypePlotItem:
        """Make plot item from data.

        Args:
            update_from: update

        Returns:
            Plot item
        """

    @abc.abstractmethod
    def update_item(self, item: TypePlotItem, data_changed: bool = True) -> None:
        """Update plot item from data.

        Args:
            item: plot item
            data_changed: if True, data has changed
        """

    def add_annotations_from_items(self, items: list) -> None:
        """Add object annotations (annotation plot items).

        Args:
            items: annotation plot items
        """
        # Use the new annotation adapter
        self.annotation_adapter.add_items(items)

    def set_annotations_from_items(self, items: list) -> None:
        """Set object annotations (annotation plot items), replacing any existing ones.

        Args:
            items: annotation plot items
        """
        # Use the new annotation adapter
        self.annotation_adapter.set_items(items)

    @abc.abstractmethod
    def add_label_with_title(self, title: str | None = None) -> None:
        """Add label with title annotation

        Args:
            title: title (if None, use object title)
        """

    def iterate_metadata_shape_items(
        self, _key: str, _value: Any, _fmt: str, _lbl: bool
    ):
        """Hook: yield additional plot items for custom metadata entries.

        Override in subclasses to handle application-specific metadata
        (e.g., geometry results, table results). Called once for each metadata
        entry whose key is not ``ROI_KEY``.

        Args:
            key: metadata key
            value: metadata value
            fmt: numeric format string (e.g. "%.3f")
            lbl: whether to show labels

        Yields:
            Plot items for this metadata entry
        """
        return
        yield  # noqa: RET504 -- make this a generator

    def iterate_shape_items(self, editable: bool = False):
        """Iterate over shape items encoded in metadata (if any).

        Args:
            editable: if True, annotations are editable

        Yields:
            Plot item
        """
        fmt = self.get_obj_option("format")
        lbl = self.get_obj_option("showlabel")
        for key, value in self.obj.metadata.items():
            if key == ROI_KEY:
                roi = self.obj.roi
                if roi is not None:
                    # Delayed import to avoid circular dependency
                    # pylint: disable=import-outside-toplevel,cyclic-import
                    from sigimax.adapters_plotpy.roi.factory import create_roi_adapter

                    adapter = create_roi_adapter(roi)
                    yield from adapter.iterate_roi_items(
                        self.obj, fmt=fmt, lbl=lbl, editable=False
                    )
            else:
                yield from self.iterate_metadata_shape_items(key, value, fmt, lbl)
        # Use the new annotation adapter to get items
        if self.obj.has_annotations():
            for item in self.annotation_adapter.get_items():
                if isinstance(item, AnnotatedShape):
                    config_annotated_shape(item, fmt, lbl)
                set_plot_item_editable(item, editable)
                yield item

    def update_plot_item_parameters(self, item: TypePlotItem) -> None:
        """Update plot item parameters from object data/metadata

        Takes into account a subset of plot item parameters. Those parameters may
        have been overriden by object metadata entries or other object data. The goal
        is to update the plot item accordingly.

        This is *almost* the inverse operation of `update_metadata_from_plot_item`.

        Args:
            item: plot item
        """
        def_dict = get_conf().get_sigima_defaults(self.__class__.__name__[:3].lower())
        self.obj.set_metadata_options_defaults(def_dict, overwrite=False)

        # Subclasses have to override this method to update plot item parameters,
        # then call this implementation of the method to update plot item.
        update_dataset(item.param, self.obj.get_metadata_options())
        item.param.update_item(item)
        if item.selected:
            item.select()

    def update_metadata_from_plot_item(self, item: TypePlotItem) -> None:
        """Update metadata from plot item.

        Takes into account a subset of plot item parameters. Those parameters may
        have been modified by the user through the plot item GUI. The goal is to
        update the metadata accordingly.

        This is *almost* the inverse operation of `update_plot_item_parameters`.

        Args:
            item: plot item
        """
        def_dict = get_conf().get_sigima_defaults(self.__class__.__name__[:3].lower())
        for key in def_dict:
            if hasattr(item.param, key):  # In case the PlotPy version is not up-to-date
                self.obj.set_metadata_option(key, getattr(item.param, key))
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/objects/image.py000066400000000000000000000130341524425144300264200ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Image Module
---------------------------
"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
from guidata.dataset import update_dataset
from plotpy.builder import make
from plotpy.items import MaskedXYImageItem
from sigima.objects import ImageObj

from sigimax.adapters_plotpy.objects.base import (
    BaseObjPlotPyAdapter,
)
from sigimax.config import get_conf

if TYPE_CHECKING:
    from sigima.config import OptionField


def get_obj_coords(obj: ImageObj) -> tuple[np.ndarray, np.ndarray]:
    """Get object coordinates

    Args:
        obj: image object

    Returns:
        x and y coordinates (pixel centers, not edges)
    """
    if obj.is_uniform_coords:
        shape = obj.data.shape
        # Generate coordinates for pixel centers, not edges
        # For N pixels: centers are at x0, x0+dx, x0+2*dx, ..., x0+(N-1)*dx
        xcoords = np.linspace(obj.x0, obj.x0 + obj.dx * (shape[1] - 1), shape[1])
        ycoords = np.linspace(obj.y0, obj.y0 + obj.dy * (shape[0] - 1), shape[0])
    else:
        xcoords, ycoords = obj.xcoords, obj.ycoords
    return xcoords, ycoords


class ImageObjPlotPyAdapter(BaseObjPlotPyAdapter[ImageObj, MaskedXYImageItem]):
    """Image object plot item adapter class"""

    DEFAULT_FMT = ".1f"

    @property
    def conf_format(self) -> OptionField:
        """Image numeric format option field (resolved at runtime)."""
        return get_conf().ima_format

    def update_plot_item_parameters(self, item: MaskedXYImageItem) -> None:
        """Update plot item parameters from object data/metadata

        Takes into account a subset of plot item parameters. Those parameters may
        have been overriden by object metadata entries or other object data. The goal
        is to update the plot item accordingly.

        This is *almost* the inverse operation of `update_metadata_from_plot_item`.

        Args:
            item: plot item
        """
        o = self.obj
        for axis in ("x", "y", "z"):
            unit = getattr(o, axis + "unit")
            fmt = r"%.1f"
            if unit:
                fmt = r"%.1f (" + unit + ")"
            setattr(item.param, axis + "format", fmt)
        item.set_xy(*get_obj_coords(o))
        zmin, zmax = item.get_lut_range()
        if o.zscalemin is not None or o.zscalemax is not None:
            zmin = zmin if o.zscalemin is None else o.zscalemin
            zmax = zmax if o.zscalemax is None else o.zscalemax
            item.set_lut_range([zmin, zmax])
        super().update_plot_item_parameters(item)

    def update_metadata_from_plot_item(self, item: MaskedXYImageItem) -> None:
        """Update metadata from plot item.

        Takes into account a subset of plot item parameters. Those parameters may
        have been modified by the user through the plot item GUI. The goal is to
        update the metadata accordingly.

        This is *almost* the inverse operation of `update_plot_item_parameters`.

        Args:
            item: plot item
        """
        super().update_metadata_from_plot_item(item)
        o = self.obj
        # Updating the LUT range:
        o.zscalemin, o.zscalemax = item.get_lut_range()

    def __viewable_data(self) -> np.ndarray:
        """Return viewable data"""
        data = self.obj.data.real
        if np.any(np.isnan(data)):
            data = np.nan_to_num(data, posinf=0, neginf=0)
        return data

    def make_item(
        self, update_from: MaskedXYImageItem | None = None
    ) -> MaskedXYImageItem:
        """Make plot item from data.

        Args:
            update_from: update from plot item

        Returns:
            Plot item
        """
        data = self.__viewable_data()
        item = make.maskedxyimage(
            *get_obj_coords(self.obj),
            data,
            self.obj.maskdata,
            title=self.obj.title,
            colormap="viridis",
            eliminate_outliers=get_conf().ima_eliminate_outliers.get(),
            interpolation="nearest",
            show_mask=True,
        )
        if update_from is None:
            self.update_plot_item_parameters(item)
        else:
            update_dataset(item.param, update_from.param)
            item.param.update_item(item)
        return item

    def update_item(self, item: MaskedXYImageItem, data_changed: bool = True) -> None:
        """Update plot item from data.

        Args:
            item: plot item
            data_changed: if True, data has changed
        """
        if data_changed:
            # When data changes, let set_data() auto-calculate the LUT range from the
            # new data (by not passing lut_range parameter). The subsequent call to
            # update_plot_item_parameters() will override it if zscalemin/zscalemax
            # are explicitly set in the object's metadata.
            item.set_data(self.__viewable_data())
        item.set_mask(self.obj.maskdata)
        item.param.label = self.obj.title
        self.update_plot_item_parameters(item)
        item.plot().update_colormap_axis(item)

    def add_label_with_title(self, title: str | None = None) -> None:
        """Add label with title annotation

        Args:
            title: title (if None, use image title)
        """
        title = self.obj.title if title is None else title
        if title:
            label = make.label(title, (self.obj.x0, self.obj.y0), (10, 10), "TL")
            self.add_annotations_from_items([label])
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/objects/signal.py000066400000000000000000000231001524425144300266060ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Signal Module
----------------------------
"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...

from __future__ import annotations

from contextlib import contextmanager
from typing import Generator

import numpy as np
from guidata.dataset import restore_dataset, update_dataset
from plotpy.builder import make
from plotpy.items import CurveItem, ErrorBarCurveItem
from sigima.objects import SignalObj

from sigimax.adapters_plotpy.objects.base import (
    BaseObjPlotPyAdapter,
)
from sigimax.config import get_conf


class CurveStyles:
    """Object to manage curve styles"""

    #: Curve colors
    COLORS = (
        "#1f77b4",  # muted blue
        "#ff7f0e",  # safety orange
        "#2ca02c",  # cooked asparagus green
        "#d62728",  # brick red
        "#9467bd",  # muted purple
        "#8c564b",  # chestnut brown
        "#e377c2",  # raspberry yogurt pink
        "#7f7f7f",  # gray
        "#bcbd22",  # curry yellow-green
        "#17becf",  # blue-teal
    )
    #: Curve line styles
    LINESTYLES = ("SolidLine", "DashLine", "DashDotLine", "DashDotDotLine")

    def __init__(self) -> None:
        self.__suspend = False
        self.curve_style = self.style_generator()

    @staticmethod
    def style_generator() -> Generator[tuple[str, str], None, None]:
        """Cycling through curve styles"""
        while True:
            for linestyle in CurveStyles.LINESTYLES:
                for color in CurveStyles.COLORS:
                    yield (color, linestyle)

    def apply_style(self, item: CurveItem) -> None:
        """Apply style to curve

        Args:
            item: curve item
        """
        if self.__suspend:
            # Suspend mode: always apply the first style
            color, linestyle = CurveStyles.COLORS[0], CurveStyles.LINESTYLES[0]
        else:
            color, linestyle = next(self.curve_style)
        item.param.line.color = color
        item.param.line.style = linestyle
        item.param.symbol.marker = "NoSymbol"
        # Note: line width is set separately via apply_line_width()
        # to ensure it's always recalculated based on current data size and settings

    def reset_styles(self) -> None:
        """Reset styles"""
        self.curve_style = self.style_generator()

    @contextmanager
    def alternative(
        self, other_style_generator: Generator[tuple[str, str], None, None]
    ) -> Generator[None, None, None]:
        """Use an alternative style generator"""
        old_style_generator = self.curve_style
        self.curve_style = other_style_generator
        yield
        self.curve_style = old_style_generator

    @contextmanager
    def suspend(self) -> Generator[None, None, None]:
        """Suspend style generator"""
        self.__suspend = True
        yield
        self.__suspend = False


CURVESTYLES = CurveStyles()  # This is the unique instance of the CurveStyles class


def apply_line_width(item: CurveItem) -> None:
    """Apply line width to curve item with smart clamping for large datasets

    Args:
        item: curve item
    """
    # Get data size
    data_size = item.get_data()[0].size

    # Get configured line width
    line_width = get_conf().sig_linewidth.get()

    # For large datasets, clamp linewidth to 1.0 for performance
    # (thick lines cause ~10x rendering slowdown due to Qt raster engine)
    threshold = get_conf().sig_linewidth_perfs_threshold.get()
    if data_size > threshold and line_width > 1.0:
        line_width = 1.0

    # Apply the line width
    item.param.line.width = line_width


def apply_downsampling(item: CurveItem, do_not_update: bool = False) -> None:
    """Apply downsampling to curve item

    Args:
        item: curve item
        do_not_update: if True, do not update the item even if the downsampling
         parameters have changed
    """
    old_use_dsamp = item.param.use_dsamp
    item.param.use_dsamp = False
    if get_conf().sig_autodownsampling.get():
        nbpoints = item.get_data()[0].size
        maxpoints = get_conf().sig_autodownsampling_maxpoints.get()
        if nbpoints > 5 * maxpoints:
            item.param.use_dsamp = True
            item.param.dsamp_factor = nbpoints // maxpoints
    if not do_not_update and old_use_dsamp != item.param.use_dsamp:
        item.update_data()


class SignalObjPlotPyAdapter(BaseObjPlotPyAdapter[SignalObj, CurveItem]):
    """Signal object plot item adapter class"""

    DEFAULT_FMT = "g"
    # conf_format is inherited from the base adapter (returns get_conf().sig_format)

    def update_plot_item_parameters(self, item: CurveItem) -> None:
        """Update plot item parameters from object data/metadata

        Takes into account a subset of plot item parameters. Those parameters may
        have been overriden by object metadata entries or other object data. The goal
        is to update the plot item accordingly.

        This is *almost* the inverse operation of `update_metadata_from_plot_item`.

        Args:
            item: plot item
        """
        update_dataset(item.param.line, self.obj.metadata)
        update_dataset(item.param.symbol, self.obj.metadata)
        super().update_plot_item_parameters(item)

    def update_metadata_from_plot_item(self, item: CurveItem) -> None:
        """Update metadata from plot item.

        Takes into account a subset of plot item parameters. Those parameters may
        have been modified by the user through the plot item GUI. The goal is to
        update the metadata accordingly.

        This is *almost* the inverse operation of `update_plot_item_parameters`.

        Args:
            item: plot item
        """
        super().update_metadata_from_plot_item(item)
        restore_dataset(item.param.line, self.obj.metadata)
        restore_dataset(item.param.symbol, self.obj.metadata)

    def make_item(self, update_from: CurveItem | None = None) -> CurveItem:
        """Make plot item from data.

        Args:
            update_from: plot item to update from

        Returns:
            Plot item
        """
        o = self.obj
        if len(o.xydata) in (2, 4):
            assert isinstance(o.xydata, np.ndarray)
            if len(o.xydata) == 2:  # x, y signal
                x, y = o.xydata
                item = make.mcurve(x.real, y.real, label=o.title)
            else:  # x, y, dx, dy error bar signal
                x, y, dx, dy = o.xydata
                if o.dx is None and o.dy is None:  # x, y signal with no error
                    item = make.mcurve(x.real, y.real, label=o.title)
                elif o.dx is None:  # x, y, dy error bar signal with y error
                    item = make.merror(x.real, y.real, dy.real, label=o.title)
                else:  # x, y, dx, dy error bar signal with x error
                    dy = np.zeros_like(y) if dy is None else dy
                    item = make.merror(x.real, y.real, dx.real, dy.real, label=o.title)
            # Apply style (without linewidth, will be set separately)
            CURVESTYLES.apply_style(item)
            apply_downsampling(item, do_not_update=True)
            # Apply linewidth with smart clamping based on actual data size
            apply_line_width(item)
        else:
            raise RuntimeError("data not supported")
        if update_from is None:
            self.update_plot_item_parameters(item)
        else:
            update_dataset(item.param, update_from.param)
            item.update_params()
        return item

    def update_item(self, item: CurveItem, data_changed: bool = True) -> None:
        """Update plot item from data.

        Args:
            item: plot item
            data_changed: if True, data has changed
        """
        o = self.obj
        if data_changed:
            assert isinstance(o.xydata, np.ndarray)
            if len(o.xydata) == 2:  # x, y signal
                x, y = o.xydata
                assert isinstance(x, np.ndarray) and isinstance(y, np.ndarray)
                item.set_data(x.real, y.real)
            elif len(o.xydata) == 3:  # x, y, dy error bar signal
                x, y, dy = o.xydata
                assert (
                    isinstance(x, np.ndarray)
                    and isinstance(y, np.ndarray)
                    and isinstance(dy, np.ndarray)
                )
                item.set_data(x.real, y.real, dy=dy.real)
            elif len(o.xydata) == 4:  # x, y, dx, dy error bar signal
                x, y, dx, dy = o.xydata
                assert (
                    isinstance(x, np.ndarray)
                    and isinstance(y, np.ndarray)
                    and isinstance(dx, np.ndarray)
                    and isinstance(dy, np.ndarray)
                )
                if isinstance(item, ErrorBarCurveItem):
                    item.set_data(x.real, y.real, dx.real, dy.real)
                else:
                    # xydata has 4 rows but dx/dy are all NaN (no real
                    # error bars) — the plot item is a plain CurveItem
                    item.set_data(x.real, y.real)
        item.param.label = o.title
        apply_downsampling(item)
        # Reapply linewidth with smart clamping (data size may have changed)
        apply_line_width(item)
        self.update_plot_item_parameters(item)

    def add_label_with_title(self, title: str | None = None) -> None:
        """Add label with title annotation

        Args:
            title: title (if None, use signal title)
        """
        title = self.obj.title if title is None else title
        if title:
            label = make.label(title, "TL", (0, 0), "TL")
            self.add_annotations_from_items([label])
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/roi/000077500000000000000000000000001524425144300241235ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/roi/__init__.py000066400000000000000000000000001524425144300262220ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/roi/base.py000066400000000000000000000074061524425144300254160ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Base ROI Module
------------------------------
"""

import abc
from typing import TYPE_CHECKING, Generic, Iterator, Literal, TypeVar

from plotpy.items import AnnotatedShape
from sigima.objects.base import (
    BaseROI,
    TypeObj,
    TypeROI,
    TypeROIParam,
    TypeSingleROI,
    get_generic_roi_title,
)

from sigimax.adapters_plotpy.base import config_annotated_shape

if TYPE_CHECKING:
    from plotpy.items import (
        AnnotatedCircle,
        AnnotatedPolygon,
        AnnotatedRectangle,
        AnnotatedXRange,
    )

TypeROIItem = TypeVar(
    "TypeROIItem",
    bound="AnnotatedXRange | AnnotatedPolygon | AnnotatedRectangle | AnnotatedCircle",
)


class BaseSingleROIPlotPyAdapter(Generic[TypeSingleROI, TypeROIItem], abc.ABC):
    """Base class for single ROI plot item adapter

    Args:
        single_roi: single ROI object
    """

    def __init__(self, single_roi: TypeSingleROI) -> None:
        self.single_roi = single_roi

    @abc.abstractmethod
    def to_plot_item(self, obj: TypeObj) -> TypeROIItem:
        """Make ROI plot item from ROI.

        Args:
            obj: object (signal/image), for physical-indices coordinates conversion

        Returns:
            Plot item
        """

    @classmethod
    @abc.abstractmethod
    def from_plot_item(cls, item: TypeROIItem) -> TypeSingleROI:
        """Create single ROI from plot item

        Args:
            item: plot item

        Returns:
            Single ROI
        """


def configure_roi_item(
    item: TypeROIItem,
    fmt: str,
    lbl: bool,
    editable: bool,
    option: Literal["s", "i"],
):
    """Configure ROI plot item.

    Args:
        item: plot item
        fmt: numeric format (e.g. "%.3f")
        lbl: if True, show shape labels
        editable: if True, make shape editable
        option: shape style option ("s" for signal, "i" for image)

    Returns:
        Plot item
    """
    option += "/" + ("editable" if editable else "readonly")
    if not editable:
        if isinstance(item, AnnotatedShape):
            config_annotated_shape(
                item, fmt, lbl, "roi", option, show_computations=editable
            )
        item.set_movable(False)
        item.set_resizable(False)
        item.set_readonly(True)
    item.set_style("roi", option)
    return item


class BaseROIPlotPyAdapter(Generic[TypeROI], abc.ABC):
    """ROI plot item adapter class

    Args:
        roi: ROI object
    """

    def __init__(self, roi: BaseROI[TypeObj, TypeSingleROI, TypeROIParam]) -> None:
        self.roi = roi

    @abc.abstractmethod
    def to_plot_item(self, single_roi: TypeSingleROI, obj: TypeObj) -> TypeROIItem:
        """Make ROI plot item from single ROI

        Args:
            single_roi: single ROI object
            obj: object (signal/image), for physical-indices coordinates conversion

        Returns:
            Plot item
        """

    def iterate_roi_items(
        self, obj: TypeObj, fmt: str, lbl: bool, editable: bool = True
    ) -> Iterator[TypeROIItem]:
        """Iterate over ROI plot items associated to each single ROI composing
        the object.

        Args:
            obj: object (signal/image), for physical-indices coordinates conversion
            fmt: format string
            lbl: if True, add label
            editable: if True, ROI is editable

        Yields:
            Plot item
        """
        for index, single_roi in enumerate(self.roi.single_rois):
            roi_item = self.to_plot_item(single_roi, obj)
            item = configure_roi_item(
                roi_item, fmt, lbl, editable, option=self.roi.PREFIX
            )
            item.setTitle(single_roi.title or get_generic_roi_title(index))
            yield item
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/roi/factory.py000066400000000000000000000022021524425144300261400ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
ROI Adapter Factory
-------------------

Factory functions for creating ROI adapters without circular imports.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from sigima.objects.base import TypeObj


def create_roi_adapter(roi):
    """Create ROI adapter from ROI object

    Args:
        roi: ROI object

    Returns:
        ROI adapter instance
    """
    # pylint: disable=import-outside-toplevel,cyclic-import
    from sigimax.adapters_plotpy.factories import create_adapter_from_object

    return create_adapter_from_object(roi)


def create_single_roi_plot_item(single_roi, obj: TypeObj):
    """Create plot item from single ROI

    Args:
        single_roi: single ROI object
        obj: object (signal/image), for physical-indices coordinates conversion

    Returns:
        Plot item
    """
    # pylint: disable=import-outside-toplevel,cyclic-import
    from sigimax.adapters_plotpy.factories import create_adapter_from_object

    return create_adapter_from_object(single_roi).to_plot_item(obj)
DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/roi/image.py000066400000000000000000000175611524425144300255710ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
PlotPy Adapter Image ROI Module
-------------------------------
"""

from __future__ import annotations

import numpy as np
from plotpy.builder import make
from plotpy.items import AnnotatedCircle, AnnotatedPolygon, AnnotatedRectangle
from sigima.objects import CircularROI, ImageObj, ImageROI, PolygonalROI, RectangularROI
from sigima.tools import coordinates

from sigimax.adapters_plotpy.coordutils import round_image_coords
from sigimax.adapters_plotpy.roi.base import (
    BaseROIPlotPyAdapter,
    BaseSingleROIPlotPyAdapter,
)


def _vs(var: str, sub: str = "") -> str:
    """Return variable name with subscript"""
    txt = f"{var}"
    if sub:
        txt += f"{sub}"
    return txt


class PolygonalROIPlotPyAdapter(
    BaseSingleROIPlotPyAdapter[PolygonalROI, AnnotatedPolygon]
):
    """Polygonal ROI plot item adapter

    Args:
        single_roi: single ROI object
    """

    def to_plot_item(self, obj: ImageObj) -> AnnotatedPolygon:
        """Make and return the annnotated polygon associated to ROI

        Args:
            obj: object (image), for physical-indices coordinates conversion
        """

        def info_callback(item: AnnotatedPolygon) -> str:
            """Return info string for circular ROI"""
            xc, yc = item.get_center()
            if self.single_roi.indices:
                xc, yc = obj.physical_to_indices([xc, yc])
            return "
".join( [ f"({_vs('x', 'c')}, {_vs('y', 'c')}) = ({xc:g}, {yc:g})", ] ) coords = np.array(self.single_roi.get_physical_coords(obj)) points = coords.reshape(-1, 2) item = AnnotatedPolygon(points) item.set_info_callback(info_callback) item.annotationparam.title = self.single_roi.title item.annotationparam.update_item(item) item.set_style("plot", "shape/drag") return item @classmethod def from_plot_item( cls, item: AnnotatedPolygon, obj: ImageObj | None = None ) -> PolygonalROI: """Create ROI from plot item Args: item: plot item obj: image object for coordinate rounding (optional) """ coords = item.get_points().flatten().tolist() # Round coordinates to appropriate precision if obj is not None: coords = round_image_coords(obj, coords) title = str(item.title().text()) return PolygonalROI(coords, False, title) class RectangularROIPlotPyAdapter( BaseSingleROIPlotPyAdapter[RectangularROI, AnnotatedRectangle] ): """Rectangular ROI plot item adapter Args: single_roi: single ROI object """ def to_plot_item(self, obj: ImageObj) -> AnnotatedRectangle: """Make and return the annnotated rectangle associated to ROI Args: obj: object (image), for physical-indices coordinates conversion """ def info_callback(item: AnnotatedRectangle) -> str: """Return info string for rectangular ROI""" x0, y0, x1, y1 = item.get_rect() if self.single_roi.indices: x0, y0, x1, y1 = obj.physical_to_indices([x0, y0, x1, y1]) x0, y0, dx, dy = self.single_roi.rect_to_coords(x0, y0, x1, y1) return "
".join( [ f"({_vs('x', '0')}, {_vs('y', '0')}) = ({x0:g}, {y0:g})", f"{_vs('Δx')} × {_vs('Δy')} = {dx:g} × {dy:g}", ] ) x0, y0, dx, dy = self.single_roi.get_physical_coords(obj) x1, y1 = x0 + dx, y0 + dy item: AnnotatedRectangle = make.annotated_rectangle( x0, y0, x1, y1, title=self.single_roi.title ) item.set_info_callback(info_callback) param = item.label.labelparam param.anchor = "BL" param.xc, param.yc = 5, -5 param.update_item(item.label) return item @classmethod def from_plot_item( cls, item: AnnotatedRectangle, obj: ImageObj | None = None ) -> RectangularROI: """Create ROI from plot item Args: item: plot item obj: image object for coordinate rounding (optional) """ rect = item.get_rect() coords = RectangularROI.rect_to_coords(*rect) # Round coordinates to appropriate precision if obj is not None: coords = round_image_coords(obj, coords) title = str(item.title().text()) return RectangularROI(coords, False, title) class CircularROIPlotPyAdapter( BaseSingleROIPlotPyAdapter[CircularROI, AnnotatedCircle] ): """Circular ROI plot item adapter Args: single_roi: single ROI object """ def to_plot_item(self, obj: ImageObj) -> AnnotatedCircle: """Make and return the annnotated circle associated to ROI Args: obj: object (image), for physical-indices coordinates conversion """ def info_callback(item: AnnotatedCircle) -> str: """Return info string for circular ROI""" x0, y0, x1, y1 = item.get_rect() if self.single_roi.indices: x0, y0, x1, y1 = obj.physical_to_indices([x0, y0, x1, y1]) xc, yc, r = self.single_roi.rect_to_coords(x0, y0, x1, y1) return "
".join( [ f"({_vs('x', 'c')}, {_vs('y', 'c')}) = ({xc:g}, {yc:g})", f"{_vs('r')} = {r:g}", ] ) xc, yc, r = self.single_roi.get_physical_coords(obj) x0, y0, x1, y1 = coordinates.circle_to_diameter(xc, yc, r) item = AnnotatedCircle(x0, y0, x1, y1) item.set_info_callback(info_callback) item.annotationparam.title = self.single_roi.title item.annotationparam.update_item(item) item.set_style("plot", "shape/drag") return item @classmethod def from_plot_item( cls, item: AnnotatedCircle, obj: ImageObj | None = None ) -> CircularROI: """Create ROI from plot item Args: item: plot item obj: image object for coordinate rounding (optional) """ rect = item.get_rect() coords = CircularROI.rect_to_coords(*rect) # Round coordinates to appropriate precision # For circular ROI: [xc, yc, r] - round center (xc, yc) as pair, then radius if obj is not None: xc, yc, r = coords # Round center coordinates xc_rounded, yc_rounded = round_image_coords(obj, [xc, yc]) # Round radius using average of X and Y precision # For radius, we use the X precision (could also average X and Y) r_rounded = round_image_coords(obj, [r, 0])[0] coords = [xc_rounded, yc_rounded, r_rounded] title = str(item.title().text()) return CircularROI(coords, False, title) class ImageROIPlotPyAdapter(BaseROIPlotPyAdapter[ImageROI]): """Image ROI plot item adapter class Args: roi: ROI object """ def to_plot_item( self, single_roi: PolygonalROI | RectangularROI | CircularROI, obj: ImageObj, ) -> AnnotatedCircle | AnnotatedRectangle | AnnotatedPolygon: """Make ROI plot item from single ROI Args: single_roi: single ROI object obj: object (signal/image), for physical-indices coordinates conversion Returns: Plot item """ if isinstance(single_roi, PolygonalROI): return PolygonalROIPlotPyAdapter(single_roi).to_plot_item(obj) if isinstance(single_roi, RectangularROI): return RectangularROIPlotPyAdapter(single_roi).to_plot_item(obj) if isinstance(single_roi, CircularROI): return CircularROIPlotPyAdapter(single_roi).to_plot_item(obj) raise TypeError(f"Invalid ROI type {type(single_roi)}") DataLab-Platform-SigimaX-0182bf4/sigimax/adapters_plotpy/roi/signal.py000066400000000000000000000317361524425144300257640ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ PlotPy Adapter Signal ROI Module -------------------------------- """ from __future__ import annotations import re from typing import TYPE_CHECKING, Iterator import numpy as np from plotpy.items import AnnotatedXRange from plotpy.items.shape.range import XRangeSelection from qtpy import QtCore as QC from qtpy import QtGui as QG from sigima.objects import SegmentROI, SignalObj, SignalROI from sigima.objects.base import GENERIC_ROI_TITLE_REGEXP, get_generic_roi_title from sigimax.adapters_plotpy.coordutils import round_signal_coords from sigimax.adapters_plotpy.roi.base import ( BaseROIPlotPyAdapter, BaseSingleROIPlotPyAdapter, configure_roi_item, ) if TYPE_CHECKING: import qwt.scale_map from qtpy.QtCore import QRectF from qtpy.QtGui import QPainter # Color palette used to cycle through ROI fill colors when several ROIs are # defined on the same signal. Inspired by the matplotlib ``tab10`` palette so # that consecutive ROIs are easily distinguishable. _ROI_FILL_COLORS: tuple[str, ...] = ( "#1f77b4", # blue "#ff7f0e", # orange "#2ca02c", # green "#d62728", # red "#9467bd", # purple "#8c564b", # brown "#e377c2", # pink "#7f7f7f", # grey "#bcbd22", # yellow-green "#17becf", # cyan ) # Alpha (0..255) used for the translucent fill of ROI rectangles _ROI_FILL_ALPHA: int = 90 def roi_color_for_index(index: int) -> QG.QColor: """Return the ROI fill color for the given ROI index (cycles through the predefined palette).""" name = _ROI_FILL_COLORS[index % len(_ROI_FILL_COLORS)] color = QG.QColor(name) color.setAlpha(_ROI_FILL_ALPHA) return color class _CurveClippedXRangeSelection(XRangeSelection): """X-range selection whose translucent fill is clipped vertically to the underlying signal curve (instead of filling the whole canvas height). The fill color can additionally be overridden on a per-instance basis to enable color cycling between sibling ROIs displayed on the same signal. """ def __init__( self, _min: float | None = None, _max: float | None = None, shapeparam=None, ) -> None: super().__init__(_min, _max, shapeparam) self._curve_x: np.ndarray | None = None self._curve_y: np.ndarray | None = None self._fill_color: QG.QColor | None = None def set_signal_curve(self, x: np.ndarray, y: np.ndarray) -> None: """Attach the signal curve coordinates used to clip the fill area.""" self._curve_x = np.asarray(x, dtype=float) self._curve_y = np.asarray(y, dtype=float) def set_fill_color(self, color: QG.QColor) -> None: """Override the fill color for this ROI instance.""" self._fill_color = QG.QColor(color) def _build_curve_polygon( # pylint: disable=too-many-return-statements self, xMap: qwt.scale_map.QwtScaleMap, # pylint: disable=invalid-name yMap: qwt.scale_map.QwtScaleMap, # pylint: disable=invalid-name rct: QRectF, ) -> QG.QPolygonF | None: """Build a polygon that follows the signal curve between ``self._min`` and ``self._max``, with a flat baseline at y=0 (clamped to the visible canvas area when y=0 lies outside the current axis range, or fall back to the canvas bottom when the y-axis uses a logarithmic scale). """ if self._curve_x is None or self._curve_y is None: return None x_arr = self._curve_x y_arr = self._curve_y if x_arr.size < 2: return None # Filter non-finite samples (NaN/Inf) which would produce invalid # polygon vertices via ``np.interp``. finite = np.isfinite(x_arr) & np.isfinite(y_arr) if not finite.all(): x_arr = x_arr[finite] y_arr = y_arr[finite] if x_arr.size < 2: return None xmin, xmax = self._min, self._max if xmin is None or xmax is None: return None if xmin > xmax: xmin, xmax = xmax, xmin # Make sure x_arr is sorted (required by np.interp at the boundaries) if not np.all(np.diff(x_arr) >= 0): order = np.argsort(x_arr) x_arr = x_arr[order] y_arr = y_arr[order] # Restrict to the actual data extent to avoid drawing the polygon # outside the signal definition domain. x0 = max(xmin, float(x_arr[0])) x1 = min(xmax, float(x_arr[-1])) if x1 <= x0: return None mask = (x_arr >= x0) & (x_arr <= x1) xs_in = x_arr[mask] ys_in = y_arr[mask] y_left = float(np.interp(x0, x_arr, y_arr)) y_right = float(np.interp(x1, x_arr, y_arr)) xs = np.concatenate(([x0], xs_in, [x1])) ys = np.concatenate(([y_left], ys_in, [y_right])) # Remove duplicate boundary samples if interpolation hit an existing # data point exactly. keep = np.concatenate(([True], np.diff(xs) > 0)) xs = xs[keep] ys = ys[keep] if xs.size < 2: return None # Baseline: y=0 in axis coordinates for linear scales, clamped to the # visible canvas area. On a log scale, y=0 has no meaning, so fall # back to the canvas bottom. baseline_y = self._compute_baseline_y(yMap, rct) if baseline_y is None: return None polygon = QG.QPolygonF() polygon.append(QC.QPointF(xMap.transform(xs[0]), baseline_y)) for xv, yv in zip(xs, ys): polygon.append(QC.QPointF(xMap.transform(xv), yMap.transform(yv))) polygon.append(QC.QPointF(xMap.transform(xs[-1]), baseline_y)) return polygon def _compute_baseline_y( self, yMap: qwt.scale_map.QwtScaleMap, # pylint: disable=invalid-name rct: QRectF, ) -> float | None: """Return the canvas y-coordinate of the polygon baseline. Uses y=0 when the y-axis is linear, falls back to the canvas bottom when the axis is logarithmic (where y=0 is undefined). """ plot = self.plot() is_log_y = plot is not None and plot.get_axis_scale(self.yAxis()) == "log" if is_log_y: return rct.bottom() baseline_y = yMap.transform(0.0) if not np.isfinite(baseline_y): return rct.bottom() return max(rct.top(), min(rct.bottom(), baseline_y)) def draw( self, painter: QPainter, xMap: qwt.scale_map.QwtScaleMap, yMap: qwt.scale_map.QwtScaleMap, canvasRect: QRectF, ) -> None: """Draw the ROI: filled polygon clipped to the curve (or a fallback rectangle if no curve information is available), surrounded by the usual handle decoration (vertical edges and central dashed line).""" plot = self.plot() if not plot: return if self.selected: pen = self.sel_pen sym = self.sel_symbol else: pen = self.pen sym = self.symbol # Build the fallback rectangle covering the canvas height rct = QC.QRectF(plot.canvas().contentsRect()) rct.setLeft(xMap.transform(self._min)) rct.setRight(xMap.transform(self._max)) # Choose the brush: per-instance color override has priority. The pen # is preserved (so selection style — width/dash — keeps its visual # role) and only its color is replaced to match the fill color. if self._fill_color is not None: brush = QG.QBrush(self._fill_color) edge_color = QG.QColor(self._fill_color) edge_color.setAlpha(255) pen = QG.QPen(pen) pen.setColor(edge_color) else: brush = self.brush polygon = self._build_curve_polygon(xMap, yMap, rct) painter.save() painter.setPen(QC.Qt.NoPen) painter.setBrush(brush) if polygon is not None: painter.drawPolygon(polygon) else: painter.fillRect(rct, brush) painter.restore() # Vertical edges at xmin and xmax (full canvas height) painter.setPen(pen) painter.drawLine(rct.topLeft(), rct.bottomLeft()) painter.drawLine(rct.topRight(), rct.bottomRight()) # Dashed central line dash = QG.QPen(pen) dash.setStyle(QC.Qt.DashLine) dash.setWidth(1) painter.setPen(dash) cx = rct.center().x() painter.drawLine(QC.QPointF(cx, rct.top()), QC.QPointF(cx, rct.bottom())) if self.can_resize() and not self.is_readonly(): painter.setPen(pen) x0, x1, y = self.get_handles_pos() sym.drawSymbol(painter, QC.QPointF(x0, y)) sym.drawSymbol(painter, QC.QPointF(x1, y)) class _CurveClippedAnnotatedXRange(AnnotatedXRange): # pylint: disable=abstract-method """Annotated X-range selection whose underlying shape is a :class:`_CurveClippedXRangeSelection` (curve-clipped fill + per-instance color). ``get_tr_size`` is intentionally not overridden: ``AnnotatedXRange`` (from PlotPy) does not override it either, so the abstract-method warning is a false positive inherited from upstream.""" SHAPE_CLASS = _CurveClippedXRangeSelection class SegmentROIPlotPyAdapter(BaseSingleROIPlotPyAdapter[SegmentROI, AnnotatedXRange]): """Segment ROI plot item adapter Args: coords: ROI coordinates (xmin, xmax) title: ROI title """ def to_plot_item( self, obj: SignalObj, fill_color: QG.QColor | None = None, ) -> AnnotatedXRange: """Make and return the annotated segment associated with the ROI Args: obj: object (signal), for physical-indices coordinates conversion fill_color: optional fill color override (used for color cycling between sibling ROIs) """ xmin, xmax = self.single_roi.get_physical_coords(obj) item = _CurveClippedAnnotatedXRange(xmin, xmax) item.setTitle(self.single_roi.title) # Apply default range style so pen/brush/symbol attributes are set item.shape.set_style("plot", "range") # Provide curve coordinates for clipped rendering x, y = obj.xydata if x is not None and y is not None: item.shape.set_signal_curve(x, y) if fill_color is not None: item.shape.set_fill_color(fill_color) return item @classmethod def from_plot_item( cls, item: AnnotatedXRange, obj: SignalObj | None = None ) -> SegmentROI: """Create ROI from plot item Args: item: plot item obj: signal object for coordinate rounding (optional) Returns: ROI """ if not isinstance(item, AnnotatedXRange): raise TypeError("Invalid plot item type") coords = sorted(item.get_range()) # Round coordinates to appropriate precision if obj is not None: coords = round_signal_coords(obj, coords) title = str(item.title().text()) return SegmentROI(coords, False, title) class SignalROIPlotPyAdapter(BaseROIPlotPyAdapter[SignalROI]): """Signal ROI plot item adapter class Args: roi: ROI object """ def to_plot_item( self, single_roi: SegmentROI, obj: SignalObj, fill_color: QG.QColor | None = None, ) -> AnnotatedXRange: """Make ROI plot item from single ROI Args: single_roi: single ROI object obj: object (signal/image), for physical-indices coordinates conversion fill_color: optional fill color override (used for color cycling) Returns: Plot item """ return SegmentROIPlotPyAdapter(single_roi).to_plot_item( obj, fill_color=fill_color ) def iterate_roi_items( self, obj: SignalObj, fmt: str, lbl: bool, editable: bool = True, ) -> Iterator[AnnotatedXRange]: """Iterate over ROI plot items, applying alternating fill colors so that several ROIs displayed on the same signal can be visually distinguished. The color cycling index is derived from the trailing digits of the ROI title (``ROI``) when available, falling back to the position of the ROI in the list otherwise; this keeps the per-ROI color stable across deletions/reorderings. """ for index, single_roi in enumerate(self.roi.single_rois): title = single_roi.title or get_generic_roi_title(index) match = re.match(GENERIC_ROI_TITLE_REGEXP, title) color_index = int(match.group(1)) if match is not None else index color = roi_color_for_index(color_index) roi_item = self.to_plot_item(single_roi, obj, fill_color=color) item = configure_roi_item( roi_item, fmt, lbl, editable, option=self.roi.PREFIX ) item.setTitle(title) yield item DataLab-Platform-SigimaX-0182bf4/sigimax/app.py000066400000000000000000000111451524425144300212540ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Application launcher ==================== The :mod:`sigimax.app` module provides helper functions to create and run SigimaX-derived applications with optional splash screen support. Derived applications typically call :func:`create` (or :func:`run`) with their own :class:`~sigimax.mainwindow.SGMXMainWindow` subclass and an optional :class:`~sigimax.widgets.splashscreen.SplashScreenConfig`. Basic usage:: from sigimax.app import run from sigimax.widgets.splashscreen import SplashScreenConfig from myapp.main import MyAppMainWindow run( window_class=MyAppMainWindow, splash_config=SplashScreenConfig( image_path="myapp/data/splash.png", app_name="MyApp", app_version="1.0.0", ), ) .. autofunction:: create .. autofunction:: run """ from __future__ import annotations from typing import TypeVar from qtpy import QtWidgets as QW from sigimax.config import get_conf from sigimax.env import execenv from sigimax.mainwindow import SGMXMainWindow from sigimax.utils.qthelpers import sigimax_app_context from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig _WindowT = TypeVar("_WindowT", bound=SGMXMainWindow) def create( window_class: type[_WindowT] = SGMXMainWindow, splash: bool = True, splash_config: SplashScreenConfig | None = None, console: bool | None = None, h5files: list[str] | None = None, size: tuple[int, int] | None = None, ) -> _WindowT: """Create and show a SigimaX application window. This is the recommended entry point for derived applications. It handles the splash screen lifecycle around the (potentially heavy) window initialization. Args: window_class: The main window class to instantiate. Defaults to :class:`~sigimax.mainwindow.SGMXMainWindow`. splash: If ``True``, show a splash screen during initialization. splash_config: Explicit splash screen configuration. If ``None`` and *splash* is ``True``, the configuration is built from :data:`sigimax.config.CONF`. console: If ``True``, enable the embedded console. ``None`` reads from :data:`sigimax.config.CONF`. h5files: Optional list of HDF5 file paths to open after startup. size: Optional ``(width, height)`` tuple for the window size. Returns: The initialized and visible main window instance. """ splashscreen: SigimaXSplashScreen | None = None if splash: config = splash_config or SplashScreenConfig.from_conf() if config.is_enabled: splashscreen = SigimaXSplashScreen(config) splashscreen.show() splashscreen.show_message("Initializing...") QW.QApplication.processEvents() # --- Heavy initialization --- window = window_class(console=console) if splashscreen is not None: splashscreen.show_message("Loading workspace...") QW.QApplication.processEvents() if size is not None: width, height = size window.resize(width, height) if splashscreen is not None: splashscreen.finish(window) if get_conf().window_maximized.get(): window.showMaximized() else: window.showNormal() if h5files is not None: window.open_h5_files(h5files, import_all=True) return window def run( window_class: type[_WindowT] = SGMXMainWindow, splash: bool = True, splash_config: SplashScreenConfig | None = None, console: bool | None = None, h5files: list[str] | None = None, size: tuple[int, int] | None = None, ) -> None: """Create and run a SigimaX application with an event loop. Convenience wrapper around :func:`create` that manages the :func:`~sigimax.utils.qthelpers.sigimax_app_context` lifecycle. Args: window_class: The main window class to instantiate. splash: If ``True``, show a splash screen during initialization. splash_config: Explicit splash screen configuration. console: If ``True``, enable the embedded console. h5files: Optional list of HDF5 file paths to open. size: Optional ``(width, height)`` tuple for the window size. """ execenv.parse_args() with sigimax_app_context(exec_loop=True): window = create( window_class=window_class, splash=splash, splash_config=splash_config, console=console, h5files=h5files, size=size, ) QW.QApplication.processEvents() window.execute_post_show_actions() DataLab-Platform-SigimaX-0182bf4/sigimax/config.py000066400000000000000000002533631524425144300217530ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX Configuration Options System (:mod:`sigimax.config`) ------------------------------------------------------------ Sigima-style in-memory option system for SigimaX-based GUI applications. This module provides a clean, extensible configuration system following the same pattern as :mod:`sigima.config` (``OptionField`` with ``get``/``set``/``context``), but tailored for GUI applications built on SigimaX. Design principles: - **Simple API**: Options are accessed via ``CONF.color_mode.get()`` and ``CONF.color_mode.set("dark")``. - **Optional initialization defaults**: ``CONF.color_mode.get("auto")`` sets and returns ``"auto"`` only when the option has not been explicitly initialized or loaded. - **Context managers**: Temporarily override options with ``with CONF.fft_shift_enabled.context(False): ...``. - **Extensible via subclassing**: Derived applications (like DataLab) subclass :class:`SigimaXOptions` to add their own options. - **Optional JSON file persistence**: For GUI apps that need to persist user preferences across sessions. Typical usage: .. code-block:: python from sigimax.config import CONF as Conf # Get an option value colormap = Conf.ima_def_colormap.get() # Set an option value Conf.ima_def_colormap.set("gray") # Temporarily override an option with Conf.fft_shift_enabled.context(False): # FFT shift is disabled in this block ... # Save/load from JSON file (for GUI persistence) Conf.save() # saves to default config path Conf.load() # loads from default config path Extending for a derived application: .. code-block:: python from sigimax.options import SigimaXOptions class MyAppOptions(SigimaXOptions): def __init__(self): super().__init__() self.my_custom_option = TypedOptionField( self, "my_custom_option", default=42, expected_type=int, description="My custom option for MyApp", ) options = MyAppOptions() .. autoclass:: SigimaXOptions :members: .. autoclass:: AppOptionsContainer :members: .. autoclass:: OptionField :members: .. autoclass:: TypedOptionField :members: .. autoclass:: ImageIOOptionField :members: .. autoclass:: EnumOptionField :members: .. autoclass:: TupleOptionField :members: .. autoclass:: FontOptionField :members: .. autoclass:: ConfigPathOptionField :members: .. autoclass:: WorkingDirOptionField :members: .. autoclass:: FormatStringOptionField :members: .. autoclass:: DataSetOptionField :members: .. autofunction:: get_conf .. autofunction:: set_conf .. autofunction:: reset_conf """ from __future__ import annotations import json import os import os.path as osp import sys from collections.abc import Generator from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any import guidata.dataset as gds from guidata import configtools from plotpy import config as plotpy_config from plotpy.config import CONF as PLOTPY_CONF from sigima.config import OptionsContainer from sigima.config import options as sigima_options from sigima.proc.title_formatting import ( PlaceholderTitleFormatter, set_default_title_formatter, ) from sigimax.utils import conf as _conf_module # For config dir resolution if TYPE_CHECKING: from qtpy import QtGui as QG # Module-level constants MOD_TITLE = "SigimaX" MOD_NAME = "sigimax" # Configure Sigima to use placeholder title formatting set_default_title_formatter(PlaceholderTitleFormatter()) # Configure guidata translation and icons paths for SigimaX _ = configtools.get_translation(MOD_NAME) configtools.add_image_module_path(MOD_NAME, osp.join("data", "icons")) DATAPATH = configtools.get_module_data_path(MOD_NAME, "data") # Other Module-level constants MOD_DESC = _("""SigimaX is a GUI library working with Sigima and PlotPyStack. It provides a App configuration system, a generic MainWindow class and a set of widgets to build applications on top of Sigima and PlotPyStack.""") DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true") if DEBUG: print("*** DEBUG mode *** [Reset configuration file, do not redirect std I/O]") TEST_SEGFAULT_ERROR = len(os.environ.get("TEST_SEGFAULT_ERROR", "")) > 0 if TEST_SEGFAULT_ERROR: print('*** TEST_SEGFAULT_ERROR mode *** [Enabling test action in "?" menu]') # DATETIME_FORMAT = "%d/%m/%Y - %H:%M:%S" def get_old_log_fname(fname): """Return old log fname from current log fname""" return osp.splitext(fname)[0] + ".1.log" def is_frozen(module_name: str) -> bool: """Test if module has been frozen (py2exe/cx_Freeze/pyinstaller) Args: module_name (str): module name Returns: bool: True if module has been frozen (py2exe/cx_Freeze/pyinstaller) """ datapath = configtools.get_module_path(module_name) parentdir = osp.normpath(osp.join(datapath, osp.pardir)) return not osp.isfile(__file__) or osp.isfile(parentdir) # library.zip IS_FROZEN = is_frozen(MOD_NAME) def get_mod_source_dir() -> str | None: """Return module source directory Returns: str | None: module source directory, or None if not found """ if IS_FROZEN: devdir = osp.abspath(osp.join(sys.prefix, os.pardir, os.pardir)) else: devdir = osp.abspath(osp.join(osp.dirname(__file__), os.pardir)) if osp.isfile(osp.join(devdir, MOD_NAME, "__init__.py")): return devdir # Unhandled case (this should not happen, but just in case): return None # --------------------------------------------------------------------------- # Custom OptionField subclasses for GUI application options # --------------------------------------------------------------------------- NO_DEFAULT = object() class OptionField: """SigimaX option field supporting optional default initialization.""" #: Suffixes of the storage keys occupied by the field, for backends storing #: one scalar per key (e.g. INI). Empty means a single key. storage_suffixes: tuple[str, ...] = () #: Whether the serialized value must be escaped by format-string-sensitive #: backends (e.g. ``%`` doubling for ConfigParser). storage_escape: bool = False def __init__( self, container: OptionsContainer, name: str, default: Any, description: str = "", category: str = "", storage_key: str = "", runtime: bool = False, ) -> None: self._container = container self.name = name self.check(default) self._value = default self.description = description self.category = category #: Storage key overriding the one derived from the option name. self.storage_key = storage_key #: Value shared between processes through the storage backend: its owner #: persists it individually, bulk saves must leave it untouched. self.runtime = runtime self._is_initialized = False def check(self, value: Any) -> None: # pylint: disable=unused-argument """Validate the configured value in specialized fields.""" def get(self, default: Any = NO_DEFAULT) -> Any: """Return the value, initializing a missing option from ``default``. Args: default: Optional value used when the option is not initialized. ``None`` is returned without initializing the option. Returns: The exact supplied default after initialization, or the current value. """ is_initialized = getattr( self._container, "is_option_initialized", lambda _name: self._is_initialized ) if ( default is not NO_DEFAULT and default is not None and not is_initialized(self.name) ): self.set(default) return default return self._value def set(self, value: Any) -> None: """Set the value and mark the option as initialized.""" self.check(value) self._value = value self.mark_initialized() self._container.option_changed(self.name) def mark_initialized(self) -> None: """Mark a value assigned outside the standard setter as initialized.""" self._is_initialized = True mark_initialized = getattr(self._container, "mark_option_initialized", None) if mark_initialized is not None: mark_initialized(self.name) def to_storage(self) -> Any: """Return the value in a serialization-friendly form. Fields whose :meth:`get` transforms the stored value must override this (and :meth:`from_storage`) to avoid a lossy ``get``/``set`` round-trip. Returns: A JSON-compatible representation of the value. When :attr:`storage_suffixes` is not empty, a sequence aligned with it. """ return self.get() def from_storage(self, value: Any) -> None: """Restore the value from its serialized form. Args: value: The serialized value, as produced by :meth:`to_storage`. """ self.set(value) def context(self, temp_value: Any) -> Generator[None, None, None]: """Temporarily override the value without changing initialization state.""" @contextmanager def _ctx(): old_value = self._value old_field_initialized = self._is_initialized old_container_initialized = self._container.is_option_initialized(self.name) snapshot_context = getattr( self._container, "snapshot_option_context_state", None ) context_state = ( snapshot_context(self.name) if snapshot_context is not None else None ) self.set(temp_value) try: yield finally: self._value = old_value self._is_initialized = old_field_initialized if old_container_initialized: self._container.mark_option_initialized(self.name) else: self._container.unmark_option_initialized(self.name) self._container.option_changed(self.name) restore_context = getattr( self._container, "restore_option_context_state", None ) if restore_context is not None: restore_context(self.name, context_state) return _ctx() class TypedOptionField(OptionField): """Typed SigimaX option field with optional-default initialization.""" def __init__( self, container: OptionsContainer, name: str, default: Any, expected_type: type, description: str = "", category: str = "", storage_key: str = "", runtime: bool = False, ) -> None: self.expected_type = expected_type OptionField.__init__( self, container, name, default, description, category, storage_key, runtime ) def check(self, value: Any) -> None: """Validate the configured value type.""" if not isinstance(value, self.expected_type): raise ValueError( f"Expected {self.expected_type.__name__}, got {type(value).__name__}" ) class ImageIOOptionField(OptionField): """Image I/O option field with optional-default initialization.""" def check(self, value: Any) -> None: """Validate image I/O format and description pairs.""" if not isinstance(value, (tuple, list)) or not all( isinstance(item, (tuple, list)) and len(item) == 2 for item in value ): raise ValueError( "Expected a tuple of tuples with two elements each " "(format, description)" ) for item in value: if not isinstance(item[0], str) or not isinstance(item[1], str): raise ValueError( "Each item must be a tuple of (format, description) as strings" ) def set(self, value: Any) -> None: """Set formats, mark initialization, and generate format classes.""" OptionField.set(self, value) # pylint: disable=cyclic-import,import-outside-toplevel from sigima.io.image import formats formats.generate_imageio_format_classes(value) class EnumOptionField(OptionField): """Option field constrained to a fixed set of valid string values. Args: container: Options container instance to which this option belongs. name: Name of the option. default: Default value (must be one of ``choices``). choices: List of valid string values. description: Description of the option. """ def __init__( self, container: AppOptionsContainer, name: str, default: str, choices: list[str], description: str = "", category: str = "", storage_key: str = "", runtime: bool = False, ) -> None: self.choices = choices super().__init__( container, name, default, description, category, storage_key, runtime ) def check(self, value: Any) -> None: """Check if value is one of the allowed choices. Args: value: The value to check. Raises: ValueError: If the value is not one of the allowed choices. """ if value not in self.choices: raise ValueError( f"Option '{self.name}': expected one of {self.choices}, got {value!r}" ) class TupleOptionField(OptionField): """Option field for tuple values (e.g., window position, size). Handles JSON serialization where tuples become lists, automatically converting back to tuples on load. Args: container: Options container instance to which this option belongs. name: Name of the option. default: Default value (tuple or None). description: Description of the option. """ def check(self, value: Any) -> None: """Check if value is a tuple, list (from JSON), or None. Args: value: The value to check. Raises: ValueError: If the value is not a valid type. """ if value is not None and not isinstance(value, (tuple, list)): raise ValueError( f"Option '{self.name}': expected tuple, list, or None, " f"got {type(value).__name__}" ) def set(self, value: Any) -> None: """Set the value, converting lists to tuples. Args: value: The new value to assign. """ if isinstance(value, list): value = tuple(value) super().set(value) class FontOptionField(OptionField): """Option field for font specifications. Stores fonts as a tuple of (family: str | list[str], size: int, bold: bool). A list of families is a list of *candidate* names, resolved to the first one available on the system by :meth:`get_font`. Args: container: Options container instance to which this option belongs. name: Name of the option. default: Default value as (family, size, bold) tuple. description: Description of the option. """ storage_suffixes = ("family", "size", "bold") def check(self, value: Any) -> None: """Check if value is a valid font tuple. Args: value: The value to check. Raises: ValueError: If the value is not a valid font specification. """ if value is not None and ( not isinstance(value, (tuple, list)) or len(value) != 3 or not isinstance(value[0], (str, list, tuple)) ): raise ValueError( f"Option '{self.name}': expected (family, size, bold) tuple, " f"got {value!r}" ) def set(self, value: Any) -> None: """Set the value, converting lists to tuples. Args: value: The new value to assign. """ if isinstance(value, list): value = tuple(value) super().set(value) def get_font(self) -> QG.QFont: """Return the font as a ``QFont`` instance. Returns: The configured font as a ``QFont``. """ # Import here to avoid requiring a Qt application when only manipulating # configuration files. from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel family, size, bold = self.get() if isinstance(family, (list, tuple)): family = configtools.get_family(family) return QG.QFont(family, size, QG.QFont.Bold if bold else QG.QFont.Normal) class ConfigPathOptionField(OptionField): """Option field for a file stored in the configuration directory. The raw stored value is a bare file *basename*. :meth:`get` validates the basename and returns the absolute path inside the configuration directory. Args: container: Options container instance to which this option belongs. name: Name of the option. default: Default file basename (e.g. ``".MyApp_traceback.log"``). description: Description of the option. """ def check(self, value: Any) -> None: """Check that the value is a string. Args: value: The value to check. Raises: ValueError: If the value is not a string. """ if not isinstance(value, str): raise ValueError(f"Expected str, got {type(value).__name__}") def get(self, default: Any = NO_DEFAULT) -> str: """Return the absolute path inside the configuration directory. Args: default: Optional basename used when the option is not initialized. Returns: The absolute path of the file inside the configuration directory. Raises: ValueError: If the stored value is not a bare basename. """ fname = super().get(default) if osp.basename(fname) != fname: raise ValueError(f"Invalid configuration file name {fname}") return _conf_module.Configuration.get_path(osp.basename(fname)) def to_storage(self) -> str: """Return the raw stored basename (bypassing path resolution).""" return self._value def from_storage(self, value: str) -> None: """Set the raw stored basename without validation or env sync. Args: value: The raw basename to store. """ self._value = value self.mark_initialized() class WorkingDirOptionField(OptionField): """Option field for a working directory. :meth:`set` validates the directory (falling back to its parent when a file path is given) and raises when invalid. :meth:`get` returns an empty string when the stored directory no longer exists. Args: container: Options container instance to which this option belongs. name: Name of the option. default: Default directory path (empty string by default). description: Description of the option. """ def check(self, value: Any) -> None: """Check that the value is a string. Args: value: The value to check. Raises: ValueError: If the value is not a string. """ if not isinstance(value, str): raise ValueError(f"Expected str, got {type(value).__name__}") def get(self, default: Any = NO_DEFAULT) -> str: """Return the working directory, or an empty string if it is missing. Args: default: Optional path used when the option is not initialized. Returns: The stored directory if it exists, otherwise an empty string. """ path = super().get(default) if osp.isdir(path): return path return "" def set(self, value: str) -> None: """Set the working directory, validating that it exists. Args: value: The directory (or a file whose parent is used) to store. Raises: FileNotFoundError: If neither the value nor its parent is a directory. """ if not osp.isdir(value): value = osp.dirname(value) if not osp.isdir(value): raise FileNotFoundError(f"Invalid working directory name {value}") super().set(value) def to_storage(self) -> str: """Return the raw stored directory (even if it no longer exists).""" return self._value def from_storage(self, value: str) -> None: """Set the raw stored directory without validation or env sync. Args: value: The raw directory path to store. """ self._value = value self.mark_initialized() class FormatStringOptionField(TypedOptionField): """Option field for a ``strftime``-style format string. The value is kept in clean form in memory (e.g. ``%H:%M:%S``); format-string-sensitive backends escape it through :attr:`storage_escape`. Args: container: Options container instance to which this option belongs. name: Name of the option. default: Default format string. description: Description of the option. category: Category of the option. """ storage_escape = True def __init__( self, container: AppOptionsContainer, name: str, default: str, description: str = "", category: str = "", storage_key: str = "", runtime: bool = False, ) -> None: super().__init__( container, name, default, str, description, category, storage_key, runtime ) class DataSetOptionField(OptionField): """Option field holding a :class:`guidata.dataset.DataSet` instance. The default value is provided through a *default instance*, which may be set lazily via :meth:`set_default_instance` (useful when the default depends on PlotPy configuration that is not yet available at construction time). JSON (de)serialization helpers (:meth:`to_json`, :meth:`from_json`) are used by the storage backend. Percent-escaping required by ConfigParser is handled by the backend through :attr:`storage_escape`, not here. Args: container: Options container instance to which this option belongs. name: Name of the option. default_instance: Default :class:`~guidata.dataset.DataSet` instance (may be ``None`` and set later via :meth:`set_default_instance`). description: Description of the option. category: Category of the option. """ storage_escape = True def __init__( self, container: AppOptionsContainer, name: str, default_instance: gds.DataSet | None = None, description: str = "", category: str = "", storage_key: str = "", runtime: bool = False, ) -> None: self.default_instance = default_instance self._serialized_value: str | None = None # The actively-set value starts as None; get() falls back to the default # instance until an explicit value is assigned. super().__init__( container, name, None, description, category, storage_key, runtime, ) def check(self, value: Any) -> None: """Check that the value is a DataSet or None. Args: value: The value to check. Raises: ValueError: If the value is neither a DataSet nor None. """ if value is not None and not isinstance(value, gds.DataSet): raise ValueError( f"Option '{self.name}': expected a DataSet instance or None, " f"got {type(value).__name__}" ) def set_default_instance(self, default_instance: gds.DataSet) -> None: """Set the default instance (for lazy initialization). Args: default_instance: The default DataSet instance to use. """ self.default_instance = default_instance def get(self, default: Any = NO_DEFAULT) -> gds.DataSet | None: """Return the current DataSet instance, or the default instance. Args: default: Optional DataSet used when the option is not initialized. Returns: The actively-set DataSet if any, otherwise the default instance. """ value = super().get(default) if self._serialized_value is not None: try: value = gds.json_to_dataset(self._serialized_value) except Exception: # pylint: disable=broad-except value = ( default if default is not NO_DEFAULT and default is not None else self.default_instance ) self._value = None self._is_initialized = False self._container.unmark_option_initialized(self.name) else: self._value = value self._serialized_value = None return value if value is not None else self.default_instance def get_raw(self) -> gds.DataSet | None: """Return the raw actively-set DataSet (``None`` if never set).""" return self._value def set(self, value: gds.DataSet | None) -> None: """Set a DataSet instance and discard any pending serialized value.""" self._serialized_value = None super().set(value) def to_storage(self) -> str | None: """Return the actively-set DataSet as a JSON string (``None`` if unset).""" return self.to_json() def from_storage(self, value: str | None) -> None: """Restore the DataSet from a JSON string (``None`` clears the value). The JSON is resolved immediately, so that an unusable value (e.g. a DataSet class that no longer exists) is reported back to the caller by :meth:`to_storage` returning ``None``. Args: value: The JSON string to deserialize, or ``None``. """ if value is None: self._value = None self._serialized_value = None self.mark_initialized() else: self.from_json(value) self.get() def to_json(self) -> str | None: """Serialize the actively-set DataSet to a JSON string. Returns: The JSON string of the actively-set DataSet, or ``None`` when no value has been explicitly set (so the default instance applies). """ if self._serialized_value is not None: return self._serialized_value if self._value is None: return None return gds.dataset_to_json(self._value) def from_json(self, json_str: str) -> None: """Deserialize a DataSet from a JSON string and store it. Args: json_str: The JSON string to deserialize. """ self._value = None self._serialized_value = json_str self.mark_initialized() # --------------------------------------------------------------------------- # Base container with JSON file persistence # --------------------------------------------------------------------------- class AppOptionsContainer(OptionsContainer): """Base options container for SigimaX-based GUI applications. Extends Sigima's :class:`OptionsContainer` with optional JSON file persistence for GUI applications that need to save user preferences across sessions. Derived applications should subclass this (or :class:`SigimaXOptions`) to add their own options as OptionField attributes in ``__init__``. Class attributes: APP_NAME: Application name used for default config directory. This name can be overridden by derived applications to customize the config path. """ APP_NAME = "SigimaX" def __init__(self) -> None: # pylint: disable=super-init-not-called # Intentionally NOT calling super().__init__() because # OptionsContainer.__init__ creates Sigima-specific options. # We start fresh with our own option fields. self._initialized_options: set[str] = set() def is_option_initialized(self, name: str) -> bool: """Return whether an option was explicitly set or externally loaded.""" return name in self._initialized_options def mark_option_initialized(self, name: str) -> None: """Mark an option as explicitly initialized.""" self._initialized_options.add(name) def unmark_option_initialized(self, name: str) -> None: """Mark an option as not explicitly initialized.""" self._initialized_options.discard(name) def option_changed(self, name: str) -> None: """Handle an option value change. Derived applications may override this hook to persist option values. Args: name: Name of the changed option. """ def generate_rst_doc(self) -> str: """Generate reStructuredText documentation for all options. Returns: A string containing the reStructuredText documentation. """ doc = """.. list-table:: :header-rows: 1 :align: left * - Name - Default Value - Description """ for name in vars(self): opt = getattr(self, name) if isinstance(opt, OptionField): description_lines = opt.description.strip().split("\n") description = "\n".join( [description_lines[0]] + [ " " + line.strip() if line.strip() else "" for line in description_lines[1:] ] ) value = repr(opt.get()) if len(value) > 200: value = value[:197] + "..." doc += f" * - ``{name}``\n" doc += f" - ``{value}``\n" doc += f" - {description}\n" return doc # -- Dictionary serialization -- def to_dict(self) -> dict[str, Any]: """Return all option values as a JSON-compatible dictionary. Returns: A dictionary with option names as keys and their serialized values. """ return { name: getattr(self, name).to_storage() for name in vars(self) if isinstance(getattr(self, name), OptionField) } def from_dict(self, values: dict[str, Any]) -> None: """Set option values from a JSON-compatible dictionary. Unknown keys are silently ignored, making this safe for loading options from a newer or older version of the application. Args: values: A dictionary with option names as keys and their new values. """ for name, value in values.items(): if hasattr(self, name): opt = getattr(self, name) if isinstance(opt, OptionField): try: opt.from_storage(value) except (ValueError, TypeError) as exc: print( f"[sigimax] Warning: invalid value for " f"option '{name}': {exc}" ) # -- JSON file persistence -- def _get_default_config_path(self) -> Path: """Return the default path for the JSON configuration file. Uses the same config directory as the INI-based system for backward compatibility. Returns: Path to the default JSON configuration file. """ # Use guidata's config directory resolution try: config_dir = Path(_conf_module.Configuration.get_path("")) except Exception: # pylint: disable=broad-except # Fallback to user home directory config_dir = Path.home() / f".{self.APP_NAME}" config_dir.mkdir(parents=True, exist_ok=True) return config_dir / "options.json" def save(self, path: str | Path | None = None) -> None: """Save current options to a JSON file. Args: path: Path to save to. If None, uses the default config path. """ filepath = Path(path) if path else self._get_default_config_path() filepath.parent.mkdir(parents=True, exist_ok=True) with open(filepath, "w", encoding="utf-8") as f: json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) def load(self, path: str | Path | None = None) -> None: """Load options from a JSON file. Missing keys are left at their current (default) values. Unknown keys are silently ignored. Args: path: Path to load from. If None, uses the default config path. """ filepath = Path(path) if path else self._get_default_config_path() if not filepath.exists(): return try: with open(filepath, encoding="utf-8") as f: values = json.load(f) self.from_dict(values) except Exception as exc: # pylint: disable=broad-except print(f"[sigimax] Warning: failed to load options from {filepath}: {exc}") # -- Introspection -- def describe_all(self) -> None: """Print the name, value, and description of all options.""" for name in vars(self): opt = getattr(self, name) if isinstance(opt, OptionField): print(f"{name} = {opt.get()} # {opt.description}") def list_options(self) -> list[str]: """Return the sorted list of all option names. Returns: Sorted list of option attribute names. """ return sorted( name for name in vars(self) if isinstance(getattr(self, name), OptionField) ) # --------------------------------------------------------------------------- # PlotPy theme color constants # --------------------------------------------------------------------------- #: ROI line color (default blue) ROI_LINE_COLOR = "#5555ff" #: ROI selected line color ROI_SEL_LINE_COLOR = "#9393ff" #: Marker line color (default dark red) MARKER_LINE_COLOR = "#A11818" #: Marker text color MARKER_TEXT_COLOR = "#440909" class SigimaXOptions(AppOptionsContainer): """Generic options for SigimaX-based GUI applications. Contains all options that are useful for any derived application building on SigimaX (window management, console, I/O, visualization defaults, processing behavior). At the end of initialization, PlotPy's INI-based configuration is initialized with default styles for plots, results, and ROI shapes. Subclasses can override :meth:`get_plotpy_defaults` to customize these. Derived applications should subclass this to add app-specific options: .. code-block:: python class MyAppOptions(SigimaXOptions): APP_NAME = "MyApp" CONF_VERSION = "1.0.0" def __init__(self): super().__init__() self.my_option = TypedOptionField( self, "my_option", default=True, expected_type=bool, description="My app-specific option", ) options = MyAppOptions() """ #: Configuration version string (override in subclass if needed) CONF_VERSION = "1.0.0" def __init__(self) -> None: super().__init__() # =================================================================== # Derivated application info — Name, version, description, URLs # =================================================================== self.app_name = TypedOptionField( self, "app_name", default=self.APP_NAME, expected_type=str, description="Application name.", ) self.app_version = TypedOptionField( self, "app_version", default="0.1.0", expected_type=str, description="Application version.", ) self.app_logo_path = TypedOptionField( self, "app_logo_path", default="", expected_type=str, description="Path to the application logo.", ) self.app_desc = TypedOptionField( self, "app_desc", default="", expected_type=str, description="Application description.", ) self.app_local_doc_path = TypedOptionField( self, "app_local_doc_path", default="", expected_type=str, description="Path pattern to a local PDF documentation file. " "Use '{lang}' as a placeholder for the locale prefix " "(e.g. 'data/doc/MyApp_{lang}.pdf'). " "If empty, no local PDF menu item is shown.", ) self.app_docurl = TypedOptionField( self, "app_docurl", default="", expected_type=str, description="URL to the application documentation.", ) self.app_homeurl = TypedOptionField( self, "app_homeurl", default="", expected_type=str, description="URL to the application homepage.", ) self.app_supporturl = TypedOptionField( self, "app_supporturl", default="", expected_type=str, description="URL to the application support/contact page.", ) self.app_developer = TypedOptionField( self, "app_developer", default="", expected_type=str, description="Developer or organization name shown in the About dialog.", ) self.app_copyright = TypedOptionField( self, "app_copyright", default="", expected_type=str, description="Copyright notice shown in the About dialog " "(e.g. '2023 My Organization').", ) self.splash_image_path = TypedOptionField( self, "splash_image_path", default="", expected_type=str, description="Path to the splash screen image (PNG, SVG, etc.). " "If empty, no splash screen is shown.", ) self.splash_show_progress = TypedOptionField( self, "splash_show_progress", default=True, expected_type=bool, description="If True, display progress messages on the splash screen " "during application startup.", ) # =================================================================== # Main options — Application-level settings # =================================================================== self.color_mode = EnumOptionField( self, "color_mode", category="main", default="auto", choices=["auto", "dark", "light"], description="Application color mode (auto, dark, or light).", ) self.datetime_format = TypedOptionField( self, "datetime_format", default="%d/%m/%Y - %H:%M:%S", expected_type=str, description="Application datetime format.", ) # =================================================================== # Log and Console state # =================================================================== self.traceback_log_path = ConfigPathOptionField( self, "traceback_log_path", category="main", default=f".{self.APP_NAME}_traceback.log", description="Traceback log file basename (inside the config dir).", ) self.traceback_log_available = TypedOptionField( self, "traceback_log_available", category="main", default=False, expected_type=bool, description="Whether a traceback log file is currently available.", ) self.faulthandler_enabled = TypedOptionField( self, "faulthandler_enabled", category="main", default=True, expected_type=bool, description="If True, enable Python faulthandler for crash reporting.", ) self.faulthandler_log_path = ConfigPathOptionField( self, "faulthandler_log_path", category="main", default=f".{self.APP_NAME}_faulthandler.log", description="Faulthandler log file basename (inside the config dir).", ) self.faulthandler_log_available = TypedOptionField( self, "faulthandler_log_available", category="main", default=False, expected_type=bool, description="Whether a faulthandler log file is currently available.", ) # =================================================================== # Other Application options # =================================================================== self.available_memory_threshold = TypedOptionField( self, "available_memory_threshold", category="main", default=500, expected_type=int, description=( "Available memory threshold in MB. A warning is shown " "when available memory drops below this value." ), ) self.ignore_warnings = TypedOptionField( self, "ignore_warnings", category="proc", default=False, expected_type=bool, description=("If True, suppress Python warnings during computations."), ) # =================================================================== # Window state — Persisted UI geometry # =================================================================== self.window_maximized = TypedOptionField( self, "window_maximized", category="main", default=False, expected_type=bool, description="Whether the main window was maximized on last close.", ) self.window_position = TupleOptionField( self, "window_position", category="main", default=None, description="Main window position as (x, y) tuple, or None.", ) self.window_size = TupleOptionField( self, "window_size", category="main", default=None, description="Main window size as (width, height) tuple, or None.", ) self.window_state = TypedOptionField( self, "window_state", category="main", default="", expected_type=str, description=( "Main window state (hex-encoded QByteArray) for restoring " "dock widget positions and toolbar layout." ), ) self.base_dir = WorkingDirOptionField( self, "base_dir", category="main", default="", description="Base working directory for file dialogs.", ) # =================================================================== # Console options — Embedded console settings # =================================================================== self.console_enabled = TypedOptionField( self, "console_enabled", category="console", default=True, expected_type=bool, description="If True, show the embedded Python console.", ) self.show_console_on_error = TypedOptionField( self, "show_console_on_error", category="console", default=False, expected_type=bool, description=( "If True, automatically show the console when an error occurs." ), ) self.console_max_line_count = TypedOptionField( self, "console_max_line_count", category="console", default=5000, expected_type=int, storage_key="max_line_count", description="Maximum number of lines to keep in the console output.", ) self.external_editor_path = TypedOptionField( self, "external_editor_path", category="console", default="code", expected_type=str, description=( "Path to the external editor executable (e.g., 'code' for VS Code)." ), ) self.external_editor_args = TypedOptionField( self, "external_editor_args", category="console", default="-g {path}:{line_number}", expected_type=str, description=( "Command-line arguments template for the external editor. " "Supports {path} and {line_number} placeholders." ), ) # =================================================================== # I/O options — File import/export settings # =================================================================== self.h5_clear_workspace = TypedOptionField( self, "h5_clear_workspace", category="io", default=True, expected_type=bool, description=( "If True, clear the workspace before loading an HDF5 file " "(avoids UUID conflicts)." ), ) self.h5_clear_workspace_ask = TypedOptionField( self, "h5_clear_workspace_ask", category="io", default=True, expected_type=bool, description=( "If True, ask user for confirmation before clearing workspace " "when loading an HDF5 file." ), ) self.h5_fullpath_in_title = TypedOptionField( self, "h5_fullpath_in_title", category="io", default=False, expected_type=bool, description=( "If True, use full HDF5 dataset path in signal/image title. " "If False, use only the dataset name." ), ) self.h5_fname_in_title = TypedOptionField( self, "h5_fname_in_title", category="io", default=True, expected_type=bool, description=("If True, include the HDF5 file name in signal/image title."), ) self.imageio_formats = ImageIOOptionField( self, "imageio_formats", category="io", default=(), description="Supported ImageIO file formats.", ) # =================================================================== # View options — Plot and visualization defaults # =================================================================== self.plot_toolbar_position = EnumOptionField( self, "plot_toolbar_position", category="view", default="left", choices=["top", "bottom", "left", "right"], description="Position of the plot toolbar.", ) self.plot_dock_location = EnumOptionField( self, "plot_dock_location", category="view", default="right", choices=["top", "bottom", "left", "right"], description="Default dock area for plot widgets " "(top, bottom, left, or right).", ) self.watermark_image_path = TypedOptionField( self, "watermark_image_path", category="view", default="", expected_type=str, description="Path to the watermark image displayed on empty plots. " "If empty, no watermark is shown.", ) self.sig_format = TypedOptionField( self, "sig_format", category="view", default="", expected_type=str, description="Format string for signal shape legends.", ) self.ima_format = TypedOptionField( self, "ima_format", category="view", default="", expected_type=str, description="Format string for image shape legends.", ) self.show_label = TypedOptionField( self, "show_label", category="view", default=False, expected_type=bool, description="If True, show labels on plot items.", ) self.sig_linewidth = TypedOptionField( self, "sig_linewidth", category="view", default=1.0, expected_type=float, description="Default line width for signal curves.", ) self.sig_linewidth_perfs_threshold = TypedOptionField( self, "sig_linewidth_perfs_threshold", category="view", default=1000, expected_type=int, description=( "Number of curves above which line width is forced to 1 " "for performance reasons." ), ) self.sig_autodownsampling = TypedOptionField( self, "sig_autodownsampling", category="view", default=True, expected_type=bool, description=( "If True, automatically downsample signals with many points " "for faster rendering." ), ) self.sig_autodownsampling_maxpoints = TypedOptionField( self, "sig_autodownsampling_maxpoints", category="view", default=100000, expected_type=int, description="Maximum number of points before auto-downsampling kicks in.", ) self.sig_autoscale_margin_percent = TypedOptionField( self, "sig_autoscale_margin_percent", category="view", default=2.0, expected_type=float, description="Margin percentage for signal plot autoscale.", ) self.ima_autoscale_margin_percent = TypedOptionField( self, "ima_autoscale_margin_percent", category="view", default=1.0, expected_type=float, description="Margin percentage for image plot autoscale.", ) self.ima_eliminate_outliers = TypedOptionField( self, "ima_eliminate_outliers", category="view", default=0.1, expected_type=float, description=( "Percentage of outliers to eliminate from image LUT range " "at item creation (0.0 to disable)." ), ) # --- Signal visualization defaults (persisted in object metadata) --- self.sig_def_shade = TypedOptionField( self, "sig_def_shade", category="view", default=0.0, expected_type=float, description="Default shade value for signal curves (0.0 = no shade).", ) self.sig_def_curvestyle = TypedOptionField( self, "sig_def_curvestyle", category="view", default="Lines", expected_type=str, description=( "Default curve style for signals " "(e.g., 'Lines', 'Sticks', 'Steps', 'Dots')." ), ) self.sig_def_baseline = TypedOptionField( self, "sig_def_baseline", category="view", default=0.0, expected_type=float, description="Default baseline value for signal curves.", ) # --- Image visualization defaults (persisted in object metadata) --- self.ima_def_colormap = TypedOptionField( self, "ima_def_colormap", category="view", default="viridis", expected_type=str, description="Default colormap for images (e.g., 'viridis', 'gray').", ) self.ima_def_invert_colormap = TypedOptionField( self, "ima_def_invert_colormap", category="view", default=False, expected_type=bool, description="If True, invert the default colormap.", ) self.ima_def_interpolation = TypedOptionField( self, "ima_def_interpolation", category="view", default=5, expected_type=int, description="Default interpolation mode for images (integer index).", ) self.ima_def_alpha = TypedOptionField( self, "ima_def_alpha", category="view", default=1.0, expected_type=float, description="Default alpha (opacity) for images (0.0 to 1.0).", ) self.ima_def_alpha_function = TypedOptionField( self, "ima_def_alpha_function", category="view", default=0, expected_type=int, description=( "Default alpha function for images (LUTAlpha enum value). " "0 = NONE (uniform alpha)." ), ) self.ima_def_keep_lut_range = TypedOptionField( self, "ima_def_keep_lut_range", category="view", default=False, expected_type=bool, description=( "If True, keep the LUT range when switching between images " "instead of auto-scaling." ), ) # =================================================================== # Processing options — Computation behavior # =================================================================== self.operation_mode = EnumOptionField( self, "operation_mode", category="proc", default="single", choices=["single", "pairwise"], description=( "Operation mode for multi-selection computations. " "'single': one operand shared, 'pairwise': paired operations." ), ) self.extract_roi_singleobj = TypedOptionField( self, "extract_roi_singleobj", category="proc", default=False, expected_type=bool, description=( "If True, extract all ROIs into a single object. " "If False, create one object per ROI." ), ) self.keep_results = TypedOptionField( self, "keep_results", category="proc", default=False, expected_type=bool, description=( "If True, keep analysis results after processing. " "Warning: results may become invalid after transformations." ), ) self.show_result_dialog = TypedOptionField( self, "show_result_dialog", category="proc", default=True, expected_type=bool, description=( "If True, systematically show a result dialog after " "analysis computations." ), ) self.use_signal_bounds = TypedOptionField( self, "use_signal_bounds", category="proc", default=False, expected_type=bool, description=( "If True, use xmin and xmax bounds from the current signal " "when creating a new signal." ), ) self.use_image_dims = TypedOptionField( self, "use_image_dims", category="proc", default=True, expected_type=bool, description=( "If True, use dimensions from the current image when " "creating a new image." ), ) self.fft_shift_enabled = TypedOptionField( self, "fft_shift_enabled", category="proc", default=True, expected_type=bool, description=( "If True, apply FFT shift to center the zero-frequency " "component. Synced with sigima.config.options." ), ) self.auto_normalize_kernel = TypedOptionField( self, "auto_normalize_kernel", category="proc", default=False, expected_type=bool, description=( "If True, automatically normalize convolution kernels to " "sum to 1.0 before convolution. " "Synced with sigima.config.options." ), ) self.xarray_compat_behavior = EnumOptionField( self, "xarray_compat_behavior", category="proc", default="ask", choices=["ask", "interpolate"], description=( "Behavior when X-arrays are incompatible in multi-signal " "operations. 'ask': prompt user, 'interpolate': auto-interpolate." ), ) # =================================================================== # Initialize PlotPy INI-based configuration # =================================================================== self.initialize_plotpy() # =================================================================== # Sync with sigima.config.options for shared settings (e.g., FFT shift, kernel # normalization) # =================================================================== self.sync_with_sigima() # =================================================================== # Capture default values for reset_to_defaults() # (Sigima's OptionField does not expose a .default attribute) # # TODO: [P3] Refactor OptionField to store the default value explicitly, # so we can simplify this logic in the future. # =================================================================== self._defaults = self.to_dict() def reset_to_defaults(self) -> None: """Reset all options to their default values.""" self._initialized_options.clear() self.from_dict(self._defaults) for name in self._defaults: field = getattr(self, name) field._is_initialized = False # pylint: disable=protected-access self._initialized_options.clear() # -- Option categories (INI sections and settings-UI grouping) -- def get_option_categories(self) -> list[tuple[str, str]]: """Return ordered option categories as ``(id, label)`` pairs. The ``id`` doubles as the INI section name for derived applications and as the persistence/grouping key; the ``label`` is a human-readable, translatable title suitable for a settings dialog tab. Derived applications extend this by concatenating their own categories to the result of ``super().get_option_categories()``. Returns: Ordered list of ``(category_id, label)`` pairs. """ return [ ("main", _("General")), ("console", _("Console")), ("io", _("I/O")), ("proc", _("Processing")), ("view", _("Visualization")), ] def get_field_category(self, name: str) -> str: """Return the category id of an option field (empty if uncategorized). Args: name: The option field name. Returns: The category id, or an empty string if the field is uncategorized or unknown. """ field = getattr(self, name, None) return getattr(field, "category", "") if field is not None else "" def fields_by_category(self) -> dict[str, list[str]]: """Return option field names grouped by category id. Categories are keyed in the order returned by :meth:`get_option_categories`; uncategorized fields are omitted. Returns: Mapping ``category_id -> [option_field_name, ...]``. """ result: dict[str, list[str]] = { cid: [] for cid, _label in self.get_option_categories() } for name in vars(self): field = getattr(self, name) if isinstance(field, OptionField): category = getattr(field, "category", "") if category: result.setdefault(category, []).append(name) return result # -- PlotPy INI-based configuration integration -- def get_plotpy_defaults(self) -> dict[str, dict[str, Any]]: """Return default PlotPy configuration values. Override this method in subclasses to customize the PlotPy styles for plots, result annotations, ROI shapes, and labels. The returned dict has top-level keys corresponding to PlotPy configuration sections (``"plot"``, ``"results"``, ``"roi"``). Returns: Nested dictionary of PlotPy default settings. """ # Read at call time: `plotpy.config.set_plotpy_color_mode` rebinds these # module globals when the color theme changes. main_fg = plotpy_config.MAIN_FG_COLOR main_bg = plotpy_config.MAIN_BG_COLOR return { "plot": { # Overriding default plot settings from PlotPy "title/font/size": 11, "title/font/bold": False, "selected_curve_symbol/marker": "Ellipse", "selected_curve_symbol/edgecolor": "#a0a0a4", "selected_curve_symbol/facecolor": main_fg, "selected_curve_symbol/alpha": 0.3, "selected_curve_symbol/size": 5, "marker/curve/text/textcolor": "black", # Cross marker style (shown when pressing Alt key on plot) "marker/cross/symbol/marker": "Cross", "marker/cross/symbol/edgecolor": main_fg, "marker/cross/symbol/facecolor": "#ff0000", "marker/cross/symbol/alpha": 1.0, "marker/cross/symbol/size": 8, "marker/cross/text/font/family": "default", "marker/cross/text/font/size": 8, "marker/cross/text/font/bold": False, "marker/cross/text/font/italic": False, "marker/cross/text/textcolor": "#000000", "marker/cross/text/background_color": "#ffffff", "marker/cross/text/background_alpha": 0.7, "marker/cross/line/style": "DashLine", "marker/cross/line/color": MARKER_LINE_COLOR, "marker/cross/line/width": 1.0, "marker/cross/markerstyle": "Cross", "marker/cross/spacing": 7, # Cursor line and symbol style "marker/cursor/line/style": "SolidLine", "marker/cursor/line/color": MARKER_LINE_COLOR, "marker/cursor/line/width": 1.0, "marker/cursor/symbol/marker": "NoSymbol", "marker/cursor/symbol/size": 11, "marker/cursor/symbol/edgecolor": main_bg, "marker/cursor/symbol/facecolor": "#ff9393", "marker/cursor/symbol/alpha": 1.0, "marker/cursor/sel_line/style": "SolidLine", "marker/cursor/sel_line/color": MARKER_LINE_COLOR, "marker/cursor/sel_line/width": 2.0, "marker/cursor/sel_symbol/marker": "NoSymbol", "marker/cursor/sel_symbol/size": 11, "marker/cursor/sel_symbol/edgecolor": main_bg, "marker/cursor/sel_symbol/facecolor": MARKER_LINE_COLOR, "marker/cursor/sel_symbol/alpha": 0.8, "marker/cursor/text/font/size": 9, "marker/cursor/text/font/family": "default", "marker/cursor/text/font/bold": False, "marker/cursor/text/font/italic": False, "marker/cursor/text/textcolor": MARKER_TEXT_COLOR, "marker/cursor/text/background_color": "#ffffff", "marker/cursor/text/background_alpha": 0.7, "marker/cursor/sel_text/font/size": 9, "marker/cursor/sel_text/font/family": "default", "marker/cursor/sel_text/font/bold": False, "marker/cursor/sel_text/font/italic": False, "marker/cursor/sel_text/textcolor": MARKER_TEXT_COLOR, "marker/cursor/sel_text/background_color": "#ffffff", "marker/cursor/sel_text/background_alpha": 0.7, # Default annotation text style for segments "shape/segment/line/style": "SolidLine", "shape/segment/line/color": "#00ff55", "shape/segment/line/width": 1.0, "shape/segment/sel_line/style": "SolidLine", "shape/segment/sel_line/color": "#00ff55", "shape/segment/sel_line/width": 2.0, "shape/segment/fill/style": "NoBrush", "shape/segment/sel_fill/style": "NoBrush", "shape/segment/symbol/marker": "XCross", "shape/segment/symbol/size": 9, "shape/segment/symbol/edgecolor": "#00ff55", "shape/segment/symbol/facecolor": "#00ff55", "shape/segment/symbol/alpha": 1.0, "shape/segment/sel_symbol/marker": "XCross", "shape/segment/sel_symbol/size": 12, "shape/segment/sel_symbol/edgecolor": "#00ff55", "shape/segment/sel_symbol/facecolor": "#00ff55", "shape/segment/sel_symbol/alpha": 0.7, # Default style for drag shapes (global annotations style) "shape/drag/line/style": "SolidLine", "shape/drag/line/color": "#00ff55", "shape/drag/line/width": 1.0, "shape/drag/fill/style": "SolidPattern", "shape/drag/fill/color": main_bg, "shape/drag/fill/alpha": 0.1, "shape/drag/symbol/marker": "Rect", "shape/drag/symbol/size": 3, "shape/drag/symbol/edgecolor": "#00ff55", "shape/drag/symbol/facecolor": "#00ff55", "shape/drag/symbol/alpha": 1.0, "shape/drag/sel_line/style": "SolidLine", "shape/drag/sel_line/color": "#00ff55", "shape/drag/sel_line/width": 2.0, "shape/drag/sel_fill/style": "SolidPattern", "shape/drag/sel_fill/color": main_bg, "shape/drag/sel_fill/alpha": 0.1, "shape/drag/sel_symbol/marker": "Rect", "shape/drag/sel_symbol/size": 7, "shape/drag/sel_symbol/edgecolor": "#00ff55", "shape/drag/sel_symbol/facecolor": "#00ff00", "shape/drag/sel_symbol/alpha": 0.7, }, "results": { # Annotated shape style for result shapes: # Signals: "s/annotation/line/style": "SolidLine", "s/annotation/line/color": "#00aa00", "s/annotation/line/width": 2, "s/annotation/fill/style": "NoBrush", "s/annotation/fill/color": main_bg, "s/annotation/fill/alpha": 0.1, "s/annotation/symbol/marker": "XCross", "s/annotation/symbol/size": 7, "s/annotation/symbol/edgecolor": "#00aa00", "s/annotation/symbol/facecolor": "#00aa00", "s/annotation/symbol/alpha": 1.0, "s/annotation/sel_line/style": "DashLine", "s/annotation/sel_line/color": "#00ff00", "s/annotation/sel_line/width": 1, "s/annotation/sel_fill/style": "SolidPattern", "s/annotation/sel_fill/color": main_bg, "s/annotation/sel_fill/alpha": 0.1, "s/annotation/sel_symbol/marker": "Rect", "s/annotation/sel_symbol/size": 9, "s/annotation/sel_symbol/edgecolor": "#00aa00", "s/annotation/sel_symbol/facecolor": "#00ff00", "s/annotation/sel_symbol/alpha": 0.7, # Images: "i/annotation/line/style": "SolidLine", "i/annotation/line/color": "#ffff00", "i/annotation/line/width": 2, "i/annotation/fill/style": "SolidPattern", "i/annotation/fill/color": main_bg, "i/annotation/fill/alpha": 0.1, "i/annotation/symbol/marker": "Rect", "i/annotation/symbol/size": 3, "i/annotation/symbol/edgecolor": "#ffff00", "i/annotation/symbol/facecolor": "#ffff00", "i/annotation/symbol/alpha": 1.0, "i/annotation/sel_line/style": "SolidLine", "i/annotation/sel_line/color": "#00ff00", "i/annotation/sel_line/width": 2, "i/annotation/sel_fill/style": "SolidPattern", "i/annotation/sel_fill/color": main_bg, "i/annotation/sel_fill/alpha": 0.1, "i/annotation/sel_symbol/marker": "Rect", "i/annotation/sel_symbol/size": 9, "i/annotation/sel_symbol/edgecolor": "#00aa00", "i/annotation/sel_symbol/facecolor": "#00ff00", "i/annotation/sel_symbol/alpha": 0.7, # Marker styles for results: # Signals: "s/marker/cursor/line/style": "DashLine", "s/marker/cursor/line/color": MARKER_LINE_COLOR, "s/marker/cursor/line/width": 1.0, "s/marker/cursor/symbol/marker": "Ellipse", "s/marker/cursor/symbol/size": 11, "s/marker/cursor/symbol/edgecolor": main_bg, "s/marker/cursor/symbol/facecolor": MARKER_LINE_COLOR, "s/marker/cursor/symbol/alpha": 0.7, "s/marker/cursor/sel_line/style": "DashLine", "s/marker/cursor/sel_line/color": MARKER_LINE_COLOR, "s/marker/cursor/sel_line/width": 2.0, "s/marker/cursor/sel_symbol/marker": "Ellipse", "s/marker/cursor/sel_symbol/size": 11, "s/marker/cursor/sel_symbol/edgecolor": MARKER_LINE_COLOR, "s/marker/cursor/sel_symbol/facecolor": MARKER_LINE_COLOR, "s/marker/cursor/sel_symbol/alpha": 0.7, "s/marker/cursor/text/font/size": 9, "s/marker/cursor/text/font/family": "default", "s/marker/cursor/text/font/bold": False, "s/marker/cursor/text/font/italic": False, "s/marker/cursor/text/textcolor": MARKER_TEXT_COLOR, "s/marker/cursor/text/background_color": "#ffffff", "s/marker/cursor/text/background_alpha": 0.7, "s/marker/cursor/sel_text/font/size": 9, "s/marker/cursor/sel_text/font/family": "default", "s/marker/cursor/sel_text/font/bold": False, "s/marker/cursor/sel_text/font/italic": False, "s/marker/cursor/sel_text/textcolor": MARKER_TEXT_COLOR, "s/marker/cursor/sel_text/background_color": "#ffffff", "s/marker/cursor/sel_text/background_alpha": 0.7, "s/marker/cursor/markerstyle": "Cross", # Images: "i/marker/cursor/line/style": "DashLine", "i/marker/cursor/line/color": MARKER_LINE_COLOR, "i/marker/cursor/line/width": 1.0, "i/marker/cursor/symbol/marker": "Diamond", "i/marker/cursor/symbol/size": 11, "i/marker/cursor/symbol/edgecolor": MARKER_LINE_COLOR, "i/marker/cursor/symbol/facecolor": MARKER_LINE_COLOR, "i/marker/cursor/symbol/alpha": 0.7, "i/marker/cursor/sel_line/style": "DashLine", "i/marker/cursor/sel_line/color": MARKER_LINE_COLOR, "i/marker/cursor/sel_line/width": 2.0, "i/marker/cursor/sel_symbol/marker": "Diamond", "i/marker/cursor/sel_symbol/size": 11, "i/marker/cursor/sel_symbol/edgecolor": MARKER_LINE_COLOR, "i/marker/cursor/sel_symbol/facecolor": MARKER_LINE_COLOR, "i/marker/cursor/sel_symbol/alpha": 0.7, "i/marker/cursor/text/font/size": 9, "i/marker/cursor/text/font/family": "default", "i/marker/cursor/text/font/bold": False, "i/marker/cursor/text/font/italic": False, "i/marker/cursor/text/textcolor": MARKER_TEXT_COLOR, "i/marker/cursor/text/background_color": "#ffffff", "i/marker/cursor/text/background_alpha": 0.7, "i/marker/cursor/sel_text/font/size": 9, "i/marker/cursor/sel_text/font/family": "default", "i/marker/cursor/sel_text/font/bold": False, "i/marker/cursor/sel_text/font/italic": False, "i/marker/cursor/sel_text/textcolor": MARKER_TEXT_COLOR, "i/marker/cursor/sel_text/background_color": "#ffffff", "i/marker/cursor/sel_text/background_alpha": 0.7, "i/marker/cursor/markerstyle": "Cross", # Style for labels: "label/symbol/marker": "NoSymbol", "label/symbol/size": 0, "label/symbol/edgecolor": main_bg, "label/symbol/facecolor": main_bg, "label/border/style": "SolidLine", "label/border/color": "#cbcbcb", "label/border/width": 1, "label/font/size": 8, "label/font/family/nt": [ "Cascadia Code", "Consolas", "Courier New", ], "label/font/family/posix": "Bitstream Vera Sans Mono", "label/font/family/mac": "Monaco", "label/font/bold": False, "label/font/italic": False, "label/color": main_fg, "label/bgcolor": main_bg, "label/bgalpha": 0.8, "label/anchor": "TL", "label/xc": 10, "label/yc": 10, "label/abspos": True, "label/absg": "TL", "label/xg": 0.0, "label/yg": 0.0, }, "roi": { # Signals — Editable ROI (ROI editor): "s/editable/fill": "#ffff00", "s/editable/shade": 0.10, "s/editable/line/style": "SolidLine", "s/editable/line/color": "#ffff00", "s/editable/line/width": 1, "s/editable/fill/style": "SolidPattern", "s/editable/fill/color": main_bg, "s/editable/fill/alpha": 0.1, "s/editable/symbol/marker": "Rect", "s/editable/symbol/size": 3, "s/editable/symbol/edgecolor": "#ffff00", "s/editable/symbol/facecolor": "#ffff00", "s/editable/symbol/alpha": 1.0, "s/editable/sel_line/style": "SolidLine", "s/editable/sel_line/color": "#00ff00", "s/editable/sel_line/width": 1, "s/editable/sel_fill/style": "SolidPattern", "s/editable/sel_fill/color": main_bg, "s/editable/sel_fill/alpha": 0.1, "s/editable/sel_symbol/marker": "Rect", "s/editable/sel_symbol/size": 9, "s/editable/sel_symbol/edgecolor": "#00aa00", "s/editable/sel_symbol/facecolor": "#00ff00", "s/editable/sel_symbol/alpha": 0.7, # Signals — Readonly ROI (plot): "s/readonly/line/style": "SolidLine", "s/readonly/line/color": ROI_LINE_COLOR, "s/readonly/line/width": 1, "s/readonly/sel_line/style": "SolidLine", "s/readonly/sel_line/color": ROI_SEL_LINE_COLOR, "s/readonly/sel_line/width": 2, "s/readonly/fill": ROI_LINE_COLOR, "s/readonly/shade": 0.10, "s/readonly/symbol/marker": "Ellipse", "s/readonly/symbol/size": 7, "s/readonly/symbol/edgecolor": main_bg, "s/readonly/symbol/facecolor": ROI_LINE_COLOR, "s/readonly/symbol/alpha": 1.0, "s/readonly/sel_symbol/marker": "Ellipse", "s/readonly/sel_symbol/size": 9, "s/readonly/sel_symbol/edgecolor": main_bg, "s/readonly/sel_symbol/facecolor": ROI_SEL_LINE_COLOR, "s/readonly/sel_symbol/alpha": 0.9, "s/readonly/multi/color": "#806060", # Images — Editable ROI (ROI editor): "i/editable/line/style": "SolidLine", "i/editable/line/color": "#ffff00", "i/editable/line/width": 1, "i/editable/fill/style": "SolidPattern", "i/editable/fill/color": main_bg, "i/editable/fill/alpha": 0.1, "i/editable/symbol/marker": "Rect", "i/editable/symbol/size": 3, "i/editable/symbol/edgecolor": "#ffff00", "i/editable/symbol/facecolor": "#ffff00", "i/editable/symbol/alpha": 1.0, "i/editable/sel_line/style": "SolidLine", "i/editable/sel_line/color": "#00ff00", "i/editable/sel_line/width": 1, "i/editable/sel_fill/style": "SolidPattern", "i/editable/sel_fill/color": main_bg, "i/editable/sel_fill/alpha": 0.1, "i/editable/sel_symbol/marker": "Rect", "i/editable/sel_symbol/size": 9, "i/editable/sel_symbol/edgecolor": "#00aa00", "i/editable/sel_symbol/facecolor": "#00ff00", "i/editable/sel_symbol/alpha": 0.7, # Images — Readonly ROI (plot): "i/readonly/line/style": "DotLine", "i/readonly/line/color": ROI_LINE_COLOR, "i/readonly/line/width": 1, "i/readonly/fill/style": "SolidPattern", "i/readonly/fill/color": main_bg, "i/readonly/fill/alpha": 0.1, "i/readonly/symbol/marker": "NoSymbol", "i/readonly/symbol/size": 5, "i/readonly/symbol/edgecolor": ROI_LINE_COLOR, "i/readonly/symbol/facecolor": ROI_LINE_COLOR, "i/readonly/symbol/alpha": 0.6, "i/readonly/sel_line/style": "DotLine", "i/readonly/sel_line/color": "#0000ff", "i/readonly/sel_line/width": 1, "i/readonly/sel_fill/style": "SolidPattern", "i/readonly/sel_fill/color": main_bg, "i/readonly/sel_fill/alpha": 0.1, "i/readonly/sel_symbol/marker": "Rect", "i/readonly/sel_symbol/size": 8, "i/readonly/sel_symbol/edgecolor": "#0000aa", "i/readonly/sel_symbol/facecolor": "#0000ff", "i/readonly/sel_symbol/alpha": 0.7, }, } def apply_plotpy_defaults(self) -> None: """Apply the default PlotPy styles returned by :meth:`get_plotpy_defaults`. This method is called automatically at the end of ``__init__``. It must also be called again after each color theme change, since the defaults depend on PlotPy's current foreground/background colors. """ PLOTPY_CONF.update_defaults(self.get_plotpy_defaults()) def set_plotpy_application( self, config_app_name: str = "", load: bool = False ) -> None: """Set the application name used for PlotPy's INI file. Args: config_app_name: Application name for the PlotPy INI file (e.g., ``"MyApp_v1"``). If empty, PlotPy uses its own default. load: If True, load existing user settings from the INI file. """ PLOTPY_CONF.set_application( osp.join(config_app_name, "plotpy") if config_app_name else "plotpy", self.CONF_VERSION, load=load, ) def initialize_plotpy(self, config_app_name: str = "", load: bool = False) -> None: """Initialize PlotPy's INI-based configuration. Convenience wrapper around :meth:`apply_plotpy_defaults` and :meth:`set_plotpy_application`. Args: config_app_name: Application name for the PlotPy INI file (e.g., ``"MyApp_v1"``). If empty, PlotPy uses its own default. load: If True, load existing user settings from the INI file. """ self.apply_plotpy_defaults() self.set_plotpy_application(config_app_name, load=load) def sync_with_sigima(self) -> None: """Synchronize relevant options with Sigima's options container. Call this after loading or modifying options that have Sigima counterparts (``fft_shift_enabled``, ``auto_normalize_kernel``, ``imageio_formats``). """ sigima_options.fft_shift_enabled.set(self.fft_shift_enabled.get()) sigima_options.auto_normalize_kernel.set(self.auto_normalize_kernel.get()) sigima_options.imageio_formats.set(self.imageio_formats.get()) def get_sigima_defaults(self, category: str) -> dict: """Get default Sigima visualization settings as a dictionary. Collects all options named ``{category}_def_*`` and returns them as a dictionary with the ``{category}_def_`` prefix stripped. Args: category: 'sig' for signal defaults, 'ima' for image defaults. Returns: Dictionary of default visualization settings. Example: >>> options.get_sigima_defaults("ima") {'colormap': 'viridis', 'alpha': 1.0, ...} """ assert category in ("ima", "sig"), f"Expected 'ima' or 'sig', got {category!r}" prefix = f"{category}_def_" result = {} for name in vars(self): if name.startswith(prefix): opt = getattr(self, name) if isinstance(opt, OptionField): value = opt.get() if value is not None: result[name[len(prefix) :]] = value return result def set_sigima_defaults(self, category: str, defaults: dict) -> None: """Set default Sigima visualization settings from a dictionary. Args: category: 'sig' for signal defaults, 'ima' for image defaults. defaults: Dictionary of setting names (without prefix) to values. Example: >>> options.set_sigima_defaults("ima", {"colormap": "gray"}) """ assert category in ("ima", "sig"), f"Expected 'ima' or 'sig', got {category!r}" prefix = f"{category}_def_" for key, value in defaults.items(): attr_name = f"{prefix}{key}" if hasattr(self, attr_name): opt = getattr(self, attr_name) if isinstance(opt, OptionField): opt.set(value) #: Global instance of SigimaX options. #: Derived applications should create their own instance of their subclass. CONF = SigimaXOptions() #: Names of every option defined by the SigimaX base configuration. A derived #: application may add options and override values, but it may **not** remove a #: base option (doing so would break SigimaX modules that read it at runtime). _BASE_OPTION_NAMES: frozenset[str] = frozenset( name for name in vars(CONF) if isinstance(getattr(CONF, name), OptionField) ) #: The currently active options container. Defaults to the SigimaX base #: configuration; a derived application installs its own container via #: :func:`set_conf`. All SigimaX modules read the active container through #: :func:`get_conf` (never by binding ``CONF`` directly), so that a derived #: application's configuration is honoured transparently regardless of import #: order. _active_conf: AppOptionsContainer = CONF def get_conf() -> AppOptionsContainer: """Return the currently active options container. SigimaX modules (and derived-application code) should call this at runtime to read configuration options, e.g. ``get_conf().color_mode.get()``. Returns: The active options container (the SigimaX base by default, or the container installed by a derived application via :func:`set_conf`). """ return _active_conf def set_conf(container: AppOptionsContainer) -> None: """Install a derived application's options container as the active one. The container must define **every** SigimaX base option (base options cannot be removed) so that SigimaX modules never fail a runtime lookup. It may freely add new options and override default values. Args: container: The options container to activate (typically a subclass of :class:`SigimaXOptions`). Raises: ValueError: If the container is missing one or more base SigimaX options. """ container_names = { name for name in vars(container) if isinstance(getattr(container, name), OptionField) } missing = _BASE_OPTION_NAMES - container_names if missing: raise ValueError( "Cannot install options container: the following base SigimaX " f"options are missing (base options cannot be removed): {sorted(missing)}" ) global _active_conf # pylint: disable=global-statement _active_conf = container def reset_conf() -> None: """Restore the SigimaX base configuration as the active container. Mainly useful for tests that install a derived container and need to revert. """ global _active_conf # pylint: disable=global-statement _active_conf = CONF DataLab-Platform-SigimaX-0182bf4/sigimax/data/000077500000000000000000000000001524425144300210315ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/000077500000000000000000000000001524425144300221445ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/analysis/000077500000000000000000000000001524425144300237675ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/analysis/show_results.svg000066400000000000000000000106211524425144300272510ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/apply.svg000066400000000000000000000007251524425144300240160ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/check_all.svg000066400000000000000000000102201524425144300245650ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/collapse.svg000066400000000000000000000031731524425144300244730ustar00rootroot00000000000000 collapse DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/collapse_selection.svg000066400000000000000000000042571524425144300265440ustar00rootroot00000000000000 collapse collapse DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/expand.svg000066400000000000000000000032131524425144300241430ustar00rootroot00000000000000 expand DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/expand_selection.svg000066400000000000000000000042751524425144300262210ustar00rootroot00000000000000 expand expand DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/h5/000077500000000000000000000000001524425144300224605ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/h5/h5attrs.svg000066400000000000000000000144021524425144300245740ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/h5/h5browser.svg000066400000000000000000000114651524425144300251300ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/h5/h5file.svg000066400000000000000000000052021524425144300243540ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/h5/h5group.svg000066400000000000000000000130611524425144300245730ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/help_pdf.svg000066400000000000000000000101071524425144300244450ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/io/000077500000000000000000000000001524425144300225535ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/io/fileopen_h5.svg000066400000000000000000000073471524425144300255040ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/io/filesave_h5.svg000066400000000000000000000100151524425144300254630ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/libre-gui-about.svg000066400000000000000000000005411524425144300256540ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/libre-gui-close.svg000066400000000000000000000032741524425144300256550ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/libre-gui-globe.svg000066400000000000000000000120461524425144300256350ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/libre-gui-help.svg000066400000000000000000000023611524425144300254740ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/libre-gui-menu.svg000066400000000000000000000004261524425144300255100ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/logs.svg000066400000000000000000000014411524425144300236310ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/menu.svg000066400000000000000000000053641524425144300236410ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/restore.svg000066400000000000000000000024611524425144300243530ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/to_signal.svg000066400000000000000000000074201524425144300246470ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/uncheck_all.svg000066400000000000000000000117401524425144300251400ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/view/000077500000000000000000000000001524425144300231165ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/icons/view/refresh-auto.svg000066400000000000000000000056711524425144300262540ustar00rootroot00000000000000 DataLab-Platform-SigimaX-0182bf4/sigimax/data/tests/000077500000000000000000000000001524425144300221735ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/data/tests/empty.h5000066400000000000000000000014401524425144300235660ustar00rootroot00000000000000‰HDF  ÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿ`ˆ¨ˆ¨TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿHEAPXÈPDataLab-Platform-SigimaX-0182bf4/sigimax/data/tests/reordering_test.h5000066400000000000000000003411501524425144300256340ustar00rootroot00000000000000‰HDF  ÿÿÿÿÿÿÿÿhÂÿÿÿÿÿÿÿÿ`ˆ¨ˆ¨TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ0HEAPX(ÈDataLab_VersionDataLab_Sig0˜SNODx   TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ (h`GCOL1.0.0a1 lorentz(a=1,sigma=1,mu=0,ymin=0)$879dd279-5c97-4cc3-8c6a-4a183773e72bLines%g derivative(s001)$51839830-bb97-46b9-b96a-8ed0def5a2c5Lines%g wiener(s002)$4f496e98-c8dd-40f8-a75b-bf08628b0235Lines %g!"#$%&'()*derivative(g001)+derivative(s001),$1cd96fc0-3053-4a52-bc27-c8325fa02651-Lines.%g/012345678derivative(s002)9$642cb4c3-7c77-4ce4-a7b1-6e2bc82ec40b:Lines;%g<=>?@ABCDEderivative(s003)F$748934e3-8398-42de-a0dc-98f30008ea14GLinesH%gIJKLMNOPQRmagnitude_spectrum(g002)Smagnitude_spectrum(s004)T$25dd7970-e2ca-4c9e-adab-1a0dee022e44ULinesV%gWX FrequencyYZ[\]^_`magnitude_spectrum(s005)a$1d83959d-57d0-44d9-be4a-2218eae8b227bLinesc%gde Frequencyfghijklmmagnitude_spectrum(s006)n$13d2e5d7-8802-47d4-a3c1-5d745105e85aoLinesp%gqr FrequencystuvwxyPHEAPXH0g001: g002: derivative(g001)g003: magnitude_spectrum(g002)TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿˆ@HEAPXÈs001: lorentz(a1sigma1mu0ymin0)s002: derivative(s001)s003: wiener(s002)SNODÀˆ¨ð¦§8©(€©À4à6ˆ¨ @titleÐ `TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ€AHEAPX 0xydatametadata8SNODÈð(°KØKøM@Pyxy˜{àGÐ @title (ôô ?@4 4ÿ@"@x$À¨hüœzë#ÀQÑø9õÖ#Àù9õÖoÂ#À¡¢ñsê­#ÀI îe™#Àòsê­ß„#ÀšÜæJZp#ÀBEãçÔ[#Àê­ß„OG#À“Ü!Ê2#À;ؾD#ÀãçÔ[¿ #ÀŒPÑø9õ"À4¹Í•´à"ÀÜ!Ê2/Ì"À„ŠÆÏ©·"À-óÂl$£"ÀÕ[¿ ŸŽ"À}Ä»¦z"À&-¸C”e"ÀΕ´àQ"Àvþ°}‰<"Àg­("ÀÇÏ©·~"Ào8¦Tùþ!À¡¢ñsê!À¿ ŸŽîÕ!Àhr›+iÁ!ÀÛ—Èã¬!À¸C”e^˜!À`¬�Ùƒ!À �ŸSo!À±}‰<ÎZ!ÀYæ…ÙHF!ÀO‚vÃ1!Àª·~>!ÀR {°¸!ÀúˆwM3ô À£ñsê­ß ÀKZp‡(Ë ÀóÂl$£¶ Àœ+iÁ¢ ÀD”e^˜� Àìüaûy À”e^˜�d À=ÎZ5P Àå6WÒ‚; À�ŸSoý& À5P x À¼á˜RåûÀ ³‘ŒÚÒÀ\„ŠÆÏ©À­UƒÅ€Àþ&|:ºWÀNøtt¯.ÀžÉm®¤Àïšfè™ÜÀ@l_"�³À�=X\„ŠÀáQ–yaÀ2àIÐn8À‚±B dÀÒ‚;DYæÀ#T4~N½Àt%-¸C”ÀÄö%ò8kÀÈ,.BÀe™f#À¶j ðÀ< Ú ÇÀW žÀ¨ÞúMøtÀø¯ó‡íKÀH�ìÁâ"À™Råû×ùÀê#Þ5ÍÐÀ:õÖo§ÀŠÆÏ©·~ÀÛ—Èã¬UÀ,iÁ¢,À|:ºW—ÀÍ ³‘ŒÚÀÝ«Ë�±Àn®¤wˆÀ¾�?l_ÀQ–ya6À`"�³V À°ó‡íKäÀÅ€'A»ÀQ–ya6’À¢gr›+iÀò8kÕ @ÀC dÀ”Û\I îÀä¬UƒÅÀ4~N½õ›À…OG÷êrÀÖ @1àIÀ&ò8kÕ ÀwÃ1¥Ê÷ÀÇ”*ß¿ÎÀf#µ¥Àh7Sª|À¹�ŸSÀ Ú Ç”*ÀZ«ŠÀ«|ÿ:ØÀûMøtt¯ÀLñ®i†Àœðéè^]ÀíÁâ"T4À=“Û\I ÀŽdÔ–>âÀÞ5ÍÐ3¹À/Æ )�ÀؾDgÀЩ·~>À!{°¸ÀqL©òýëÀ¢,óÂÀïšfè™ÀcÀ“ ÝpÀ³‘ŒÚÒGÀc…ÈÀT4~N½õÀ¥wˆ²ÌÀõÖo§£ÀF¨hüœzÀ—ya6’QÀçJZp‡(À8Sª|ÿÀˆíKäqÖÀÙ¾Dg­À)�=X\„Àza6’Q[ÀÊ2/ÌF2À(< ÀkÕ @1àÀ¼¦z&·À x´ŽÀ]I îeÀ®(<ÀþëüaûÀO½õ›ðéÀŸŽîÕåÀÀð_çÛ—À@1àIÐnÀ‘ÙƒÅEÀáÓѽºÀdJ•ï_çÀí†cJ•À¦�x×4CÀH2jKñÀèÔ[¿ ŸÀŠwM3ôLÀ*?§Þú À̼0ɨ Àl_"�³V Àž À°¤wˆ² ÀPG÷êr` Àòéè^] À’ŒÚÒG¼ À4/ÌF2j ÀÔѽº Àvt¯.Æ À¡¢ñs À¸¹’Ü! ÀX\„ŠÆÏ Àúþuþ°} Àœ¡gr›+ ÀÝ«ËÀ¶àQ–yÀVƒÅ€'Àø%ò8kÕÀ˜Èã¬UƒÀ:kÕ @1À¸Ž)U¾ÿ¿ø`q*ÿ¿8¦Tùþuþ¿€ë7áÓÑý¿À0ɨ-ý¿vþ°}‰ü¿@»á˜Råû¿ˆÅ€'Aû¿ÈE¨hüœú¿‹‹PÑøù¿HÐn8¦Tù¿�R {°ø¿ÐZ5P ø¿ ð$h÷¿Xåû×ùÃö¿˜*ß¿Îö¿Øo§£{õ¿µ¥�x×ô¿`úˆwM3ô¿ ?l_"�ó¿à„OG÷êò¿ Ê2/ÌFò¿h¡¢ñ¿¨Tùþuþð¿è™ÜæJZð¿`¾�?lï¿àHFmé#î¿`Ó =“Ûì¿à]Ó =“ë¿pè™ÜæJê¿ðr`¬�é¿pý&|:ºç¿ð‡íKäqæ¿€´Ž)å¿�zë7áã¿€'A»á˜â¿²‹‹Pá¿�<ÎZ5à¿ Ž)U¾Ý¿ £¶ôïÚ¿@¸C”e^Ø¿@ÍÐ3¹ÍÕ¿@â]Ó =Ó¿@÷êr`¬Ð¿Àð$h7Ì¿ÀB dÇ¿Àl$£¶ôÁ¿.}Ä»¦¹¿c…È®¿X—c…”¿X—c…”?c…È®?€-}Ä»¦¹?Àl$£¶ôÁ?ÀB dÇ?€ð$h7Ì?@÷êr`¬Ð?@â]Ó =Ó? ÍÐ3¹ÍÕ? ¸C”e^Ø? £¶ôïÚ? Ž)U¾Ý?€<ÎZ5à?²‹‹Pá?€'A»á˜â?�zë7áã?p´Ž)å?ð‡íKäqæ?pý&|:ºç?àr`¬�é?`è™ÜæJê?à]Ó =“ë?`Ó =“Ûì?ÐHFmé#î?P¾�?lï?è™ÜæJZð?¨Tùþuþð?`¡¢ñ? Ê2/ÌFò?à„OG÷êò?˜?l_"�ó?XúˆwM3ô?µ¥�x×ô?Øo§£{õ?�*ß¿Îö?Påû×ùÃö? ð$h÷?ÐZ5P ø?ˆR {°ø?HÐn8¦Tù?‹‹PÑøù?ÀE¨hüœú?€Å€'Aû?@»á˜Råû?vþ°}‰ü?¸0ɨ-ý?xë7áÓÑý?8¦Tùþuþ?ø`q*ÿ?°Ž)U¾ÿ?8kÕ @1@˜Èã¬Uƒ@ô%ò8kÕ@TƒÅ€'@´àQ–y@>Ý«Ë@p›+iÁ@Ðø9õÖo@0VH�ìÁ@�³V @ìe™f@Lns%-¸@¬Ë�±B @)�=X\@h†žÉm®@Èã¬Uƒ@(A»á˜R@„žÉm®¤@äû×ùÃö@DYæ…ÙH@ ¶ôïš@ží@`q*?@Àζ/‘@,.BEã@|‰<ÎZ5@ÜæJZp‡@@~ؾDg@.Æ )�@Þ5ÍÐ3¹@ŒdÔ–>â@<“Û\I @ìÁâ"T4@œðéè^]@Jñ®i†@úMøtt¯@ª|ÿ:Ø@Z«Š@Ú Ç”*@¸�ŸS@h7Sª|@f#µ¥@Æ”*ß¿Î@vÃ1¥Ê÷@$ò8kÕ @Ô @1àI@„OG÷êr@4~N½õ›@ä¬UƒÅ@”Û\I î@@ d@ð8kÕ @@ gr›+i@P–ya6’@Å€'A»@°ó‡íKä@`"�³V @Q–ya6@¼�?l_@l®¤wˆ@Ý«Ë�±@Ì ³‘ŒÚ@|:ºW—@,iÁ¢,@Ü—Èã¬U@ˆÆÏ©·~@8õÖo§@è#Þ5ÍÐ@˜Råû×ù@H�ìÁâ"@ø¯ó‡íK@¨ÞúMøt@T ž@< Ú Ç@´j ð@d™f#@È,.B@Äö%ò8k@t%-¸C”@$T4~N½@Ђ;DYæ@€±B d@0àIÐn8@àQ–ya@�=X\„Š@@l_"�³@ðšfè™Ü@œÉm®¤@Løtt¯.@ü&|:ºW@¬UƒÅ€@\„ŠÆÏ©@ ³‘ŒÚÒ@¼á˜Råû@4P x @ŒŸSoý& @ä6WÒ‚; @<ÎZ5P @”e^˜�d @ìüaûy @D”e^˜� @œ+iÁ¢ @òÂl$£¶ @JZp‡(Ë @¢ñsê­ß @úˆwM3ô @R {°¸!@ª·~>!@O‚vÃ1!@Xæ…ÙHF!@°}‰<ÎZ!@�ŸSo!@`¬�Ùƒ!@¸C”e^˜!@Û—Èã¬!@hr›+iÁ!@¾ ŸŽîÕ!@¡¢ñsê!@n8¦Tùþ!@ÆÏ©·~"@g­("@vþ°}‰<"@Ε´àQ"@&-¸C”e"@|Ä»¦z"@Ô[¿ ŸŽ"@,óÂl$£"@„ŠÆÏ©·"@Ü!Ê2/Ì"@4¹Í•´à"@ŒPÑø9õ"@âçÔ[¿ #@:ؾD#@’Ü!Ê2#@ê­ß„OG#@BEãçÔ[#@šÜæJZp#@òsê­ß„#@H îe™#@ ¢ñsê­#@ø9õÖoÂ#@PÑø9õÖ#@¨hüœzë#@$@qñ¡AYÑi?":@!j?°Üªá„;j?�|Z�qj?5)lB¨j?-¼ôŠžßj?Z}P5§k?%"}8_Pk?�9ppɉk?— ÈèÃk?}c}9Àþk?¬2›ÎR:l?kJ¡£vl?g´áÛµ³l?~Ô•¹Œñl?[Öá†+0m?Ã÷¡•om?7m2{ίm??óŠ•Ùðm?¹‡º2n?�öjùtun?ZúNª ¹n?‚@l…ýn?9&ãBo?ÉË�Õ)‰o?üÄ�]Ðo?ð4uq?÷¶·�‰gq?�r=Ï�q?q4s¸§ºq?p»åq?8ÁB r?�퇽;r?ÿýgr?¥ùå¯Ý”r?=fbÂr?ãÅlWŽðr?=“ó¶ds?o#/ËèNs?“ãÌîs?¿v1‘°s?’!7©ás?üa{t?uæl#Gt?o“¹{t?ïxæ\©¯t?àóhñåt?¬‰y‰[u?>k–QoRu?8ß%‘ZŠu?J.F«!Ãu?nߤÉüu?1�]‹U7v?—°à©Ërv?‚‰âU0¯v?wÀRŠˆìv?nÕ\cÙ*w?|ãq(jw?d \ zªw?Ïû[ìÔëw?™ØP/>.x?¨7ë»»qx? cëŒS¶x?¶…kÆ üx? 66·êBy?|ç)ÚöŠy?Ñ©×6Ôy?1݇±z?¥?{ðmjz?SëMs·z?–kn É{?¨PžÒvU{?÷|x„¦{?sBQúø{?ê—£òßL|?O'=£>¢|?ÛÄHñù|?K„ê‰Q}?6‰àˆ«}?¦¨.k%~?hðÿjid~?U±Ò _Ã~?ììsÇ$?Â*sh‰†?å Ôê?òª~(€?pKdž\€?Q]!3Š‘€?¸5ØžŽÇ€?¶Æ‚šþ€?Ûñ2‹´6�?_„Kšão�?¯æ(Ê.ª�?@°ïn�å�?G«7"‚?¬f—`‚?\Àú Ÿ‚?ä X”Uß‚?š¹üÿë ƒ?Q~u×cƒ?œ> ! ¨ƒ?�Ú�Ðíƒ?@næò4„?m:yòŽ}„?Rï�£±Ç„?g«oýd…?4Š‚9´`…?ïŠ:õª¯…?WîB6U†?ªØn¿R†?ã¸Z‚ö¦†?Œ8Êý†? v<U‡?y7EÇð®‡?#ÈW«å ˆ?ï‚',ïhˆ?–ÑAɈ?S×Wy€+‰?˜@2*�‰?Äùˆ²+÷‰?°ÛŽ˜`Š?y¯‰;‚ÌŠ?¬B ?þ:‹?D£˜Ì ¬‹?œÿÅlÿŒ?Ó΀°–Œ?çq¬LK�?X¤ÂèŒ�?@ÊŸ Ž?vÔŒ�Ž? Â_Ê�?²|ÈtŸ�?E@ìÌT�?³”ËÃ^�?Ý‚Kg©�?ò·eT`õ�?£îý¯C‘?Å]—Š”‘?kÌî¬æ‘?á¾~î€;’?ÙZ-,©’’?HÊ{3;ì’?)œQ…MH“?ÈU¯¤÷¦“?%3G$R”?4æ´vl”?ï0»4€Ó”?õ§Š¿Š=•?týÞ¿³ª•?ªF–? Ç­ÃÝŽ–?¢úóÏ —?à‚¼��—?„T¥´ÿ—?×/ïbP‚˜?!�´; ™?ffÉ~ú“™?¸a(`#š?¶ê›vd·š?d· 9P›?÷­‘î›?[('W&‘œ?²ˆ˜�¯9�?¥<†gêç�?«W·œž?Óü%»wVŸ?Ý¼ë ª  ?±¢Ðr{o ?R”#×Ö ?±=¦æA¡?8²Õ°¡?âXfÒ#¢?²ÖEp ›¢?R#�3º£?ˆsõ—£?, E¤?a†B™¦¤?£ôJ6¥?¬cŽƒžË¥?‚í~MÛf¦?‡¿ M§?– ½ÛC°§?˜T4ƒ_¨?£k¯¸©?*áÆo¯Ò©?^)=˜ª?OoíE,f«?¹¢H_í<¬?˜ER ÷­??׌#É®?/|nRçú®?TÈŸFßù¯?,°ù#‚°?ÉÊðÐ\ ±?ñ�~ ðž±?¶7œ47²?Uÿ€‡Ö²?J~eáH}³?2ìÇ’à+´?¦¾Á¹â´?¥6>4E¢µ?e ÅÓøj¶?×pá•O=·?JïÉLɸ?” .Zê¹?%/f=;ó¹?ižêôGñº?K¡)Ÿû»?¡Íѽ?Ñ¥1n7¾?aUhSj¿?7úçÑ�UÀ?s ò£ýÀ?cÅîšy­Á?ÏÒùœHeÂ?ì\ê¹>%Ã?¢‚¯î}íÃ?ÃÒK¾Ä?ÏðÕT—Å?ñÌ âOxÆ?{-…£aÇ?œþwºRÈ?!Ÿ(%KÉ?”¬ h(JÊ? ·®OË?/žw;¯XÌ?àÔíÖÖeÍ?& ¢YõtÎ?ó­"ˆ=„Ï?;uEgÎHÐ?ׂm9^ÍÐ?:$¨ê‚NÑ?ߊ$#ÕÊÑ?Öõ(k×@Ò?ºÒ&þ®Ò?~Ke�¹Ó?3“w�mÓ?ªìKâºÓ?|#P¾ŠúÓ?ŒÁ‘9Y+Ô?9”Ú4hLÔ?“µVw]Ô?“µVw]Ô?:”Ú4hLÔ?�Á‘9Y+Ô?|#P¾ŠúÓ?ªìKâºÓ?3“w�mÓ?~Ke�¹Ó?ºÒ&þ®Ò?Ýõ(k×@Ò?æŠ$#ÕÊÑ?:$¨ê‚NÑ?ׂm9^ÍÐ?BuEgÎHÐ?®"ˆ=„Ï?& ¢YõtÎ?àÔíÖÖeÍ?=žw;¯XÌ? ·®OË?”¬ h(JÊ?.Ÿ(%KÉ?%œþwºRÈ?{-…£aÇ?ñÌ âOxÆ?ÙðÕT—Å?)ÃÒK¾Ä?¢‚¯î}íÃ?ì\ê¹>%Ã?ÙÒùœHeÂ?cÅîšy­Á?s ò£ýÀ??úçÑ�UÀ?pUhSj¿?Ñ¥1n7¾?¡Íѽ?Z¡)Ÿû»?užêôGñº?%/f=;ó¹?” .Zê¹?UïÉLɸ?×pá•O=·?e ÅÓøj¶?®6>4E¢µ?¯¾Á¹â´?2ìÇ’à+´?J~eáH}³?\ÿ€‡Ö²?¾7œ47²?ñ�~ ðž±?ÉÊðÐ\ ±?3°ù#‚°?ZÈŸFßù¯?/|nRçú®?J׌#É®?�ER ÷­?¾¢H_í<¬?OoíE,f«?g)=˜ª?.áÆo¯Ò©?£k¯¸©?˜T4ƒ_¨?› ½ÛC°§?Œ¿ M§?‚í~MÛf¦?´cŽƒžË¥?£ôJ6¥?d†B™¦¤?, E¤?ˆsõ—£?U#�3º£?²ÖEp ›¢?âXfÒ#¢?;²Õ°¡?±=¦æA¡?R”#×Ö ?¶¢Ðr{o ?à¼ë ª  ?Øü%»wVŸ?«W·œž?­<†gêç�?¶ˆ˜�¯9�?[('W&‘œ?þ­‘î›?h· 9P›?ºê›vd·š?¸a(`#š?mfÉ~ú“™?$�´; ™?Ú/ïbP‚˜?„T¥´ÿ—?悼��—?¥úóÏ —? Ç­ÃÝŽ–?¯F–?wýÞ¿³ª•?ø§Š¿Š=•?ï0»4€Ó”?4æ´vl”?(3G$R”?ÊU¯¤÷¦“?)œQ…MH“?JÊ{3;ì’?ÛZ-,©’’?á¾~î€;’?pÌî¬æ‘?Ç]—Š”‘?¥îý¯C‘?ò·eT`õ�?á‚Kg©�?µ”ËÃ^�?G@ìÌT�?²|ÈtŸ�?Â_Ê�?yÔŒ�Ž?@ÊŸ Ž?^¤ÂèŒ�?ëq¬LK�?×΀°–Œ?œÿÅlÿŒ?J£˜Ì ¬‹?°B ?þ:‹?{¯‰;‚ÌŠ?°ÛŽ˜`Š?Æùˆ²+÷‰?›@2*�‰?S×Wy€+‰?›ÑAɈ?ò‚',ïhˆ?%ÈW«å ˆ?y7EÇð®‡?v<U‡?Ž8Êý†?å¸Z‚ö¦†?ªØn¿R†?YîB6U†?òŠ:õª¯…?4Š‚9´`…?j«oýd…?Tï�£±Ç„?n:yòŽ}„?@næò4„? �Ú�Ðíƒ?ž> ! ¨ƒ?T~u×cƒ?š¹üÿë ƒ?æ X”Uß‚?]Àú Ÿ‚?¬f—`‚?J«7"‚?A°ïn�å�?±æ(Ê.ª�?b„Kšão�?Ýñ2‹´6�?¸Æ‚šþ€?¸5ØžŽÇ€?Q]!3Š‘€?pKdž\€?õª~(€?ê Ôê?Ç*sh‰†?îìsÇ$?U±Ò _Ã~?hðÿjid~?¦¨.k%~?3‰àˆ«}?K„ê‰Q}?ßÄHñù|?T'=£>¢|?í—£òßL|?sBQúø{?÷|x„¦{?¦PžÒvU{?škn É{?SëMs·z?©?{ðmjz?3݇±z?Ñ©×6Ôy?|ç)ÚöŠy? 66·êBy?º…kÆ üx?cëŒS¶x?«7ë»»qx?›ØP/>.x?Ïû[ìÔëw?d \ zªw?|ãq(jw?mÕ\cÙ*w?zÀRŠˆìv?…‰âU0¯v?š°à©Ërv?3�]‹U7v?nߤÉüu?J.F«!Ãu?6ß%‘ZŠu?@k–QoRu?¯‰y‰[u?âóhñåt?ñxæ\©¯t?o“¹{t?uæl#Gt?üa{t?”!7©ás?Âv1‘°s?–ãÌîs?q#/ËèNs?=“ó¶ds?ãÅlWŽðr?=fbÂr?¥ùå¯Ý”r?"ÿýgr?�퇽;r?:ÁB r?p»åq?q4s¸§ºq?�r=Ï�q?÷¶·�‰gq? ¶Ú Ô>q?ë¾6¬q?ô‚bïp?Ók­úÇp?vûqWl¡p?dRNºa{p?½¹0ƒØUp?•JlÎ0p?ñ4u!ÀR {°¸!ÀúˆwM3ô À£ñsê­ß ÀKZp‡(Ë ÀóÂl$£¶ Àœ+iÁ¢ ÀD”e^˜� Àìüaûy À”e^˜�d À=ÎZ5P Àå6WÒ‚; À�ŸSoý& À5P x À¼á˜RåûÀ ³‘ŒÚÒÀ\„ŠÆÏ©À­UƒÅ€Àþ&|:ºWÀNøtt¯.ÀžÉm®¤Àïšfè™ÜÀ@l_"�³À�=X\„ŠÀáQ–yaÀ2àIÐn8À‚±B dÀÒ‚;DYæÀ#T4~N½Àt%-¸C”ÀÄö%ò8kÀÈ,.BÀe™f#À¶j ðÀ< Ú ÇÀW žÀ¨ÞúMøtÀø¯ó‡íKÀH�ìÁâ"À™Råû×ùÀê#Þ5ÍÐÀ:õÖo§ÀŠÆÏ©·~ÀÛ—Èã¬UÀ,iÁ¢,À|:ºW—ÀÍ ³‘ŒÚÀÝ«Ë�±Àn®¤wˆÀ¾�?l_ÀQ–ya6À`"�³V À°ó‡íKäÀÅ€'A»ÀQ–ya6’À¢gr›+iÀò8kÕ @ÀC dÀ”Û\I îÀä¬UƒÅÀ4~N½õ›À…OG÷êrÀÖ @1àIÀ&ò8kÕ ÀwÃ1¥Ê÷ÀÇ”*ß¿ÎÀf#µ¥Àh7Sª|À¹�ŸSÀ Ú Ç”*ÀZ«ŠÀ«|ÿ:ØÀûMøtt¯ÀLñ®i†Àœðéè^]ÀíÁâ"T4À=“Û\I ÀŽdÔ–>âÀÞ5ÍÐ3¹À/Æ )�ÀؾDgÀЩ·~>À!{°¸ÀqL©òýëÀ¢,óÂÀïšfè™ÀcÀ“ ÝpÀ³‘ŒÚÒGÀc…ÈÀT4~N½õÀ¥wˆ²ÌÀõÖo§£ÀF¨hüœzÀ—ya6’QÀçJZp‡(À8Sª|ÿÀˆíKäqÖÀÙ¾Dg­À)�=X\„Àza6’Q[ÀÊ2/ÌF2À(< ÀkÕ @1àÀ¼¦z&·À x´ŽÀ]I îeÀ®(<ÀþëüaûÀO½õ›ðéÀŸŽîÕåÀÀð_çÛ—À@1àIÐnÀ‘ÙƒÅEÀáÓѽºÀdJ•ï_çÀí†cJ•À¦�x×4CÀH2jKñÀèÔ[¿ ŸÀŠwM3ôLÀ*?§Þú À̼0ɨ Àl_"�³V Àž À°¤wˆ² ÀPG÷êr` Àòéè^] À’ŒÚÒG¼ À4/ÌF2j ÀÔѽº Àvt¯.Æ À¡¢ñs À¸¹’Ü! ÀX\„ŠÆÏ Àúþuþ°} Àœ¡gr›+ ÀÝ«ËÀ¶àQ–yÀVƒÅ€'Àø%ò8kÕÀ˜Èã¬UƒÀ:kÕ @1À¸Ž)U¾ÿ¿ø`q*ÿ¿8¦Tùþuþ¿€ë7áÓÑý¿À0ɨ-ý¿vþ°}‰ü¿@»á˜Råû¿ˆÅ€'Aû¿ÈE¨hüœú¿‹‹PÑøù¿HÐn8¦Tù¿�R {°ø¿ÐZ5P ø¿ ð$h÷¿Xåû×ùÃö¿˜*ß¿Îö¿Øo§£{õ¿µ¥�x×ô¿`úˆwM3ô¿ ?l_"�ó¿à„OG÷êò¿ Ê2/ÌFò¿h¡¢ñ¿¨Tùþuþð¿è™ÜæJZð¿`¾�?lï¿àHFmé#î¿`Ó =“Ûì¿à]Ó =“ë¿pè™ÜæJê¿ðr`¬�é¿pý&|:ºç¿ð‡íKäqæ¿€´Ž)å¿�zë7áã¿€'A»á˜â¿²‹‹Pá¿�<ÎZ5à¿ Ž)U¾Ý¿ £¶ôïÚ¿@¸C”e^Ø¿@ÍÐ3¹ÍÕ¿@â]Ó =Ó¿@÷êr`¬Ð¿Àð$h7Ì¿ÀB dÇ¿Àl$£¶ôÁ¿.}Ä»¦¹¿c…È®¿X—c…”¿X—c…”?c…È®?€-}Ä»¦¹?Àl$£¶ôÁ?ÀB dÇ?€ð$h7Ì?@÷êr`¬Ð?@â]Ó =Ó? ÍÐ3¹ÍÕ? ¸C”e^Ø? £¶ôïÚ? Ž)U¾Ý?€<ÎZ5à?²‹‹Pá?€'A»á˜â?�zë7áã?p´Ž)å?ð‡íKäqæ?pý&|:ºç?àr`¬�é?`è™ÜæJê?à]Ó =“ë?`Ó =“Ûì?ÐHFmé#î?P¾�?lï?è™ÜæJZð?¨Tùþuþð?`¡¢ñ? Ê2/ÌFò?à„OG÷êò?˜?l_"�ó?XúˆwM3ô?µ¥�x×ô?Øo§£{õ?�*ß¿Îö?Påû×ùÃö? ð$h÷?ÐZ5P ø?ˆR {°ø?HÐn8¦Tù?‹‹PÑøù?ÀE¨hüœú?€Å€'Aû?@»á˜Råû?vþ°}‰ü?¸0ɨ-ý?xë7áÓÑý?8¦Tùþuþ?ø`q*ÿ?°Ž)U¾ÿ?8kÕ @1@˜Èã¬Uƒ@ô%ò8kÕ@TƒÅ€'@´àQ–y@>Ý«Ë@p›+iÁ@Ðø9õÖo@0VH�ìÁ@�³V @ìe™f@Lns%-¸@¬Ë�±B @)�=X\@h†žÉm®@Èã¬Uƒ@(A»á˜R@„žÉm®¤@äû×ùÃö@DYæ…ÙH@ ¶ôïš@ží@`q*?@Àζ/‘@,.BEã@|‰<ÎZ5@ÜæJZp‡@@~ؾDg@.Æ )�@Þ5ÍÐ3¹@ŒdÔ–>â@<“Û\I @ìÁâ"T4@œðéè^]@Jñ®i†@úMøtt¯@ª|ÿ:Ø@Z«Š@Ú Ç”*@¸�ŸS@h7Sª|@f#µ¥@Æ”*ß¿Î@vÃ1¥Ê÷@$ò8kÕ @Ô @1àI@„OG÷êr@4~N½õ›@ä¬UƒÅ@”Û\I î@@ d@ð8kÕ @@ gr›+i@P–ya6’@Å€'A»@°ó‡íKä@`"�³V @Q–ya6@¼�?l_@l®¤wˆ@Ý«Ë�±@Ì ³‘ŒÚ@|:ºW—@,iÁ¢,@Ü—Èã¬U@ˆÆÏ©·~@8õÖo§@è#Þ5ÍÐ@˜Råû×ù@H�ìÁâ"@ø¯ó‡íK@¨ÞúMøt@T ž@< Ú Ç@´j ð@d™f#@È,.B@Äö%ò8k@t%-¸C”@$T4~N½@Ђ;DYæ@€±B d@0àIÐn8@àQ–ya@�=X\„Š@@l_"�³@ðšfè™Ü@œÉm®¤@Løtt¯.@ü&|:ºW@¬UƒÅ€@\„ŠÆÏ©@ ³‘ŒÚÒ@¼á˜Råû@4P x @ŒŸSoý& @ä6WÒ‚; @<ÎZ5P @”e^˜�d @ìüaûy @D”e^˜� @œ+iÁ¢ @òÂl$£¶ @JZp‡(Ë @¢ñsê­ß @úˆwM3ô @R {°¸!@ª·~>!@O‚vÃ1!@Xæ…ÙHF!@°}‰<ÎZ!@�ŸSo!@`¬�Ùƒ!@¸C”e^˜!@Û—Èã¬!@hr›+iÁ!@¾ ŸŽîÕ!@¡¢ñsê!@n8¦Tùþ!@ÆÏ©·~"@g­("@vþ°}‰<"@Ε´àQ"@&-¸C”e"@|Ä»¦z"@Ô[¿ ŸŽ"@,óÂl$£"@„ŠÆÏ©·"@Ü!Ê2/Ì"@4¹Í•´à"@ŒPÑø9õ"@âçÔ[¿ #@:ؾD#@’Ü!Ê2#@ê­ß„OG#@BEãçÔ[#@šÜæJZp#@òsê­ß„#@H îe™#@ ¢ñsê­#@ø9õÖoÂ#@PÑø9õÖ#@¨hüœzë#@$@‡‰�“Y’D?Æ+uÑD?fMwµ’E?}S Ô·RE?2K›¨é”E?¸Oøœ-ØE?¥×Í<‰F?Åç*6bF?G(‘Zž¨F?÷ŸcðF?+Ò!X9G?ŒÝ*"‚ƒG?|¸ èÎG? On�H?n†¡‚iH?•âXÂøH?$Tx¯\ I?û?T[I?ŒPŠ[±®I?û–-|J?f\¼YJ?×ò]îy±J?È¥È ½ K?µ¦VfŽeK?JŸPjöÁK?"ÎýþL?~‹h~®L?Ê™2áL?ÒÑy_/DM? ÒÍQ©M?éd7ÇN?;?RëTxN?þòy«ÇâN?„® C*OO?é¹¶ø‡½O?¢»ø0öP?³a¸²1PP?Ôâž|ŠP?§>ÝÅP?\—]ZQ?=Ä>˜ù?Q?Ü£žÉÂ~Q?Ÿ*��¼¾Q?­Ú"KîÿQ? A�=_BR?À�k†R?ÉML¯ËR?æÅ›{S?3Ë•ª7YS?Ò¶Ié[¢S?‚¤²¤ðìS?˜Eäëþ8T?TnM�†T?ü ²­ÕT??2w®a&U?;‰u6¶xU?�SǵÌU?Ý9e/k"V?‰Aê�áyV?y¸ÿd$ÓV?Òk¸~?.W?€öQ?‹W?ËÒ‹¤/êW?Ã_!6KX?îzh®X?éU(+Y?-X,‡ezY?ÖßçÕãY?ðÛÇi‹OZ? Pw­•½Z?‘êóÐ.[?qñ�xé [?ùfÓT\?HÕ XŽ\?Ó�¦2 ]?�û1zs†]?Ø-±±^?NŒNÔ‰^?]ßxñ_?ŒBªÜ™_?�f¿G¹`?­uŒÔ�Z`?Ʀîõ£`?÷Îv6ï`?brM. Iˆa–Èn?º&EZ_ro?°LÉ:�p?) çUjp?8®ÄÆp? Jp†å%q?M›mчq?V™Ñt¢ìq?äÎsTr?¡·Þa¿r?ãjšFŠ-s?Äyø Ÿs?ÏGt?T#¿÷�Œt?¶®JVóu?[—ŽH,‰u?ßíAdo v?tL¤å•v?aÀ’g·"w?ü¹ ½´w?:L%Jx?¤ ‹åx?²Ò>4…y?ü¦—˜*z?B×�[„Õz?µÊQ2†{?OÁ4àÞ<|?åg!NËù|?þ}7Õ:½}?$ì]t‡~? ¹]ÂX? ™Fˆ¹€?;SW숀?Ô3¼Ò$ý€?N¸–l�u�?”ó^ò�?æáĶÂs‚?Ç¥³«ðù‚?ËHþ– …ƒ?;¯�7�„?æ ut«„?“Cp�G…?Q.Y»è…?”Ód¨�†?™AâJ+?‡?ÀŽ–ä”ô‡?$Cû�:±ˆ?ö¦˜}vu‰?`¾ô§AŠ?@ù¬3‹?:ìc(„ó‹?QX ÚŒ?¹þ‰<Ê�?ÿ)>șĎ?xô"Y§É�?Ü#3Yùl�?…ãÝØû�?qúB”Q�‘?5ÍŽ¸'*’?ª…ÿ†äË’?p™·©æt“?Ž’%”?<úÞÄRÞ”? |h˜Ÿ•?ž�‰Ûi–?0å|¢›=—?t‰û`˜?ÞñÒ—¸™??øæ=÷™?%LG+’öš?‰ýt¥aœ?¼q!Hc�?DÉ\YBž?l2/xŸ?˜;_´^ ?U÷¬¸¢ ¡?å‹iO½¡?ƒ´ï$=z¢?î}siö@£?R©Ü ¤?�Ü Ÿî¤?ò8}£ÌÕ¥?{åŽðÄɦ?àÒ}޽ʧ?âøÍ?wÙ¨?Lsºö©?>I÷WZ#«?ÒRo3`¬?tÆÀÑ)®­?%Rß+¯?�’ž—@°?ï5¾‰±?ð| Ò±?ŽEËh «²?M‰ý˜��³?W•Džô´?«SÂÆÜ|µ?Ý3êÚ«†¶?kƒ‹È�·?( õŠÂ¸?nÿÇü2õ¹?_À©®ê5»?¶󵄼?ï3µðmá½?M&梵K¿?³á(ÈvaÀ?dÑsÏ#Á?)úrêÁ?®uq¸oµÂ?Ó6þ(„Ã?ç¼bí½TÄ?Óæ¨­_%Å?Ò€g©ØóÅ?�‡Y€½Æ?!ÇáÃ/Ç?‚þ¤“75È?¡uÕ÷XÛÈ?ƒý½ÂlÉ? O^äÉ?g_i;Ê?dCpllÊ?+^Ö!|pÊ?uéÕ@Ê?ĦÎvÕÖÉ?Ýtº‹@,É?9i i ;È?¤?© ¥Ç?Â'†¤ŒxÅ?¾V Ò‡¢Ã?—£òô€Á?¤.Y*¾?ŠeѸ?é8.@å³?‹B”{Æ©?h*š?µg*š¿ü@”{Æ©¿(9.@峿ŠeѸ¿í¤.Y*¾¿„£òô€Á¿¾V Ò‡¢Ã¿p'†¤ŒxÅ¿¤?© ¥Ç¿hi i ;È¿Ýtº‹@,É¿©¦ÎvÕÖÉ¿uéÕ@Ê¿>^Ö!|pÊ¿dCpllÊ¿Q_i;Ê¿:O^äÉ¿ƒý½ÂlÉ¿’uÕ÷XÛÈ¿›þ¤“75È¿'ÇáÃ/Ç¿�‡Y€½Æ¿ë€g©ØóÅ¿Óæ¨­_%Å¿ã¼bí½TÄ¿Ó6þ(„ÿ�uq¸oµÂ¿6)úrêÁ¿dÑsÏ#Á¿¸á(ÈvaÀ¿f&梵K¿¿ð3µðm὿¶󵄼¿<À©®ê5»¿¹ÿÇü2õ¹¿ õŠÂ¸¿kƒ‹È�·¿ã3êÚ«†¶¿²SÂÆÜ|µ¿W•Džô´¿a‰ý˜��³¿ŽEËh «²¿ò| Ò±¿ï5¾‰±¿¬’ž—@°¿ã$Rß+¯¿‘ÆÀÑ)®­¿ÒRo3`¬¿3I÷WZ#«¿:Lsºö©¿ÜøÍ?wÙ¨¿öÒ}޽ʧ¿‚åŽðÄɦ¿ò8}£ÌÕ¥¿—Ü Ÿl©Ü ¤¿ó}siö@£¿t´ï$=z¢¿å‹iO½¡¿C÷¬¸¢ ¡¿˜;_´^ ¿¢2/xŸ¿íCÉ\YBž¿r!Hc�¿‰ýt¥aœ¿LG+’öš¿‰øæ=÷™¿¦ñÒ—¸™¿t‰û`˜¿'å|¢›=—¿ž�‰Ûi–¿ |h˜Ÿ•¿OúÞÄRÞ”¿Ž’%”¿[™·©æt“¿Ã…ÿ†äË’¿AÍŽ¸'*’¿…úB”Q�‘¿ãÝØû�¿Ú#3Yùl�¿¨ô"Y§É�¿Ô)>șĎ¿¹þ‰<Ê�¿eX ÚŒ¿9ìc(„ó‹¿_ù¬3‹¿`¾ô§AŠ¿í¦˜}vu‰¿,Cû�:±ˆ¿ÀŽ–ä”ô‡¿�AâJ+?‡¿!”Ód¨�†¿íP.Y»è…¿“Cp�G…¿û ut«„¿<¯�7�„¿µHþ– …ƒ¿ø¥³«ðù‚¿ÖáĶÂs‚¿Î”ó^ò�¿N¸–l�u�¿Î3¼Ò$ý€¿ê:SW숀¿:™Fˆ¹€¿ ¹]ÂX¿-ì]t‡~¿â}7Õ:½}¿øg!NËù|¿OÁ4àÞ<|¿ÑÊQ2†{¿T×�[„Õz¿ü¦—˜*z¿¨Ò>4…y¿&¤ ‹åx¿K:L%Jx¿ü¹ ½´w¿˜À’g·"w¿bL¤å•v¿ºíAdo v¿¿—ŽH,‰u¿—®JVóu¿q#¿÷�Œt¿�Gt¿àyø Ÿs¿ØjšFŠ-s¿±¡·Þa¿r¿QäÎsTr¿J™Ñt¢ìq¿1M›mчq¿Jp†å%q¿8®ÄÆp¿' çUjp¿°LÉ:�p¿V&EZ_ro¿©Iˆa–Èn¿ž(8}#n¿ü¸f=ë‚m¿H…o¹æl¿õË VÂNl¿Ýß?÷áºk¿ƒ²Ñ¼õ*k¿\ÂkÜžj¿‘J®vj¿G…Ìö£‘i¿r�Hi¿E·rG’h¿Ýæ9D…h¿ÿÜ[µçŸg¿n`åoU+g¿Î N¶¹f¿Á„ÇòJf¿ïIuâòÞe¿„A¨¢ue¿FöGdìe¿½GÌÖ»ªd¿*uÀyýHd¿ JËyžéc¿]„ЮŒŒc¿%Áó”¶1c¿„íE Ùb¿Z4®rz‚b¿K]ô-b¿”1ÓiÛa¿ G�'ÌŠa¿brM. ˜ù?Q¿\—]ZQ¿v§>ÝÅP¿Ôâž|ŠP¿³a¸²1PP¿¢»ø0öP¿æ¹¶ø‡½O¿„® C*OO¿Æóy«ÇâN¿†>RëTxN¿éd7ÇN¿ ÒÍQ©M¿ÒÑy_/DM¿kš2áL¿~‹h~®L¿[ÍýþL¿®ŸPjöÁK¿‹¦VfŽeK¿È¥È ½ K¿×ò]îy±J¿f\¼YJ¿€û–-|J¿(PŠ[±®I¿˜?T[I¿{Tx¯\ I¿•âXÂøH¿n†¡‚iH¿ On�H¿Ý¸ èÎG¿ÅÜ*"‚ƒG¿g+Ò!X9G¿!÷ŸcðF¿G(‘Zž¨F¿Åç*6bF¿¥×Í<‰F¿Pøœ-ØE¿2K›¨é”E¿S Ô·RE¿fMwµ’E¿@Æ+uÑD¿‡‰�“Y’D¿‡‰�“Y’D¿SNODhp�p°rÐN (sXTREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿHEAPXÐrP�p°r @__uuid$ @ __baseline ?@4 4ÿ H __curvestyle 8__shade ?@4 4ÿ H __format P &__showlabelFALSETRUE 8 __number  0 __dict ØKøM H annotations @xlabel @xunit @ylabel @yunit P &autoscaleFALSETRUE P &xscalelogFALSETRUE H xscalemin H xscalemax P &yscalelogFALSETRUE H yscalemin H yscalemax|`TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÀœHEAPX ¸{xydatametadata8 £Ð @title (ôô ?@4 4ÿ€}@x$À¨hüœzë#ÀQÑø9õÖ#Àù9õÖoÂ#À¡¢ñsê­#ÀI îe™#Àòsê­ß„#ÀšÜæJZp#ÀBEãçÔ[#Àê­ß„OG#À“Ü!Ê2#À;ؾD#ÀãçÔ[¿ #ÀŒPÑø9õ"À4¹Í•´à"ÀÜ!Ê2/Ì"À„ŠÆÏ©·"À-óÂl$£"ÀÕ[¿ ŸŽ"À}Ä»¦z"À&-¸C”e"ÀΕ´àQ"Àvþ°}‰<"Àg­("ÀÇÏ©·~"Ào8¦Tùþ!À¡¢ñsê!À¿ ŸŽîÕ!Àhr›+iÁ!ÀÛ—Èã¬!À¸C”e^˜!À`¬�Ùƒ!À �ŸSo!À±}‰<ÎZ!ÀYæ…ÙHF!ÀO‚vÃ1!Àª·~>!ÀR {°¸!ÀúˆwM3ô À£ñsê­ß ÀKZp‡(Ë ÀóÂl$£¶ Àœ+iÁ¢ ÀD”e^˜� Àìüaûy À”e^˜�d À=ÎZ5P Àå6WÒ‚; À�ŸSoý& À5P x À¼á˜RåûÀ ³‘ŒÚÒÀ\„ŠÆÏ©À­UƒÅ€Àþ&|:ºWÀNøtt¯.ÀžÉm®¤Àïšfè™ÜÀ@l_"�³À�=X\„ŠÀáQ–yaÀ2àIÐn8À‚±B dÀÒ‚;DYæÀ#T4~N½Àt%-¸C”ÀÄö%ò8kÀÈ,.BÀe™f#À¶j ðÀ< Ú ÇÀW žÀ¨ÞúMøtÀø¯ó‡íKÀH�ìÁâ"À™Råû×ùÀê#Þ5ÍÐÀ:õÖo§ÀŠÆÏ©·~ÀÛ—Èã¬UÀ,iÁ¢,À|:ºW—ÀÍ ³‘ŒÚÀÝ«Ë�±Àn®¤wˆÀ¾�?l_ÀQ–ya6À`"�³V À°ó‡íKäÀÅ€'A»ÀQ–ya6’À¢gr›+iÀò8kÕ @ÀC dÀ”Û\I îÀä¬UƒÅÀ4~N½õ›À…OG÷êrÀÖ @1àIÀ&ò8kÕ ÀwÃ1¥Ê÷ÀÇ”*ß¿ÎÀf#µ¥Àh7Sª|À¹�ŸSÀ Ú Ç”*ÀZ«ŠÀ«|ÿ:ØÀûMøtt¯ÀLñ®i†Àœðéè^]ÀíÁâ"T4À=“Û\I ÀŽdÔ–>âÀÞ5ÍÐ3¹À/Æ )�ÀؾDgÀЩ·~>À!{°¸ÀqL©òýëÀ¢,óÂÀïšfè™ÀcÀ“ ÝpÀ³‘ŒÚÒGÀc…ÈÀT4~N½õÀ¥wˆ²ÌÀõÖo§£ÀF¨hüœzÀ—ya6’QÀçJZp‡(À8Sª|ÿÀˆíKäqÖÀÙ¾Dg­À)�=X\„Àza6’Q[ÀÊ2/ÌF2À(< ÀkÕ @1àÀ¼¦z&·À x´ŽÀ]I îeÀ®(<ÀþëüaûÀO½õ›ðéÀŸŽîÕåÀÀð_çÛ—À@1àIÐnÀ‘ÙƒÅEÀáÓѽºÀdJ•ï_çÀí†cJ•À¦�x×4CÀH2jKñÀèÔ[¿ ŸÀŠwM3ôLÀ*?§Þú À̼0ɨ Àl_"�³V Àž À°¤wˆ² ÀPG÷êr` Àòéè^] À’ŒÚÒG¼ À4/ÌF2j ÀÔѽº Àvt¯.Æ À¡¢ñs À¸¹’Ü! ÀX\„ŠÆÏ Àúþuþ°} Àœ¡gr›+ ÀÝ«ËÀ¶àQ–yÀVƒÅ€'Àø%ò8kÕÀ˜Èã¬UƒÀ:kÕ @1À¸Ž)U¾ÿ¿ø`q*ÿ¿8¦Tùþuþ¿€ë7áÓÑý¿À0ɨ-ý¿vþ°}‰ü¿@»á˜Råû¿ˆÅ€'Aû¿ÈE¨hüœú¿‹‹PÑøù¿HÐn8¦Tù¿�R {°ø¿ÐZ5P ø¿ ð$h÷¿Xåû×ùÃö¿˜*ß¿Îö¿Øo§£{õ¿µ¥�x×ô¿`úˆwM3ô¿ ?l_"�ó¿à„OG÷êò¿ Ê2/ÌFò¿h¡¢ñ¿¨Tùþuþð¿è™ÜæJZð¿`¾�?lï¿àHFmé#î¿`Ó =“Ûì¿à]Ó =“ë¿pè™ÜæJê¿ðr`¬�é¿pý&|:ºç¿ð‡íKäqæ¿€´Ž)å¿�zë7áã¿€'A»á˜â¿²‹‹Pá¿�<ÎZ5à¿ Ž)U¾Ý¿ £¶ôïÚ¿@¸C”e^Ø¿@ÍÐ3¹ÍÕ¿@â]Ó =Ó¿@÷êr`¬Ð¿Àð$h7Ì¿ÀB dÇ¿Àl$£¶ôÁ¿.}Ä»¦¹¿c…È®¿X—c…”¿X—c…”?c…È®?€-}Ä»¦¹?Àl$£¶ôÁ?ÀB dÇ?€ð$h7Ì?@÷êr`¬Ð?@â]Ó =Ó? ÍÐ3¹ÍÕ? ¸C”e^Ø? £¶ôïÚ? Ž)U¾Ý?€<ÎZ5à?²‹‹Pá?€'A»á˜â?�zë7áã?p´Ž)å?ð‡íKäqæ?pý&|:ºç?àr`¬�é?`è™ÜæJê?à]Ó =“ë?`Ó =“Ûì?ÐHFmé#î?P¾�?lï?è™ÜæJZð?¨Tùþuþð?`¡¢ñ? Ê2/ÌFò?à„OG÷êò?˜?l_"�ó?XúˆwM3ô?µ¥�x×ô?Øo§£{õ?�*ß¿Îö?Påû×ùÃö? ð$h÷?ÐZ5P ø?ˆR {°ø?HÐn8¦Tù?‹‹PÑøù?ÀE¨hüœú?€Å€'Aû?@»á˜Råû?vþ°}‰ü?¸0ɨ-ý?xë7áÓÑý?8¦Tùþuþ?ø`q*ÿ?°Ž)U¾ÿ?8kÕ @1@˜Èã¬Uƒ@ô%ò8kÕ@TƒÅ€'@´àQ–y@>Ý«Ë@p›+iÁ@Ðø9õÖo@0VH�ìÁ@�³V @ìe™f@Lns%-¸@¬Ë�±B @)�=X\@h†žÉm®@Èã¬Uƒ@(A»á˜R@„žÉm®¤@äû×ùÃö@DYæ…ÙH@ ¶ôïš@ží@`q*?@Àζ/‘@,.BEã@|‰<ÎZ5@ÜæJZp‡@@~ؾDg@.Æ )�@Þ5ÍÐ3¹@ŒdÔ–>â@<“Û\I @ìÁâ"T4@œðéè^]@Jñ®i†@úMøtt¯@ª|ÿ:Ø@Z«Š@Ú Ç”*@¸�ŸS@h7Sª|@f#µ¥@Æ”*ß¿Î@vÃ1¥Ê÷@$ò8kÕ @Ô @1àI@„OG÷êr@4~N½õ›@ä¬UƒÅ@”Û\I î@@ d@ð8kÕ @@ gr›+i@P–ya6’@Å€'A»@°ó‡íKä@`"�³V @Q–ya6@¼�?l_@l®¤wˆ@Ý«Ë�±@Ì ³‘ŒÚ@|:ºW—@,iÁ¢,@Ü—Èã¬U@ˆÆÏ©·~@8õÖo§@è#Þ5ÍÐ@˜Råû×ù@H�ìÁâ"@ø¯ó‡íK@¨ÞúMøt@T ž@< Ú Ç@´j ð@d™f#@È,.B@Äö%ò8k@t%-¸C”@$T4~N½@Ђ;DYæ@€±B d@0àIÐn8@àQ–ya@�=X\„Š@@l_"�³@ðšfè™Ü@œÉm®¤@Løtt¯.@ü&|:ºW@¬UƒÅ€@\„ŠÆÏ©@ ³‘ŒÚÒ@¼á˜Råû@4P x @ŒŸSoý& @ä6WÒ‚; @<ÎZ5P @”e^˜�d @ìüaûy @D”e^˜� @œ+iÁ¢ @òÂl$£¶ @JZp‡(Ë @¢ñsê­ß @úˆwM3ô @R {°¸!@ª·~>!@O‚vÃ1!@Xæ…ÙHF!@°}‰<ÎZ!@�ŸSo!@`¬�Ùƒ!@¸C”e^˜!@Û—Èã¬!@hr›+iÁ!@¾ ŸŽîÕ!@¡¢ñsê!@n8¦Tùþ!@ÆÏ©·~"@g­("@vþ°}‰<"@Ε´àQ"@&-¸C”e"@|Ä»¦z"@Ô[¿ ŸŽ"@,óÂl$£"@„ŠÆÏ©·"@Ü!Ê2/Ì"@4¹Í•´à"@ŒPÑø9õ"@âçÔ[¿ #@:ؾD#@’Ü!Ê2#@ê­ß„OG#@BEãçÔ[#@šÜæJZp#@òsê­ß„#@H îe™#@ ¢ñsê­#@ø9õÖoÂ#@PÑø9õÖ#@¨hüœzë#@$@5ß—;?­‰aËÑD?T"A„êE?]ùfSE?xO‘E•E?…{ ÖŠØE?µ¯¥ZèF?;MƒDcbF?`‘e©F?Ë´ÈðF?áüK¿9G?·‘oëƒG?\øˆSÏG?ý¥+þH?èÿòiH?¸éÐ(6¹H?‘'O�Ñ I?9L _Ë[I?„.¿3+¯I?ýt½¡øJ?ÀUY};ZJ?äiÎû±J?qjÔÑA K?˜N%ûfK?µ7ö€ÂK?M¨ç¨‹ L?x¦Ý4?€L?]R\ù¤áL?8¿(•ÆDM?—­è­©M?e'eN?µ‡VŠöxN?? GólãN?$ÉMÓOO?åŸ;ß4¾O?Ã( NP?Ì¥†+ŒPP?Ÿà/ÙŠP?ù°ö;ÆP?áóºQ?|ªÍ\@Q?“Û(U(Q?c8Ê�$¿Q?so·XR?Ó= 5ÌBR?‡¹Á«††R?z�Æõ�ËR?÷ô)'ðS?£ÂÓ�¯YS?× †½Ö¢S?ùŠõ}níS?%Èöà9T?£ñÀ:‡T?…@G&5ÖT?(ô©‡ì&U?T꿎DyU?Ýì¹¹GÍU?øÔà×#V?õ»o {zV?Gw‹ÑÁÓV?™³Xûà.W?´2»ä‹W?[¸ÿ¢ÙêW?䱨ÌKX?3à)Ë®X?cŽïâY?O/2"{Y?ü[V�—äY?›®ÄTRPZ?„øa¾Z?Y¹­§Ö.[?G¨ Á¡[?™ê±N2\?t_7<�\?�£ñ ]?=ƒæc‡]?å‘0¨^?,œŸ/ÒŠ^?høGÌö_?Xc­¡+š_?+ÿŠƒC`?VÖh [`?yNûZ‡¤`?µ¢;εï`?}-Ë.¨Pl?}^V"èl?�‹¡`„m?K°�ÿ$n?ÃÔQQ&Ên?È­Êeýso?güRšWp?}÷ï3kp?xÑÙ³ªÇp?ä³àÓ&q?ÕºIxȈq?1Vp�¢íq?Ï`}Ur?ìbgQuÀr?Ícî ¨.s?}à‚4 s?Msh;t?I£‡Ý�t?ÌÍ22? u?P …Šu?;›Ñ�Õv?<þÒEY—v?›—kc:$w?ô€�%¥µw?3""ÜÇKx?o*Óæx?˜‡˜uù†y?ûí¼ep,z?ü•,§o×z?!\¿1ˆ{?øû7ô>|?»7/÷û|?X›Ï*~¿}?d¶…Љ~?kòæ˜8[?7� Z€?ûWÇ�CŠ€?gb72‹þ€?¡O¢»w�?R4¥æó�?%.r[u‚?}EÒý›û‚?ï‰~߆ƒ?O[7Á`„?<¿]­„?.:8ÊI…?7¸ÐÄÓê…?ä LXÚ’†?Ìn1xA‡?)±&@þö‡?óÒ¸ûÁ³ˆ?Óâ‹«x‰?Ý~à´pDŠ?HëÎí‹?ïçõ•ö‹?ÁþÔ”CÝŒ?Xs Í�?e´¶ã)ÈŽ?"í›fÍ�?ßÕàôñn�?E«Æìý�?dŽ:·€‘‘?oð›t,’?¤løPÎ’?ï”tw“?í:äþC(”?!µ%é)á”?àR’—¢•?Áƒ‡1m–?9²iò@—?ÕÊÃÃæ˜?…Ùì/r™?kŽ-û™?O<=»úš?Í“ô²Çœ?‘Ê �?dMWñDGž?5OLÃF}Ÿ?a™eua ?«²½ÕŒ ¡?P0¸eÀ¡?‚²Oý€}¢?ëóv#kD£?ŸV*L¶¤?¡?Ø´ýñ¤?©\fåÙ¥?oPƒ{Φ?ÀåHZSϧ?óž2ÛOÞ¨?gÆYÙû©?·9æ¡Â(«?,vbÁçe¬?j+™-´­?8°<�¯?IW~ëC°?{Ÿ�Ž”±?z0W»Õ±?DLl ß®²?ÌY5€“³?p{ª„´?  û¿)�µ?�û‹¶?·ã ÔT¢·?ß=(7ÃÆ¸?$r(×ù¹?Ìèñ}9»?‚…²ö‡¼?p+èy[ä½?^ªœINN¿?Ô]B0—bÀ?æ´/ø$Á?~µÆÞÆêÁ?'¥ˆIùµÂ?Aß²?t„Ã?˧Ãï¿TÄ?PÎŒ#%Å?¨†rŸóÅ?Ç‚é+¼Æ?ë«/.ï|Ç?(ô8Þ£1È?Hã ˆÕÈ?Á@UdÉ?YqسjÙÉ?/>éD£.Ê?3C(†p]Ê?¼…~?_Ê?Q�³€b-Ê?áˆÿ£NÁÉ?ïn€w!É?ۙö5È?âQÆËsüÆ? ¬6²uÅ?S�SÃ?CÆÝœ~Á? ÈÛ=(¾?YVª��ϸ?1ì 5Ù³?_æˆ)Å©?yš­Çš?àkRÎ$œG<'š­Çš¿Úäˆ)Å©¿lì 5Ù³¿ZVª��ϸ¿òÇÛ=(¾¿mCÆÝœ~Á¿P�SÿŬ6²uÅ¿âQÆËsüÆ¿šÃ¶5È¿ïn€w!ɿوÿ£NÁÉ¿O�³€b-ʿÅ~?_Ê¿1C(†p]Ê¿0>éD£.Ê¿[qسjÙÉ¿Å@UdÉ¿Eã ˆÕÈ¿4ô8Þ£1È¿ô«/.ï|ǿ̂é+¼Æ¿¹†rŸóÅ¿SÎŒ#%ſȧÃï¿TÄ¿>ß²?t„ÿ ¥ˆIùµÂ¿ŒµÆÞÆêÁ¿ë´/ø$Á¿Ú]B0—bÀ¿rªœINN¿¿v+èy[使y…²ö‡¼¿Ìèñ}9»¿8r(×ù¹¿ñ=(7ÃÆ¸¿µã ÔT¢·¿�û‹¶¿¤ û¿)�µ¿x{ª„´¿ÌY5€“³¿KLl ß®²¿{0W»Õ±¿„Ÿ�Ž”±¿IW~ëC°¿D°<�¯¿j+™-´­¿3vbÁçe¬¿À9æ¡Â(«¿mÆYÙû©¿Ÿ2ÛOÞ¨¿ÇåHZSϧ¿xPƒ{Φ¿©\fåÙ¥¿§?Ø´ýñ¤¿¨V*L¶¤¿ñóv#kD£¿²Oý€}¢¿D0¸eÀ¡¿¥²½ÕŒ ¡¿d™eua ¿?OLÃF}Ÿ¿…MWñDGž¿'‘Ê �¿Ý“ô²Çœ¿a<=»úš¿kŽ-û™¿ŒÙì/r™¿ÀÊÃÃæ˜¿;²iò@—¿Áƒ‡1m–¿$àR’—¢•¿(µ%é)ᔿí:äþC(”¿ï”tw“¿ ¤løPÎ’¿-oð›t,’¿lŽ:·€‘‘¿J«Æìý�¿äÕàôñn�¿"í›fÍ�¿g´¶ã)ÈŽ¿Ps Í�¿ÈþÔ”CÝŒ¿ÿçõ•ö‹¿SëÎí‹¿ä~à´pDŠ¿Óâ‹«x‰¿óÒ¸ûÁ³ˆ¿-±&@þö‡¿)Ìn1xA‡¿ä LXÚ’†¿5¸ÐÄÓê…¿):8ÊI…¿C¿]­„¿O[7Á`„¿ø‰~߆ƒ¿�EÒý›û‚¿4.r[u‚¿Q4¥æó�¿£O¢»w�¿Wb72‹þ€¿ûWÇ�CŠ€¿8� Z€¿�òæ˜8[¿]¶…Љ~¿X›Ï*~¿}¿¸7/÷û|¿ü7ô>|¿'!\¿1ˆ{¿ –,§o×z¿ýí¼ep,z¿˜‡˜uù†y¿]*Óæx¿$""ÜÇKx¿õ€�%¥µw¿¨—kc:$w¿<þÒEY—v¿I›Ñ�Õv¿[ …Šu¿íÍ22? u¿7£‡Ý�t¿Osh;t¿qà‚4 s¿Ícî ¨.s¿óbgQuÀr¿Ï`}Ur¿DVp�¢íq¿ÕºIxȈq¿$ä³àÓ&q¿uÑÙ³ªÇp¿}÷ï3kp¿WüRšWp¿Ë­Êeýso¿àÔQQ&Ên¿l°�ÿ$n¿ ‹¡`„m¿h^V"èl¿»û>Pl¿qæX3¼k¿Kú› <,k¿1sk j¿d|§j¿ù/ˆË’i¿‹�L¨fi¿¼ZÆl\“h¿ )4y‘h¿ma~xë g¿h…Q,g¿©¬SÜ©ºf¿€úØWÞKf¿gà×ße¿“€ó¤€ve¿Ø¡Ãe¿º;œ‘Œ«d¿¥WîÇId¿ÛkÉàbêc¿„Ú/?K�c¿1C;ƒo2c¿†(…ľÙb¿+š÷±(ƒb¿Õô¸‹�.b¿û]Üa¿€Ä^¸k‹a¿e-Ë.¨!ÀR {°¸!ÀúˆwM3ô À£ñsê­ß ÀKZp‡(Ë ÀóÂl$£¶ Àœ+iÁ¢ ÀD”e^˜� Àìüaûy À”e^˜�d À=ÎZ5P Àå6WÒ‚; À�ŸSoý& À5P x À¼á˜RåûÀ ³‘ŒÚÒÀ\„ŠÆÏ©À­UƒÅ€Àþ&|:ºWÀNøtt¯.ÀžÉm®¤Àïšfè™ÜÀ@l_"�³À�=X\„ŠÀáQ–yaÀ2àIÐn8À‚±B dÀÒ‚;DYæÀ#T4~N½Àt%-¸C”ÀÄö%ò8kÀÈ,.BÀe™f#À¶j ðÀ< Ú ÇÀW žÀ¨ÞúMøtÀø¯ó‡íKÀH�ìÁâ"À™Råû×ùÀê#Þ5ÍÐÀ:õÖo§ÀŠÆÏ©·~ÀÛ—Èã¬UÀ,iÁ¢,À|:ºW—ÀÍ ³‘ŒÚÀÝ«Ë�±Àn®¤wˆÀ¾�?l_ÀQ–ya6À`"�³V À°ó‡íKäÀÅ€'A»ÀQ–ya6’À¢gr›+iÀò8kÕ @ÀC dÀ”Û\I îÀä¬UƒÅÀ4~N½õ›À…OG÷êrÀÖ @1àIÀ&ò8kÕ ÀwÃ1¥Ê÷ÀÇ”*ß¿ÎÀf#µ¥Àh7Sª|À¹�ŸSÀ Ú Ç”*ÀZ«ŠÀ«|ÿ:ØÀûMøtt¯ÀLñ®i†Àœðéè^]ÀíÁâ"T4À=“Û\I ÀŽdÔ–>âÀÞ5ÍÐ3¹À/Æ )�ÀؾDgÀЩ·~>À!{°¸ÀqL©òýëÀ¢,óÂÀïšfè™ÀcÀ“ ÝpÀ³‘ŒÚÒGÀc…ÈÀT4~N½õÀ¥wˆ²ÌÀõÖo§£ÀF¨hüœzÀ—ya6’QÀçJZp‡(À8Sª|ÿÀˆíKäqÖÀÙ¾Dg­À)�=X\„Àza6’Q[ÀÊ2/ÌF2À(< ÀkÕ @1àÀ¼¦z&·À x´ŽÀ]I îeÀ®(<ÀþëüaûÀO½õ›ðéÀŸŽîÕåÀÀð_çÛ—À@1àIÐnÀ‘ÙƒÅEÀáÓѽºÀdJ•ï_çÀí†cJ•À¦�x×4CÀH2jKñÀèÔ[¿ ŸÀŠwM3ôLÀ*?§Þú À̼0ɨ Àl_"�³V Àž À°¤wˆ² ÀPG÷êr` Àòéè^] À’ŒÚÒG¼ À4/ÌF2j ÀÔѽº Àvt¯.Æ À¡¢ñs À¸¹’Ü! ÀX\„ŠÆÏ Àúþuþ°} Àœ¡gr›+ ÀÝ«ËÀ¶àQ–yÀVƒÅ€'Àø%ò8kÕÀ˜Èã¬UƒÀ:kÕ @1À¸Ž)U¾ÿ¿ø`q*ÿ¿8¦Tùþuþ¿€ë7áÓÑý¿À0ɨ-ý¿vþ°}‰ü¿@»á˜Råû¿ˆÅ€'Aû¿ÈE¨hüœú¿‹‹PÑøù¿HÐn8¦Tù¿�R {°ø¿ÐZ5P ø¿ ð$h÷¿Xåû×ùÃö¿˜*ß¿Îö¿Øo§£{õ¿µ¥�x×ô¿`úˆwM3ô¿ ?l_"�ó¿à„OG÷êò¿ Ê2/ÌFò¿h¡¢ñ¿¨Tùþuþð¿è™ÜæJZð¿`¾�?lï¿àHFmé#î¿`Ó =“Ûì¿à]Ó =“ë¿pè™ÜæJê¿ðr`¬�é¿pý&|:ºç¿ð‡íKäqæ¿€´Ž)å¿�zë7áã¿€'A»á˜â¿²‹‹Pá¿�<ÎZ5à¿ Ž)U¾Ý¿ £¶ôïÚ¿@¸C”e^Ø¿@ÍÐ3¹ÍÕ¿@â]Ó =Ó¿@÷êr`¬Ð¿Àð$h7Ì¿ÀB dÇ¿Àl$£¶ôÁ¿.}Ä»¦¹¿c…È®¿X—c…”¿X—c…”?c…È®?€-}Ä»¦¹?Àl$£¶ôÁ?ÀB dÇ?€ð$h7Ì?@÷êr`¬Ð?@â]Ó =Ó? ÍÐ3¹ÍÕ? ¸C”e^Ø? £¶ôïÚ? Ž)U¾Ý?€<ÎZ5à?²‹‹Pá?€'A»á˜â?�zë7áã?p´Ž)å?ð‡íKäqæ?pý&|:ºç?àr`¬�é?`è™ÜæJê?à]Ó =“ë?`Ó =“Ûì?ÐHFmé#î?P¾�?lï?è™ÜæJZð?¨Tùþuþð?`¡¢ñ? Ê2/ÌFò?à„OG÷êò?˜?l_"�ó?XúˆwM3ô?µ¥�x×ô?Øo§£{õ?�*ß¿Îö?Påû×ùÃö? ð$h÷?ÐZ5P ø?ˆR {°ø?HÐn8¦Tù?‹‹PÑøù?ÀE¨hüœú?€Å€'Aû?@»á˜Råû?vþ°}‰ü?¸0ɨ-ý?xë7áÓÑý?8¦Tùþuþ?ø`q*ÿ?°Ž)U¾ÿ?8kÕ @1@˜Èã¬Uƒ@ô%ò8kÕ@TƒÅ€'@´àQ–y@>Ý«Ë@p›+iÁ@Ðø9õÖo@0VH�ìÁ@�³V @ìe™f@Lns%-¸@¬Ë�±B @)�=X\@h†žÉm®@Èã¬Uƒ@(A»á˜R@„žÉm®¤@äû×ùÃö@DYæ…ÙH@ ¶ôïš@ží@`q*?@Àζ/‘@,.BEã@|‰<ÎZ5@ÜæJZp‡@@~ؾDg@.Æ )�@Þ5ÍÐ3¹@ŒdÔ–>â@<“Û\I @ìÁâ"T4@œðéè^]@Jñ®i†@úMøtt¯@ª|ÿ:Ø@Z«Š@Ú Ç”*@¸�ŸS@h7Sª|@f#µ¥@Æ”*ß¿Î@vÃ1¥Ê÷@$ò8kÕ @Ô @1àI@„OG÷êr@4~N½õ›@ä¬UƒÅ@”Û\I î@@ d@ð8kÕ @@ gr›+i@P–ya6’@Å€'A»@°ó‡íKä@`"�³V @Q–ya6@¼�?l_@l®¤wˆ@Ý«Ë�±@Ì ³‘ŒÚ@|:ºW—@,iÁ¢,@Ü—Èã¬U@ˆÆÏ©·~@8õÖo§@è#Þ5ÍÐ@˜Råû×ù@H�ìÁâ"@ø¯ó‡íK@¨ÞúMøt@T ž@< Ú Ç@´j ð@d™f#@È,.B@Äö%ò8k@t%-¸C”@$T4~N½@Ђ;DYæ@€±B d@0àIÐn8@àQ–ya@�=X\„Š@@l_"�³@ðšfè™Ü@œÉm®¤@Løtt¯.@ü&|:ºW@¬UƒÅ€@\„ŠÆÏ©@ ³‘ŒÚÒ@¼á˜Råû@4P x @ŒŸSoý& @ä6WÒ‚; @<ÎZ5P @”e^˜�d @ìüaûy @D”e^˜� @œ+iÁ¢ @òÂl$£¶ @JZp‡(Ë @¢ñsê­ß @úˆwM3ô @R {°¸!@ª·~>!@O‚vÃ1!@Xæ…ÙHF!@°}‰<ÎZ!@�ŸSo!@`¬�Ùƒ!@¸C”e^˜!@Û—Èã¬!@hr›+iÁ!@¾ ŸŽîÕ!@¡¢ñsê!@n8¦Tùþ!@ÆÏ©·~"@g­("@vþ°}‰<"@Ε´àQ"@&-¸C”e"@|Ä»¦z"@Ô[¿ ŸŽ"@,óÂl$£"@„ŠÆÏ©·"@Ü!Ê2/Ì"@4¹Í•´à"@ŒPÑø9õ"@âçÔ[¿ #@:ؾD#@’Ü!Ê2#@ê­ß„OG#@BEãçÔ[#@šÜæJZp#@òsê­ß„#@H îe™#@ ¢ñsê­#@ø9õÖoÂ#@PÑø9õÖ#@¨hüœzë#@$@‡‰�“Y’D?Æ+uÑD?fMwµ’E?}S Ô·RE?2K›¨é”E?¸Oøœ-ØE?¥×Í<‰F?Åç*6bF?G(‘Zž¨F?÷ŸcðF?+Ò!X9G?ŒÝ*"‚ƒG?|¸ èÎG? On�H?n†¡‚iH?•âXÂøH?$Tx¯\ I?û?T[I?ŒPŠ[±®I?û–-|J?f\¼YJ?×ò]îy±J?È¥È ½ K?µ¦VfŽeK?JŸPjöÁK?"ÎýþL?~‹h~®L?Ê™2áL?ÒÑy_/DM? ÒÍQ©M?éd7ÇN?;?RëTxN?þòy«ÇâN?„® C*OO?é¹¶ø‡½O?¢»ø0öP?³a¸²1PP?Ôâž|ŠP?§>ÝÅP?\—]ZQ?=Ä>˜ù?Q?Ü£žÉÂ~Q?Ÿ*��¼¾Q?­Ú"KîÿQ? A�=_BR?À�k†R?ÉML¯ËR?æÅ›{S?3Ë•ª7YS?Ò¶Ié[¢S?‚¤²¤ðìS?˜Eäëþ8T?TnM�†T?ü ²­ÕT??2w®a&U?;‰u6¶xU?�SǵÌU?Ý9e/k"V?‰Aê�áyV?y¸ÿd$ÓV?Òk¸~?.W?€öQ?‹W?ËÒ‹¤/êW?Ã_!6KX?îzh®X?éU(+Y?-X,‡ezY?ÖßçÕãY?ðÛÇi‹OZ? Pw­•½Z?‘êóÐ.[?qñ�xé [?ùfÓT\?HÕ XŽ\?Ó�¦2 ]?�û1zs†]?Ø-±±^?NŒNÔ‰^?]ßxñ_?ŒBªÜ™_?�f¿G¹`?­uŒÔ�Z`?Ʀîõ£`?÷Îv6ï`?brM. Iˆa–Èn?º&EZ_ro?°LÉ:�p?) çUjp?8®ÄÆp? Jp†å%q?M›mчq?V™Ñt¢ìq?äÎsTr?¡·Þa¿r?ãjšFŠ-s?Äyø Ÿs?ÏGt?T#¿÷�Œt?¶®JVóu?[—ŽH,‰u?ßíAdo v?tL¤å•v?aÀ’g·"w?ü¹ ½´w?:L%Jx?¤ ‹åx?²Ò>4…y?ü¦—˜*z?B×�[„Õz?µÊQ2†{?OÁ4àÞ<|?åg!NËù|?þ}7Õ:½}?$ì]t‡~? ¹]ÂX? ™Fˆ¹€?;SW숀?Ô3¼Ò$ý€?N¸–l�u�?”ó^ò�?æáĶÂs‚?Ç¥³«ðù‚?ËHþ– …ƒ?;¯�7�„?æ ut«„?“Cp�G…?Q.Y»è…?”Ód¨�†?™AâJ+?‡?ÀŽ–ä”ô‡?$Cû�:±ˆ?ö¦˜}vu‰?`¾ô§AŠ?@ù¬3‹?:ìc(„ó‹?QX ÚŒ?¹þ‰<Ê�?ÿ)>șĎ?xô"Y§É�?Ü#3Yùl�?…ãÝØû�?qúB”Q�‘?5ÍŽ¸'*’?ª…ÿ†äË’?p™·©æt“?Ž’%”?<úÞÄRÞ”? |h˜Ÿ•?ž�‰Ûi–?0å|¢›=—?t‰û`˜?ÞñÒ—¸™??øæ=÷™?%LG+’öš?‰ýt¥aœ?¼q!Hc�?DÉ\YBž?l2/xŸ?˜;_´^ ?U÷¬¸¢ ¡?å‹iO½¡?ƒ´ï$=z¢?î}siö@£?R©Ü ¤?�Ü Ÿî¤?ò8}£ÌÕ¥?{åŽðÄɦ?àÒ}޽ʧ?âøÍ?wÙ¨?Lsºö©?>I÷WZ#«?ÒRo3`¬?tÆÀÑ)®­?%Rß+¯?�’ž—@°?ï5¾‰±?ð| Ò±?ŽEËh «²?M‰ý˜��³?W•Džô´?«SÂÆÜ|µ?Ý3êÚ«†¶?kƒ‹È�·?( õŠÂ¸?nÿÇü2õ¹?_À©®ê5»?¶󵄼?ï3µðmá½?M&梵K¿?³á(ÈvaÀ?dÑsÏ#Á?)úrêÁ?®uq¸oµÂ?Ó6þ(„Ã?ç¼bí½TÄ?Óæ¨­_%Å?Ò€g©ØóÅ?�‡Y€½Æ?!ÇáÃ/Ç?‚þ¤“75È?¡uÕ÷XÛÈ?ƒý½ÂlÉ? O^äÉ?g_i;Ê?dCpllÊ?+^Ö!|pÊ?uéÕ@Ê?ĦÎvÕÖÉ?Ýtº‹@,É?9i i ;È?¤?© ¥Ç?Â'†¤ŒxÅ?¾V Ò‡¢Ã?—£òô€Á?¤.Y*¾?ŠeѸ?é8.@å³?‹B”{Æ©?h*š?µg*š¿ü@”{Æ©¿(9.@峿ŠeѸ¿í¤.Y*¾¿„£òô€Á¿¾V Ò‡¢Ã¿p'†¤ŒxÅ¿¤?© ¥Ç¿hi i ;È¿Ýtº‹@,É¿©¦ÎvÕÖÉ¿uéÕ@Ê¿>^Ö!|pÊ¿dCpllÊ¿Q_i;Ê¿:O^äÉ¿ƒý½ÂlÉ¿’uÕ÷XÛÈ¿›þ¤“75È¿'ÇáÃ/Ç¿�‡Y€½Æ¿ë€g©ØóÅ¿Óæ¨­_%Å¿ã¼bí½TÄ¿Ó6þ(„ÿ�uq¸oµÂ¿6)úrêÁ¿dÑsÏ#Á¿¸á(ÈvaÀ¿f&梵K¿¿ð3µðm὿¶󵄼¿<À©®ê5»¿¹ÿÇü2õ¹¿ õŠÂ¸¿kƒ‹È�·¿ã3êÚ«†¶¿²SÂÆÜ|µ¿W•Džô´¿a‰ý˜��³¿ŽEËh «²¿ò| Ò±¿ï5¾‰±¿¬’ž—@°¿ã$Rß+¯¿‘ÆÀÑ)®­¿ÒRo3`¬¿3I÷WZ#«¿:Lsºö©¿ÜøÍ?wÙ¨¿öÒ}޽ʧ¿‚åŽðÄɦ¿ò8}£ÌÕ¥¿—Ü Ÿl©Ü ¤¿ó}siö@£¿t´ï$=z¢¿å‹iO½¡¿C÷¬¸¢ ¡¿˜;_´^ ¿¢2/xŸ¿íCÉ\YBž¿r!Hc�¿‰ýt¥aœ¿LG+’öš¿‰øæ=÷™¿¦ñÒ—¸™¿t‰û`˜¿'å|¢›=—¿ž�‰Ûi–¿ |h˜Ÿ•¿OúÞÄRÞ”¿Ž’%”¿[™·©æt“¿Ã…ÿ†äË’¿AÍŽ¸'*’¿…úB”Q�‘¿ãÝØû�¿Ú#3Yùl�¿¨ô"Y§É�¿Ô)>șĎ¿¹þ‰<Ê�¿eX ÚŒ¿9ìc(„ó‹¿_ù¬3‹¿`¾ô§AŠ¿í¦˜}vu‰¿,Cû�:±ˆ¿ÀŽ–ä”ô‡¿�AâJ+?‡¿!”Ód¨�†¿íP.Y»è…¿“Cp�G…¿û ut«„¿<¯�7�„¿µHþ– …ƒ¿ø¥³«ðù‚¿ÖáĶÂs‚¿Î”ó^ò�¿N¸–l�u�¿Î3¼Ò$ý€¿ê:SW숀¿:™Fˆ¹€¿ ¹]ÂX¿-ì]t‡~¿â}7Õ:½}¿øg!NËù|¿OÁ4àÞ<|¿ÑÊQ2†{¿T×�[„Õz¿ü¦—˜*z¿¨Ò>4…y¿&¤ ‹åx¿K:L%Jx¿ü¹ ½´w¿˜À’g·"w¿bL¤å•v¿ºíAdo v¿¿—ŽH,‰u¿—®JVóu¿q#¿÷�Œt¿�Gt¿àyø Ÿs¿ØjšFŠ-s¿±¡·Þa¿r¿QäÎsTr¿J™Ñt¢ìq¿1M›mчq¿Jp†å%q¿8®ÄÆp¿' çUjp¿°LÉ:�p¿V&EZ_ro¿©Iˆa–Èn¿ž(8}#n¿ü¸f=ë‚m¿H…o¹æl¿õË VÂNl¿Ýß?÷áºk¿ƒ²Ñ¼õ*k¿\ÂkÜžj¿‘J®vj¿G…Ìö£‘i¿r�Hi¿E·rG’h¿Ýæ9D…h¿ÿÜ[µçŸg¿n`åoU+g¿Î N¶¹f¿Á„ÇòJf¿ïIuâòÞe¿„A¨¢ue¿FöGdìe¿½GÌÖ»ªd¿*uÀyýHd¿ JËyžéc¿]„ЮŒŒc¿%Áó”¶1c¿„íE Ùb¿Z4®rz‚b¿K]ô-b¿”1ÓiÛa¿ G�'ÌŠa¿brM. ˜ù?Q¿\—]ZQ¿v§>ÝÅP¿Ôâž|ŠP¿³a¸²1PP¿¢»ø0öP¿æ¹¶ø‡½O¿„® C*OO¿Æóy«ÇâN¿†>RëTxN¿éd7ÇN¿ ÒÍQ©M¿ÒÑy_/DM¿kš2áL¿~‹h~®L¿[ÍýþL¿®ŸPjöÁK¿‹¦VfŽeK¿È¥È ½ K¿×ò]îy±J¿f\¼YJ¿€û–-|J¿(PŠ[±®I¿˜?T[I¿{Tx¯\ I¿•âXÂøH¿n†¡‚iH¿ On�H¿Ý¸ èÎG¿ÅÜ*"‚ƒG¿g+Ò!X9G¿!÷ŸcðF¿G(‘Zž¨F¿Åç*6bF¿¥×Í<‰F¿Pøœ-ØE¿2K›¨é”E¿S Ô·RE¿fMwµ’E¿@Æ+uÑD¿‡‰�“Y’D¿‡‰�“Y’D¿SNODÐ8ÐXÒx® ÐÒXTREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿHEAPXxÒP8ÐXÒ @__uuid$, @ __baseline ?@4 4ÿ H __curvestyle- 8__shade ?@4 4ÿ H __format. P &__showlabelFALSETRUE 8 __number  0 __dict 8ªX¬ H annotations/ @xlabel0 @xunit1 @ylabel2 @yunit3 P &autoscaleFALSETRUE P &xscalelogFALSETRUE H xscalemin4 H xscalemax5 P &yscalelogFALSETRUE H yscalemin6 H yscalemax7¸Û`TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿhüHEAPX `Ûxydatametadata8ÈÐ @title8(ôô ?@4 4ÿ(Ý@x$À¨hüœzë#ÀQÑø9õÖ#Àù9õÖoÂ#À¡¢ñsê­#ÀI îe™#Àòsê­ß„#ÀšÜæJZp#ÀBEãçÔ[#Àê­ß„OG#À“Ü!Ê2#À;ؾD#ÀãçÔ[¿ #ÀŒPÑø9õ"À4¹Í•´à"ÀÜ!Ê2/Ì"À„ŠÆÏ©·"À-óÂl$£"ÀÕ[¿ ŸŽ"À}Ä»¦z"À&-¸C”e"ÀΕ´àQ"Àvþ°}‰<"Àg­("ÀÇÏ©·~"Ào8¦Tùþ!À¡¢ñsê!À¿ ŸŽîÕ!Àhr›+iÁ!ÀÛ—Èã¬!À¸C”e^˜!À`¬�Ùƒ!À �ŸSo!À±}‰<ÎZ!ÀYæ…ÙHF!ÀO‚vÃ1!Àª·~>!ÀR {°¸!ÀúˆwM3ô À£ñsê­ß ÀKZp‡(Ë ÀóÂl$£¶ Àœ+iÁ¢ ÀD”e^˜� Àìüaûy À”e^˜�d À=ÎZ5P Àå6WÒ‚; À�ŸSoý& À5P x À¼á˜RåûÀ ³‘ŒÚÒÀ\„ŠÆÏ©À­UƒÅ€Àþ&|:ºWÀNøtt¯.ÀžÉm®¤Àïšfè™ÜÀ@l_"�³À�=X\„ŠÀáQ–yaÀ2àIÐn8À‚±B dÀÒ‚;DYæÀ#T4~N½Àt%-¸C”ÀÄö%ò8kÀÈ,.BÀe™f#À¶j ðÀ< Ú ÇÀW žÀ¨ÞúMøtÀø¯ó‡íKÀH�ìÁâ"À™Råû×ùÀê#Þ5ÍÐÀ:õÖo§ÀŠÆÏ©·~ÀÛ—Èã¬UÀ,iÁ¢,À|:ºW—ÀÍ ³‘ŒÚÀÝ«Ë�±Àn®¤wˆÀ¾�?l_ÀQ–ya6À`"�³V À°ó‡íKäÀÅ€'A»ÀQ–ya6’À¢gr›+iÀò8kÕ @ÀC dÀ”Û\I îÀä¬UƒÅÀ4~N½õ›À…OG÷êrÀÖ @1àIÀ&ò8kÕ ÀwÃ1¥Ê÷ÀÇ”*ß¿ÎÀf#µ¥Àh7Sª|À¹�ŸSÀ Ú Ç”*ÀZ«ŠÀ«|ÿ:ØÀûMøtt¯ÀLñ®i†Àœðéè^]ÀíÁâ"T4À=“Û\I ÀŽdÔ–>âÀÞ5ÍÐ3¹À/Æ )�ÀؾDgÀЩ·~>À!{°¸ÀqL©òýëÀ¢,óÂÀïšfè™ÀcÀ“ ÝpÀ³‘ŒÚÒGÀc…ÈÀT4~N½õÀ¥wˆ²ÌÀõÖo§£ÀF¨hüœzÀ—ya6’QÀçJZp‡(À8Sª|ÿÀˆíKäqÖÀÙ¾Dg­À)�=X\„Àza6’Q[ÀÊ2/ÌF2À(< ÀkÕ @1àÀ¼¦z&·À x´ŽÀ]I îeÀ®(<ÀþëüaûÀO½õ›ðéÀŸŽîÕåÀÀð_çÛ—À@1àIÐnÀ‘ÙƒÅEÀáÓѽºÀdJ•ï_çÀí†cJ•À¦�x×4CÀH2jKñÀèÔ[¿ ŸÀŠwM3ôLÀ*?§Þú À̼0ɨ Àl_"�³V Àž À°¤wˆ² ÀPG÷êr` Àòéè^] À’ŒÚÒG¼ À4/ÌF2j ÀÔѽº Àvt¯.Æ À¡¢ñs À¸¹’Ü! ÀX\„ŠÆÏ Àúþuþ°} Àœ¡gr›+ ÀÝ«ËÀ¶àQ–yÀVƒÅ€'Àø%ò8kÕÀ˜Èã¬UƒÀ:kÕ @1À¸Ž)U¾ÿ¿ø`q*ÿ¿8¦Tùþuþ¿€ë7áÓÑý¿À0ɨ-ý¿vþ°}‰ü¿@»á˜Råû¿ˆÅ€'Aû¿ÈE¨hüœú¿‹‹PÑøù¿HÐn8¦Tù¿�R {°ø¿ÐZ5P ø¿ ð$h÷¿Xåû×ùÃö¿˜*ß¿Îö¿Øo§£{õ¿µ¥�x×ô¿`úˆwM3ô¿ ?l_"�ó¿à„OG÷êò¿ Ê2/ÌFò¿h¡¢ñ¿¨Tùþuþð¿è™ÜæJZð¿`¾�?lï¿àHFmé#î¿`Ó =“Ûì¿à]Ó =“ë¿pè™ÜæJê¿ðr`¬�é¿pý&|:ºç¿ð‡íKäqæ¿€´Ž)å¿�zë7áã¿€'A»á˜â¿²‹‹Pá¿�<ÎZ5à¿ Ž)U¾Ý¿ £¶ôïÚ¿@¸C”e^Ø¿@ÍÐ3¹ÍÕ¿@â]Ó =Ó¿@÷êr`¬Ð¿Àð$h7Ì¿ÀB dÇ¿Àl$£¶ôÁ¿.}Ä»¦¹¿c…È®¿X—c…”¿X—c…”?c…È®?€-}Ä»¦¹?Àl$£¶ôÁ?ÀB dÇ?€ð$h7Ì?@÷êr`¬Ð?@â]Ó =Ó? ÍÐ3¹ÍÕ? ¸C”e^Ø? £¶ôïÚ? Ž)U¾Ý?€<ÎZ5à?²‹‹Pá?€'A»á˜â?�zë7áã?p´Ž)å?ð‡íKäqæ?pý&|:ºç?àr`¬�é?`è™ÜæJê?à]Ó =“ë?`Ó =“Ûì?ÐHFmé#î?P¾�?lï?è™ÜæJZð?¨Tùþuþð?`¡¢ñ? Ê2/ÌFò?à„OG÷êò?˜?l_"�ó?XúˆwM3ô?µ¥�x×ô?Øo§£{õ?�*ß¿Îö?Påû×ùÃö? ð$h÷?ÐZ5P ø?ˆR {°ø?HÐn8¦Tù?‹‹PÑøù?ÀE¨hüœú?€Å€'Aû?@»á˜Råû?vþ°}‰ü?¸0ɨ-ý?xë7áÓÑý?8¦Tùþuþ?ø`q*ÿ?°Ž)U¾ÿ?8kÕ @1@˜Èã¬Uƒ@ô%ò8kÕ@TƒÅ€'@´àQ–y@>Ý«Ë@p›+iÁ@Ðø9õÖo@0VH�ìÁ@�³V @ìe™f@Lns%-¸@¬Ë�±B @)�=X\@h†žÉm®@Èã¬Uƒ@(A»á˜R@„žÉm®¤@äû×ùÃö@DYæ…ÙH@ ¶ôïš@ží@`q*?@Àζ/‘@,.BEã@|‰<ÎZ5@ÜæJZp‡@@~ؾDg@.Æ )�@Þ5ÍÐ3¹@ŒdÔ–>â@<“Û\I @ìÁâ"T4@œðéè^]@Jñ®i†@úMøtt¯@ª|ÿ:Ø@Z«Š@Ú Ç”*@¸�ŸS@h7Sª|@f#µ¥@Æ”*ß¿Î@vÃ1¥Ê÷@$ò8kÕ @Ô @1àI@„OG÷êr@4~N½õ›@ä¬UƒÅ@”Û\I î@@ d@ð8kÕ @@ gr›+i@P–ya6’@Å€'A»@°ó‡íKä@`"�³V @Q–ya6@¼�?l_@l®¤wˆ@Ý«Ë�±@Ì ³‘ŒÚ@|:ºW—@,iÁ¢,@Ü—Èã¬U@ˆÆÏ©·~@8õÖo§@è#Þ5ÍÐ@˜Råû×ù@H�ìÁâ"@ø¯ó‡íK@¨ÞúMøt@T ž@< Ú Ç@´j ð@d™f#@È,.B@Äö%ò8k@t%-¸C”@$T4~N½@Ђ;DYæ@€±B d@0àIÐn8@àQ–ya@�=X\„Š@@l_"�³@ðšfè™Ü@œÉm®¤@Løtt¯.@ü&|:ºW@¬UƒÅ€@\„ŠÆÏ©@ ³‘ŒÚÒ@¼á˜Råû@4P x @ŒŸSoý& @ä6WÒ‚; @<ÎZ5P @”e^˜�d @ìüaûy @D”e^˜� @œ+iÁ¢ @òÂl$£¶ @JZp‡(Ë @¢ñsê­ß @úˆwM3ô @R {°¸!@ª·~>!@O‚vÃ1!@Xæ…ÙHF!@°}‰<ÎZ!@�ŸSo!@`¬�Ùƒ!@¸C”e^˜!@Û—Èã¬!@hr›+iÁ!@¾ ŸŽîÕ!@¡¢ñsê!@n8¦Tùþ!@ÆÏ©·~"@g­("@vþ°}‰<"@Ε´àQ"@&-¸C”e"@|Ä»¦z"@Ô[¿ ŸŽ"@,óÂl$£"@„ŠÆÏ©·"@Ü!Ê2/Ì"@4¹Í•´à"@ŒPÑø9õ"@âçÔ[¿ #@:ؾD#@’Ü!Ê2#@ê­ß„OG#@BEãçÔ[#@šÜæJZp#@òsê­ß„#@H îe™#@ ¢ñsê­#@ø9õÖoÂ#@PÑø9õÖ#@¨hüœzë#@$@di*ˆš(?´Œ>èÆþ(?;,~�xe)?ĉÕ9Î)?ä�©v9*?#ˤ‚¦*?·®i\+?·XvýÞ†+?Y²Nt´ú+?ùÅolëp,?<Ì`ï’é,?ù»vuºd-?2·~éqâ-?3ý¬Éb.?þ…®™Òå.?»Du žk/?&%´Ú=ô/? Ô�6â?0?-mÞW"‡0?[^éÏ0?MZ�i@1?¿­2º2f1?ö-a¨Ê³1?(Ó,2?©u•T2?Éègˆã¦2?)Ƀû2? ÓaqR3?Û=ùˆnª3?ÍSÇtÔ4?ÁˆÄAa4??ñ‰tÄ¿4?Šó‚ñj 5?´ž¢Dƒ5?�é¿O_è5?aÌO6?Ä”òª›¹6?[ * Þ%7?d¶J餔7?eK•B8?©Æù¥z8?´# >Ëð8?ì¡·Ø]j9?´´zçÔæ9?6Š|‹Ef:?n1™Åè:?ñÒÇŸkn;?rÝDðN÷;?›Ú𤇃?Ö?䮿×>?x!—˜*v??OôyèF @?mˆøïƒ_@?ëËŒ/Ü´@?ÿ:` A?óáûV fA?r³¶.ÂA?„»mJ� B?ÆÖàá~�B?J-@úæäB?L2\ÅéJC?¾•1œ³C?ý£0ïD?^¤t}g�D?çðA.®þD?�î1sE?lëü�vêE?éð={+eF?SýÈË9ãF?:Òè—½dG?ÇÞûøÓéG?ÔG%›rH?L´d|2ÿH?©OF”º�I?-}àFU$J?ïÀ%½J?)6ÔŽPZK? ±½°ûûK?C$á¦N¢L?:x…rMM?ê笒ýM?¬»¸—Ù²N?¥¾6}wmO?—,ëÍP?Ò™k^¼yP?Ú¦=-¡ßP? xꀗHQ?­!¤»´Q?Ïb®+$R?ºcã}—R?Îïg S?ØQ5Çv‡S?·yÒST?2\_#‡T?^IºM U?'©V#3—U?iœ® Ä%V?WÃþVé¸V?Äöz¿ÏPW?à©ÒS¦íW?4ÿ(ž�X?2 †ê6Y?¶5 ÁãY?0àÂY–Z?”}ãLïN[?ƒÛFý¾ \?|ÂÊýÓ\?�}÷vŸ]?r7зr^?>E¯`tL_?b$TÈ3`?8–Î #Œ`?t™O2a?È“iú�‚a?¹|—db?ääóùæŠb?h€\Hc?Û{�¾¦c?g~hÛ�+�•?/:K$¥–?‘5Þ©×»—?c0uvâ˜?c='õÞš?†c›?#%[ƒH¿œ?¼È�/ž?tÚq /µŸ?¦üÖº¨ ?ôx pá‚¡?;ª`Åi¢?þG (^£?lTŽÐÕ`¤?FÌ?5¢r¥?¼ ïh”¦?p�¶ ǧ?p<ýu ©?Xö;‘bª?Ȩ#«LÍ«?8SV•L­?Ä!fZSá®?OK•2F°?N°¨ÏL'±?màLT²?uÅ{÷” ³?¡”4$E´?Ì~%µ?øæX7D¶?CŸ¬Ã5o·?)¿¦¸?ËMI�îç¹?lUˆœØ3»?¿¤,>Cˆ¼?ˆÜK)ã½?y�À/çA¿?YS´'ŽPÀ?t‡šDþÀ?Ž6¼›q§Á?ó­Ñ±ÙHÂ? ÜÀ‚ŽÞÂ?í¥$cØcÃ?UêÆÓÃ?(?ü¿Á%Ä?ƒ¤‡‚TÄ?ßÉ J]UÄ?Vè9¼}Ä?wýÞK§Ã?9\%7ràÂ?ˆÙyL©½Á?B†Pô0À?“bçñX¼?Ë�…ãB·?Ø8Ê̱?¾çl8Š£?Jæ‘ÑUi?ßþö`f“¢¿2�…¿|©´¿qvÑ‚ À¿¹;Ca›sÇ¿\ê‹Ì²Î¿âüH‡cÓ¿÷{;(|çÖ¿Æ-{l$¡Ú¿qMš©­(Þ¿uÑ|q®à¿ÌàGy†â¿ÄQa™%ã¿7‡æk/çã¿‹,RJä¿Ê‹,RJä¿û…æk/çã¿DÆQa™%ã¿àGy†â¿.Ñ|q®à¿Mš©­(Þ¿³.{l$¡Ú¿øw;(|çÖ¿ÐI‡cÓ¿rê‹Ì²Î¿$7Ca›sÇ¿ÐsÑ‚ À¿w“…¿|©´¿`÷`f“¢¿Í\’ÑUi?Qðl8Š£?}/Ê̱?݆…ãB·?€eçñX¼?&ÿ…Pô0À?…ÚyL©½Á?Ï\%7ràÂ?ûÞK§Ã?Áë9¼}Ä?EÉ J]UÄ?¤‡‚TÄ?Ð@ü¿Á%Ä?$æÆÓÃ?v¨$cØcÃ?�ÛÀ‚ŽÞÂ?®Ñ±ÙHÂ?Þ6¼›q§Á?€‡šDþÀ?U´'ŽPÀ?G„À/çA¿?\ ÜK)ã½?Å£,>Cˆ¼?×TˆœØ3»?²MI�îç¹?׿¦¸?P�¬Ã5o·?æX7D¶?ÆÌ~%µ?Ó”4$E´?ºÂ{÷” ³?$sàLT²?ž«¨ÏL'±?„PK•2F°?Ö"fZSá®?(OV•L­?d¬#«LÍ«?žö;‘bª?%r<ýu ©?‘¶ ǧ?Å ïh”¦?(É?5¢r¥?÷VŽÐÕ`¤?xI (^£?Ĩ`Åi¢?Hz pá‚¡?µüÖº¨ ?kÔq /µŸ?$Ð�/ž?[ƒH¿œ?¢Œc›?š>'õÞš?R'uvâ˜?ÑBÞ©×»—?,4K$¥–?ž>+�•?:.),£”?ì^lGq¶“?Ó|Ž#Ö’?dŽŠˆ’?ö$6èó7‘?rµpÂx�?RÃøÖ³†�?Ø,aFW.Ž?1‡`çŒ?Kôa/Ò°‹?Dñ‚Y¾‰Š?ªøå«Eq‰? �p–fˆ?:Ô+¹ëh‡?ñk ‘Œw†?i?v:Ë‘…?Ǿx·„?ˈNåžæƒ?5ŠO ƒ?-–Ð&¿b‚? Qî=®�?Ï»¸�?LM[¯À]€?m3¥:Ó�?íçi KV~?Øgy,8}?ƒãÞÆ&|?H¸rÃp!{?¡½×Ž'z?Sß9¿‹8y?6ÓøOÛSx?£Jùxw?ñöÚh§v?bë�(´Þu?Íá{äku?x�ýå&ft?¿rºÊ�µs?=š� s?º jƒ¤ir?ò¸Þl¿Íq?Îú˜Í8q?8RÕWz¨p?†/w¼‡p?í©IÞ4o?©ÈYÜ`5n?íž@m?ž{�MUl?�¦Çsk?AF(T™j?BÛáÇi?]%£ÒLþh?ükf‡={+eF?ÇÝü�vêE?uú1sE?‚ÜA.®þD?Ç«t}g�D?ý£0ïD?ÊŸ1œ³C?”\ÅéJC?Ú9@úæäB?ÆÖàá~�B?ÙÃmJ� B?!®³¶.ÂA?íûV fA?¶ò:` A?×ÕŒ/Ü´@?ážøïƒ_@?…ÍyèF @?^z—˜*v??ºð㮿×>?‘üþ’/=>?üË%Á]¦=?¯ý©.=?êñ¤‡ƒËð8?|‚ù¥z8?ar•B8?À¸J餔7?Ùœ* Þ%7?Ä”òª›¹6?aÌO6?"é¿O_è5?7 ¢Dƒ5?ŽÌ‚ñj 5?€:ŠtÄ¿4?Õ�ˆÄAa4?ÍSÇtÔ4?Û=ùˆnª3?¸²aqR3?eêƒû2?“hˆã¦2?aÍt•T2?âBÓ,2?Æ%a¨Ê³1?¿­2º2f1?MZ�i@1?éA^éÏ0?¥šÞW"‡0?ÚÓ�6â?0?Û³Ú=ô/?¦fu žk/?þ…®™Òå.?3ý¬Éb.?í�~éqâ-?;2wuºd-?X`ï’é,?æolëp,?j·Nt´ú+?·XvýÞ†+?·®i\+?a­¤‚¦*?让v9*?ë‰Õ9Î)??~�xe)?H|>èÆþ(?˜x*ˆš(?SNOD°ýØýøÿÜ pXTREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿHEAPXPØýøÿ @__uuid$9 @ __baseline ?@4 4ÿ H __curvestyle: 8__shade ?@4 4ÿ H __format; P &__showlabelFALSETRUE 8 __number  0 __dict  Ù@Û H annotations< @xlabel= @xunit> @ylabel? @yunit@ P &autoscaleFALSETRUE P &xscalelogFALSETRUE H xscaleminA H xscalemaxB P &yscalelogFALSETRUE H yscaleminC H yscalemaxD `TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ¸*HEAPX xydatametadata8s004: derivative(s001)s005: derivative(s002)s006: derivative(s003)`ð0Ð @titleE(ôô ?@4 4ÿx @x$À¨hüœzë#ÀQÑø9õÖ#Àù9õÖoÂ#À¡¢ñsê­#ÀI îe™#Àòsê­ß„#ÀšÜæJZp#ÀBEãçÔ[#Àê­ß„OG#À“Ü!Ê2#À;ؾD#ÀãçÔ[¿ #ÀŒPÑø9õ"À4¹Í•´à"ÀÜ!Ê2/Ì"À„ŠÆÏ©·"À-óÂl$£"ÀÕ[¿ ŸŽ"À}Ä»¦z"À&-¸C”e"ÀΕ´àQ"Àvþ°}‰<"Àg­("ÀÇÏ©·~"Ào8¦Tùþ!À¡¢ñsê!À¿ ŸŽîÕ!Àhr›+iÁ!ÀÛ—Èã¬!À¸C”e^˜!À`¬�Ùƒ!À �ŸSo!À±}‰<ÎZ!ÀYæ…ÙHF!ÀO‚vÃ1!Àª·~>!ÀR {°¸!ÀúˆwM3ô À£ñsê­ß ÀKZp‡(Ë ÀóÂl$£¶ Àœ+iÁ¢ ÀD”e^˜� Àìüaûy À”e^˜�d À=ÎZ5P Àå6WÒ‚; À�ŸSoý& À5P x À¼á˜RåûÀ ³‘ŒÚÒÀ\„ŠÆÏ©À­UƒÅ€Àþ&|:ºWÀNøtt¯.ÀžÉm®¤Àïšfè™ÜÀ@l_"�³À�=X\„ŠÀáQ–yaÀ2àIÐn8À‚±B dÀÒ‚;DYæÀ#T4~N½Àt%-¸C”ÀÄö%ò8kÀÈ,.BÀe™f#À¶j ðÀ< Ú ÇÀW žÀ¨ÞúMøtÀø¯ó‡íKÀH�ìÁâ"À™Råû×ùÀê#Þ5ÍÐÀ:õÖo§ÀŠÆÏ©·~ÀÛ—Èã¬UÀ,iÁ¢,À|:ºW—ÀÍ ³‘ŒÚÀÝ«Ë�±Àn®¤wˆÀ¾�?l_ÀQ–ya6À`"�³V À°ó‡íKäÀÅ€'A»ÀQ–ya6’À¢gr›+iÀò8kÕ @ÀC dÀ”Û\I îÀä¬UƒÅÀ4~N½õ›À…OG÷êrÀÖ @1àIÀ&ò8kÕ ÀwÃ1¥Ê÷ÀÇ”*ß¿ÎÀf#µ¥Àh7Sª|À¹�ŸSÀ Ú Ç”*ÀZ«ŠÀ«|ÿ:ØÀûMøtt¯ÀLñ®i†Àœðéè^]ÀíÁâ"T4À=“Û\I ÀŽdÔ–>âÀÞ5ÍÐ3¹À/Æ )�ÀؾDgÀЩ·~>À!{°¸ÀqL©òýëÀ¢,óÂÀïšfè™ÀcÀ“ ÝpÀ³‘ŒÚÒGÀc…ÈÀT4~N½õÀ¥wˆ²ÌÀõÖo§£ÀF¨hüœzÀ—ya6’QÀçJZp‡(À8Sª|ÿÀˆíKäqÖÀÙ¾Dg­À)�=X\„Àza6’Q[ÀÊ2/ÌF2À(< ÀkÕ @1àÀ¼¦z&·À x´ŽÀ]I îeÀ®(<ÀþëüaûÀO½õ›ðéÀŸŽîÕåÀÀð_çÛ—À@1àIÐnÀ‘ÙƒÅEÀáÓѽºÀdJ•ï_çÀí†cJ•À¦�x×4CÀH2jKñÀèÔ[¿ ŸÀŠwM3ôLÀ*?§Þú À̼0ɨ Àl_"�³V Àž À°¤wˆ² ÀPG÷êr` Àòéè^] À’ŒÚÒG¼ À4/ÌF2j ÀÔѽº Àvt¯.Æ À¡¢ñs À¸¹’Ü! ÀX\„ŠÆÏ Àúþuþ°} Àœ¡gr›+ ÀÝ«ËÀ¶àQ–yÀVƒÅ€'Àø%ò8kÕÀ˜Èã¬UƒÀ:kÕ @1À¸Ž)U¾ÿ¿ø`q*ÿ¿8¦Tùþuþ¿€ë7áÓÑý¿À0ɨ-ý¿vþ°}‰ü¿@»á˜Råû¿ˆÅ€'Aû¿ÈE¨hüœú¿‹‹PÑøù¿HÐn8¦Tù¿�R {°ø¿ÐZ5P ø¿ ð$h÷¿Xåû×ùÃö¿˜*ß¿Îö¿Øo§£{õ¿µ¥�x×ô¿`úˆwM3ô¿ ?l_"�ó¿à„OG÷êò¿ Ê2/ÌFò¿h¡¢ñ¿¨Tùþuþð¿è™ÜæJZð¿`¾�?lï¿àHFmé#î¿`Ó =“Ûì¿à]Ó =“ë¿pè™ÜæJê¿ðr`¬�é¿pý&|:ºç¿ð‡íKäqæ¿€´Ž)å¿�zë7áã¿€'A»á˜â¿²‹‹Pá¿�<ÎZ5à¿ Ž)U¾Ý¿ £¶ôïÚ¿@¸C”e^Ø¿@ÍÐ3¹ÍÕ¿@â]Ó =Ó¿@÷êr`¬Ð¿Àð$h7Ì¿ÀB dÇ¿Àl$£¶ôÁ¿.}Ä»¦¹¿c…È®¿X—c…”¿X—c…”?c…È®?€-}Ä»¦¹?Àl$£¶ôÁ?ÀB dÇ?€ð$h7Ì?@÷êr`¬Ð?@â]Ó =Ó? ÍÐ3¹ÍÕ? ¸C”e^Ø? £¶ôïÚ? Ž)U¾Ý?€<ÎZ5à?²‹‹Pá?€'A»á˜â?�zë7áã?p´Ž)å?ð‡íKäqæ?pý&|:ºç?àr`¬�é?`è™ÜæJê?à]Ó =“ë?`Ó =“Ûì?ÐHFmé#î?P¾�?lï?è™ÜæJZð?¨Tùþuþð?`¡¢ñ? Ê2/ÌFò?à„OG÷êò?˜?l_"�ó?XúˆwM3ô?µ¥�x×ô?Øo§£{õ?�*ß¿Îö?Påû×ùÃö? ð$h÷?ÐZ5P ø?ˆR {°ø?HÐn8¦Tù?‹‹PÑøù?ÀE¨hüœú?€Å€'Aû?@»á˜Råû?vþ°}‰ü?¸0ɨ-ý?xë7áÓÑý?8¦Tùþuþ?ø`q*ÿ?°Ž)U¾ÿ?8kÕ @1@˜Èã¬Uƒ@ô%ò8kÕ@TƒÅ€'@´àQ–y@>Ý«Ë@p›+iÁ@Ðø9õÖo@0VH�ìÁ@�³V @ìe™f@Lns%-¸@¬Ë�±B @)�=X\@h†žÉm®@Èã¬Uƒ@(A»á˜R@„žÉm®¤@äû×ùÃö@DYæ…ÙH@ ¶ôïš@ží@`q*?@Àζ/‘@,.BEã@|‰<ÎZ5@ÜæJZp‡@@~ؾDg@.Æ )�@Þ5ÍÐ3¹@ŒdÔ–>â@<“Û\I @ìÁâ"T4@œðéè^]@Jñ®i†@úMøtt¯@ª|ÿ:Ø@Z«Š@Ú Ç”*@¸�ŸS@h7Sª|@f#µ¥@Æ”*ß¿Î@vÃ1¥Ê÷@$ò8kÕ @Ô @1àI@„OG÷êr@4~N½õ›@ä¬UƒÅ@”Û\I î@@ d@ð8kÕ @@ gr›+i@P–ya6’@Å€'A»@°ó‡íKä@`"�³V @Q–ya6@¼�?l_@l®¤wˆ@Ý«Ë�±@Ì ³‘ŒÚ@|:ºW—@,iÁ¢,@Ü—Èã¬U@ˆÆÏ©·~@8õÖo§@è#Þ5ÍÐ@˜Råû×ù@H�ìÁâ"@ø¯ó‡íK@¨ÞúMøt@T ž@< Ú Ç@´j ð@d™f#@È,.B@Äö%ò8k@t%-¸C”@$T4~N½@Ђ;DYæ@€±B d@0àIÐn8@àQ–ya@�=X\„Š@@l_"�³@ðšfè™Ü@œÉm®¤@Løtt¯.@ü&|:ºW@¬UƒÅ€@\„ŠÆÏ©@ ³‘ŒÚÒ@¼á˜Råû@4P x @ŒŸSoý& @ä6WÒ‚; @<ÎZ5P @”e^˜�d @ìüaûy @D”e^˜� @œ+iÁ¢ @òÂl$£¶ @JZp‡(Ë @¢ñsê­ß @úˆwM3ô @R {°¸!@ª·~>!@O‚vÃ1!@Xæ…ÙHF!@°}‰<ÎZ!@�ŸSo!@`¬�Ùƒ!@¸C”e^˜!@Û—Èã¬!@hr›+iÁ!@¾ ŸŽîÕ!@¡¢ñsê!@n8¦Tùþ!@ÆÏ©·~"@g­("@vþ°}‰<"@Ε´àQ"@&-¸C”e"@|Ä»¦z"@Ô[¿ ŸŽ"@,óÂl$£"@„ŠÆÏ©·"@Ü!Ê2/Ì"@4¹Í•´à"@ŒPÑø9õ"@âçÔ[¿ #@:ؾD#@’Ü!Ê2#@ê­ß„OG#@BEãçÔ[#@šÜæJZp#@òsê­ß„#@H îe™#@ ¢ñsê­#@ø9õÖoÂ#@PÑø9õÖ#@¨hüœzë#@$@¶°.•çu?ʶ÷©rÿ(?,Ôln(f)?•*;óíÎ)?_�ïÏ9*?&TvÛ¦*?{h˜ø+?B`&I¥‡+?$ðfŸû+?cµš»q,?oÂEhê,?Q¿•e-?9ÑÑRã-?„½9e¯c.?ÆBá¾æ.?.O��l/?Ô±Áp5õ/?ÎB)a@0?*‰CФ‡0?›¶H›nÐ0?¥ ºfÉ1?fgD¿f1?Ä«wÚZ´1?KÊ�#§2?|²:n¯T2?ï=¦a§2?I�J#ü2?ùž%«¦R3?uv %«3?ýp–�4?¬H�óa4?Öâ1{À4?Ž+å€&!5?¼(÷É„5?ܳD%é5?ÐWxÉ—P6?”F²lº6?-ùwß´&7?¯6®¼�•7?NBóEå8?q¼ òz8?’/?»ñ8?€(FªTk9?{õäÃÒç9?99¸®Jg:?°™|AÒé:?‘_¿ €o;?¯-ÿfkø;?íj¬„ÿ“§=?Rùo>>?ÙÀ(žÙ>?~nz]}w??÷®”aõ @?ãU­7`@?sq•`•µ@?´9AØ A?A6NåfA?au´rùÂA?ªq«Kn!B?N•/bV‚B?+gÔ5ÅåB?ÍQæúÎKC?,$ᡈ´C?VŸ>ß D?@h¢3cŽD?m�gô±ÿD?tî”T tE?!C>n‹ëE?BžVLIfF?k•úô`äF?Éä9tîeG?óìcçëG?JŽâˆàsH?Ц¼‚I?€€.‘I?¿“7‰¼%J?­‹2™¾J?М°ªÐ[K?1®{÷ˆýK?�oæžé£L?êÖàºOM?’³( JÿM?Óu‰¡´N?­ßùNoO?P®»ÂP?�pœ'¹zP?]=1§àP?…™ƦIQ?ú‚“¼ÔµQ?µÐ;fN%R?Cu2˜R?n=¢¡S?*®)ƒ»ˆS?�Y¨¤T?»0Õ€ˆT?Äa$ðuU?�„ê…«˜U?0®VÞJ'V?ÇbºV?pÌn#uRW?�ßË\ïW?Žç¨e‘X? Zt=Ã8Y?`ûËp¬åY?-̳X˜Z?h®Q[?t §Âç\?³&HÕ\?Â}Ûg¡]?`ý|ÚŠt^?ì—b8ýN_?tÓ«…`?Ÿó“ ƒ�`?ÏÂE¡a?dE*K „a?žÜ�.ób?µÕ�¤†Œb?°À�òùc?,HS肨c?{÷Z>d?Q�ᘺÙd?²Eê÷âze?jù›"f?Ì·ÑW”Ïf?O s‡ªƒg?4c~D£>h?öö�Îi?Äá–Ó€Êi?1fø”œj?AKØDáuk?ö•º@OXl?þ @-ÄCm?¡�…G­8n?áAÛ»}7o?‡Ç%�W p?­Ò¡ `ªp?+oÛ:q?!• ”ÖÏq?i¬K/Ökr?Ãè”Nls?\oí·s?M‡±ht?¶{X¦!u?¿£ø‚áu?ÀŠÄ\ªv?D¤W||w?a“à Wx?éP¢ü;y?XÀxs.+z?v½¯çA%{?SÈË*|?ϹÅßi<}?5„²”ÃZ~?¼�싊†?¤Mx¬=`€?.‰¼¯�?Qù8ï±�?qþ}!­e‚?me8É"#ƒ?e52�äéƒ?ôCÖ2zº„?]å¿t•…?k€äÖk{†?”èèm‡?åñ0Gíjˆ?LýM'Þu‰?õ�C¼œŽŠ?}²Lûµ‹?ªÅ–©ØìŒ?E³sá#4Ž?Ú!imÚŒ�?gi¶M|�?##>-j;‘?!H.5’?­#Z£ Ú’?+Yaw•º“?¹8ñÒ‘§”?k�;Çá•?´.Íýü©–?HàÂkÁ—?¾à¶è˜?ë¦ yК?¢4Óki›? '„4üÅœ?ëì i­6ž?XGÕw¼¼Ÿ?ñ)Ǽ¬ ?¿)n ‡¡?T#„ÓDn¢?mSžëb£?xxHàe¤?Üó¦õw¥?Ùxtóš¦?ö6ù̧?´ÁO±©?øeÇöiª?D²ºi&Ô«?, às¼S­?˰[žÄè®?Ú¯·° J°?­$œ�F+±?¬ \g²?$íÚ"º³?üzï÷r´?ãBh©)µ?¼šå¢NH¶?ÌðŠž&s·?§¬ ¹©¸?ø”J¨Dë¹?ïlÿ'®6»?Ÿ¢*�(€¼?bL8‹ Û½?×�¥ŽÚ9¿?ªïÿˆLÀ?Œ¢^ 6úÀ?°ë«N£Á?£ �ê‘DÂ?ÁÕ”¢ ÚÂ?õ´jlÿ^Ã?Ÿ‚3�ÃÍÃ?‘\j¯Ä?.‹oúLÄ?/è £LÄ?郣ùTÄ?�xe=E˜Ã?6‚V÷ÊÂ?øÊ(œœÁ?·è+½,ò¿?„Ññ­NÈ»?í^Ÿùß¶?´º,­†œ°?„'^<Ñ>¢?úŸõ…��V?š©M&Zp£¿'SáQµ¿BŽùE‚<¿¿2÷|LÿÆ¿3+ü&`xοúÙð® Ó¿Å ºÝÖ¿§&�”"šÚ¿*¨±µ#Þ¿¾'@–¬à¿o,±  â¿wl_Ùc$ã¿!ö{æã¿ÅG`¯SI俆G`¯SIä¿ûô{æã¿Ÿn_Ùc$㿺+±  â¿s'@–¬à¿Ù§±µ#Þ¿b'�”"šÚ¿ŒÁ ºÝÖ¿£þÙð® Ó¿k-ü&`xο~ó|LÿÆ¿²ŒùE‚<¿¿YUáQµ¿*¬M&Zp£¿@ö…��V?Y&^<Ñ>¢?‚º,­†œ°?‰^Ÿùß¶?âÒñ­NÈ»?Yç+½,ò¿?gÊ(œœÁ?™‚V÷ÊÂ?dwe=E˜Ã?A†£ùTÄ?Çç £LÄ?.‹oúLÄ?õ\j¯Ä?”€3�ÃÍÃ?Õµjlÿ^Ã?¨Õ”¢ ÚÂ?$ �ê‘DÂ?å¯ë«N£Á?G£^ 6úÀ?ûîÿˆLÀ?æ�¥ŽÚ9¿? K8‹ Û½?’¤*�(€¼?rlÿ'®6»?ß”J¨Dë¹?C¬ ¹©¸?þðŠž&s·?¹›å¢NH¶?qBh©)µ?5zï÷r´?ÓíÚ"º³?*¡\g²?Ä$œ�F+±?+¯·° J°?™°[žÄè®?. às¼S­?„°ºi&Ô«?òfÇöiª?Š´ÁO±©?×6ù̧?·wtóš¦?‘ó¦õw¥?ByHàe¤?gmSžëb£?5$„ÓDn¢?¾)n ‡¡?à)Ǽ¬ ?.FÕw¼¼Ÿ?­ê i­6ž?•)„4üÅœ?f¢4Óki›?¦ yК?â¶è˜?-àÂkÁ—?â0Íýü©–?-;Çá•?l9ñÒ‘§”?JXaw•º“?i#Z£ Ú’?ÐH.5’?v#>-j;‘?i¶M|�?ŒimÚŒ�?8µsá#4Ž?ôÆ–©ØìŒ?бLûµ‹?îžC¼œŽŠ?¹ýM'Þu‰?Þò0Gíjˆ?‹èèm‡?‹äÖk{†?aæ¿t•…?XDÖ2zº„?˜52�äéƒ?me8É"#ƒ?€þ}!­e‚?ø8ï±�?Yм¯�?h?Ž s‡ªƒg?ø¹ÑW”Ïf?ù›"f?ÛDê÷âze?Q�ᘺÙd?¦ôZ>d?àKS肨c?.À�òùc?D×�¤†Œb?˜Û�.ób?H*K „a?:ÀE¡a?¯ð“ ƒ�`?±sÓ«…`?™b8ýN_?ƒ}ÚŠt^?…�Ûg¡]?À&HÕ\?€ §Âç\?5U®Q[?rÖ³X˜Z?îËp¬åY?#ht=Ã8Y?lä¨e‘X?|âË\ïW? Ðn#uRW?yÄbºV?VªVÞJ'V?‡ꅫ˜U?²_$ðuU? 0Õ€ˆT?– Y¨¤T?0­)ƒ»ˆS?×A¢¡S?=Du2˜R?8Ô;fN%R?Ø~“¼ÔµQ?ç˜ƦIQ?P?1§àP??mœ'¹zP?¯»ÂP?éÞùNoO?ƒ„‰¡´N?;±( JÿM?÷ÏàºOM?=hæžé£L?i­{÷ˆýK?À£°ªÐ[K?‹2™¾J?°–7‰¼%J?÷}.‘I?l¦¼‚I?€�âˆàsH?îcçëG?öÝ9tîeG?Š™úô`äF?æŸVLIfF?O>n‹ëE?¸æ”T tE?�gô±ÿD?(d¢3cŽD?ý¤>ß D?a ᡈ´C?TQæúÎKC?²eÔ5ÅåB?‹›/bV‚B?…m«Kn!B?ót´rùÂA??*èo>>?xBÿ“§=?ïž\=?ßïj¬„èY(À�6À[@(À…òAÏ&(ÀzîÉÃB (ÀoÊ¡E¶ó'Àd¦yÇ)Ú'ÀY‚QI�À'ÀN^)˧'ÀB:M„�'À7ÙÎ÷s'À,ò°PkZ'À!ΈÒÞ@'Àª`TR''À †8ÖÅ 'ÀbX9ô&Àõ=èÙ¬Ú&ÀéÀ[ Á&ÀÞõ—Ý“§&ÀÓÑo_Ž&ÀÈ­Gázt&À½‰cîZ&À²e÷äaA&À§AÏfÕ'&À›§èH&À�ù~j¼ô%À…ÕVì/Û%Àz±.n£Á%Ào�ð¨%ÀdiÞqŠŽ%ÀYE¶óýt%ÀN!Žuq[%ÀBýe÷äA%À7Ù=yX(%À,µûË%À!‘í|?õ$ÀmÅþ²Û$À I�€&Â$À%uš¨$ÀõM„ �$ÀéÜ$�u$ÀÞ¸ü‡ô[$ÀÓ”Ô hB$ÀÈp¬‹Û($À½L„ O$À²(\�Âõ#À§46Ü#Àœà “©Â#À�¼ã©#À…˜»–��#Àzt“v#ÀoPkšw\#Àd,CëB#ÀYž^)#ÀNäòÒ#ÀBÀÊ¡Eö"À7œ¢#¹Ü"À,xz¥,Ã"À!TR' ©"À0*©�"À +‡v"ÀèÙ¬ú\"Àõñ.nC"À韉°á)"ÀÞ{a2U"ÀÓW9´Èö!ÀÈ36<Ý!À½é·¯Ã!À²ëÀ9#ª!À§Ç˜»–�!Àœ£p= w!À�H¿}]!À…[ AñC!Àz7øÂd*!ÀoÐDØ!Àdï§ÆK÷ ÀYËH¿Ý ÀN§WÊ2Ä ÀCƒ/L¦ª À7_Α À,;ßO�w À!·Ñ^ ÀóŽStD À ÏfÕç* À«>W[ Àé -²�ïÀÓÅܵ„¼À½}Œ¹k‰À¦5<½RVÀ�íëÀ9#Àz¥›Ä ðÀd]KȽÀMûËî‰À7ͪÏÕVÀ!…ZÓ¼#À = ×£ðÀôô¹ÚнÀÞ¬iÞqŠÀÈdâXWÀ²Éå?$À›Ôxé&ñÀ…Œ(í ¾ÀoDØðôŠÀYü‡ôÛWÀB´7øÂ$À,lçû©ñÀ$—ÿ�¾ÀÜFx‹Àé“ö_XÀÓK¦ F%À½V-òÀ§»¿À�sµû‹Àz+eâXÀdãÉ%ÀN›Ä °òÀ7St$—¿À! $(~ŒÀ ÃÓ+eYÀôzƒ/L&ÀÞ2333óÀÈêâ6ÀÀ²¢’:�À›ZB>èYÀ…òAÏ&ÀoÊ¡E¶óÀY‚QI�ÀÀB:M„�À,ò°PkZÀª`TR'ÀbX9ôÀéÀ[ ÁÀÓÑo_ŽÀ½‰cîZÀ§AÏfÕ'À�ù~j¼ôÀz±.n£ÁÀdiÞqŠŽÀN!Žuq[À7Ù=yX(À!‘í|?õÀ I�€&ÂÀõM„ �ÀÞ¸ü‡ô[ÀÈp¬‹Û(À²(\�ÂõÀœà “©ÂÀ…˜»–��ÀoPkšw\ÀYž^)ÀBÀÊ¡EöÀ,xz¥,ÃÀ0*©�ÀèÙ¬ú\À韉°á)ÀÓW9´ÈöÀ½é·¯ÃÀ§Ç˜»–�À�H¿}]Àz7øÂd*Àdï§ÆK÷ÀN§WÊ2ÄÀ7_ΑÀ!·Ñ^À ÏfÕç*Àé -²�ïÀ½}Œ¹k‰À�íëÀ9#Àd]KȽÀ7ͪÏÕVÀ = ×£ð ÀÞ¬iÞqŠ À²Éå?$ À…Œ(í ¾ ÀYü‡ôÛW À,lçû©ñ ÀÜFx‹ ÀÓK¦ F% À§»¿ Àz+eâX ÀN›Ä °ò À! $(~Œ Àôzƒ/L& ÀÈêâ6ÀÀ›ZB>èYÀoÊ¡E¶óÀB:M„�Àª`TR'ÀéÀ[ ÁÀ½‰cîZÀ�ù~j¼ôÀdiÞqŠŽÀ7Ù=yX(À I�€&ÂÀÞ¸ü‡ô[À²(\�ÂõÀ…˜»–��ÀYž^)À,xz¥,ÃÀèÙ¬ú\ÀÓW9´ÈöÀ§Ç˜»–�Àz7øÂd*ÀN§WÊ2ÄÀ!·Ñ^Àé -²�ïÿ¿�íëÀ9#ÿ¿7ͪÏÕVþ¿Þ¬iÞqŠý¿…Œ(í ¾ü¿,lçû©ñû¿ÓK¦ F%û¿z+eâXú¿! $(~Œù¿Èêâ6Àø¿oÊ¡E¶ó÷¿ª`TR'÷¿½‰cîZö¿diÞqŠŽõ¿ I�€&Âô¿²(\�Âõó¿Yž^)ó¿èÙ¬ú\ò¿§Ç˜»–�ñ¿N§WÊ2Äð¿é -²�ïï¿7ͪÏÕVî¿…Œ(í ¾ì¿ÓK¦ F%ë¿! $(~Œé¿oÊ¡E¶ó翽‰cîZæ¿ I�€&Âä¿Yž^)㿧ǘ»–�á¿é -²�ïß¿…Œ(í ¾Ü¿! $(~ŒÙ¿½‰cîZÖ¿Yž^)Ó¿é -²�ïÏ¿! $(~ŒÉ¿Yž^)ÿ! $(~Œ¹¿! $(~Œ©¿! $(~Œ©?! $(~Œ¹?Yž^)Ã?! $(~ŒÉ?é -²�ïÏ?Yž^)Ó?½‰cîZÖ?! $(~ŒÙ?…Œ(í ¾Ü?é -²�ïß?§Ç˜»–�á?Yž^)ã? I�€&Âä?½‰cîZæ?oÊ¡E¶óç?! $(~Œé?ÓK¦ F%ë?…Œ(í ¾ì?7ͪÏÕVî?é -²�ïï?N§WÊ2Äð?§Ç˜»–�ñ?èÙ¬ú\ò?Yž^)ó?²(\�Âõó? I�€&Âô?diÞqŠŽõ?½‰cîZö?ª`TR'÷?oÊ¡E¶ó÷?Èêâ6Àø?! $(~Œù?z+eâXú?ÓK¦ F%û?,lçû©ñû?…Œ(í ¾ü?Þ¬iÞqŠý?7ͪÏÕVþ?�íëÀ9#ÿ?é -²�ïÿ?!·Ñ^@N§WÊ2Ä@z7øÂd*@§Ç˜»–�@ÓW9´Èö@èÙ¬ú\@,xz¥,Ã@Yž^)@…˜»–��@²(\�Âõ@Þ¸ü‡ô[@ I�€&Â@7Ù=yX(@diÞqŠŽ@�ù~j¼ô@½‰cîZ@éÀ[ Á@ª`TR'@B:M„�@oÊ¡E¶ó@›ZB>èY@Èêâ6À@ôzƒ/L& @! $(~Œ @N›Ä °ò @z+eâX @§»¿ @ÓK¦ F% @ÜFx‹ @,lçû©ñ @Yü‡ôÛW @…Œ(í ¾ @²Éå?$ @Þ¬iÞqŠ @ = ×£ð @7ͪÏÕV@d]KȽ@�íëÀ9#@½}Œ¹k‰@é -²�ï@ ÏfÕç*@!·Ñ^@7_Α@N§WÊ2Ä@dï§ÆK÷@z7øÂd*@�H¿}]@§Ç˜»–�@½é·¯Ã@ÓW9´Èö@韉°á)@èÙ¬ú\@0*©�@,xz¥,Ã@BÀÊ¡Eö@Yž^)@oPkšw\@…˜»–��@œà “©Â@²(\�Âõ@Èp¬‹Û(@Þ¸ü‡ô[@õM„ �@ I�€&Â@!‘í|?õ@7Ù=yX(@N!Žuq[@diÞqŠŽ@z±.n£Á@�ù~j¼ô@§AÏfÕ'@½‰cîZ@ÓÑo_Ž@éÀ[ Á@bX9ô@ª`TR'@,ò°PkZ@B:M„�@Y‚QI�À@oÊ¡E¶ó@…òAÏ&@›ZB>èY@²¢’:�@Èêâ6À@Þ2333ó@ôzƒ/L&@ ÃÓ+eY@! $(~Œ@7St$—¿@N›Ä °ò@dãÉ%@z+eâX@�sµû‹@§»¿@½V-ò@ÓK¦ F%@é“ö_X@ÜFx‹@$—ÿ�¾@,lçû©ñ@B´7øÂ$@Yü‡ôÛW@oDØðôŠ@…Œ(í ¾@›Ôxé&ñ@²Éå?$@ÈdâXW@Þ¬iÞqŠ@ôô¹Úн@ = ×£ð@!…ZÓ¼#@7ͪÏÕV@MûËî‰@d]KȽ@z¥›Ä ð@�íëÀ9#@¦5<½RV@½}Œ¹k‰@ÓÅܵ„¼@é -²�ï@«>W[ @ ÏfÕç* @óŽStD @!·Ñ^ @,;ßO�w @7_Α @Cƒ/L¦ª @N§WÊ2Ä @YËH¿Ý @dï§ÆK÷ @oÐDØ!@z7øÂd*!@…[ AñC!@�H¿}]!@œ£p= w!@§Ç˜»–�!@²ëÀ9#ª!@½é·¯Ã!@È36<Ý!@ÓW9´Èö!@Þ{a2U"@韉°á)"@õñ.nC"@èÙ¬ú\"@ +‡v"@0*©�"@!TR' ©"@,xz¥,Ã"@7œ¢#¹Ü"@BÀÊ¡Eö"@NäòÒ#@Yž^)#@d,CëB#@oPkšw\#@zt“v#@…˜»–��#@�¼ã©#@œà “©Â#@§46Ü#@²(\�Âõ#@½L„ O$@Èp¬‹Û($@Ó”Ô hB$@Þ¸ü‡ô[$@éÜ$�u$@õM„ �$@%uš¨$@ I�€&Â$@mÅþ²Û$@!‘í|?õ$@,µûË%@7Ù=yX(%@Býe÷äA%@N!Žuq[%@YE¶óýt%@diÞqŠŽ%@o�ð¨%@z±.n£Á%@…ÕVì/Û%@�ù~j¼ô%@›§èH&@§AÏfÕ'&@²e÷äaA&@½‰cîZ&@È­Gázt&@ÓÑo_Ž&@Þõ—Ý“§&@éÀ[ Á&@õ=èÙ¬Ú&@bX9ô&@ †8ÖÅ '@ª`TR''@!ΈÒÞ@'@,ò°PkZ'@7ÙÎ÷s'@B:M„�'@N^)˧'@Y‚QI�À'@d¦yÇ)Ú'@oÊ¡E¶ó'@zîÉÃB (@…òAÏ&(@�6À[@(@›ZB>èY(@§~j¼ts(@²¢’:�(@½Æº¸�¦(@Èêâ6À(@Ó µ¦Ù(@Š€“Y’D?t\ï�s’D?‰†Á’D?'×S{C“D?Âßñtù“D?@1½|ã”D?l4²ž–D? ­‚éS—D?Or‰nÚ˜D?¿ÅÙA•šD?ò54z„œD?—1¨žD?Æ–µ‚¡D?·ëŽ�£D?~2·tO¦D?‹#J[F©D?¢:þhr¬D?bÖêÇÓ¯D?;�ö¤j³D?�Qä/7·D?ãõV›9»D?…×r¿D?ö™ÚìàÃD?ß�ÍF†ÈD?ibÍD?€d•uÒD?lWÀ×D?¬{VBÝD?cŒ¿üâD?ápg.îèD?ÚNÌïD?À¯¸=|õD?)*ÚüD?ú€üîE?“ñÿ E?5-%OIE?JC8FÎE?ßoÌPŽ E?™fÛ‰(E?À úTÁ0E?„úù159E?þÊ`éåAE?y{QöÓJE?&=Ê×ÿSE?®6Àj]E?.þ.(gE?±_+©ûpE?ªºô"${E?®†)�…E?\õSS7�E?¿Û>#›E?8hì‰Q¦E?Y{iܱE?y ‹ßw½E?™K4BqÉE?v%"¸¯ÕE?Ÿú3âE?ZÅùÅþîE?ZtÕÞüE?„. k F?}ô¡F?ƒ„éæú$F?B�?23F?Ÿ¯+µAF?è=$!„PF?�Û{ _F? M{ oF?¨Ï ¸Ä~F?t�(‘ÎŽF?ƒöþ•)ŸF?Ä–ÒÖ¯F?WVZ×ÀF?“TáF,ÒF?gàߺÖãF?±Z™ß×õF?’Þqæ0G?ÜšâãG?ü¨ˆï-G?ë�°WAG?ó/ÒUG?_…±J@iG?iº+Ã}G?Fÿ=Þ§’G?äfàî§G?äÖ1š½G?R_„âªÓG?îÞ#êG?‰Í¿H?óáÂOH?×6 Á0H?à9LÙ-HH? ÆÛÃ`H?Y§¦ËyH? &G“H?Á‘ÁR8­H?Ó{3¡ÇH?68w݃âH?Ãt³tâýH?á},¿I?ÔPåG6I?YLüRI?l®áapI? >†MŽI?½îĬI?:‘fÇËI?ŒøôZëI?«üÉØ~ J?ǤC™8,J?1íV;ŠMJ?/íô¿voJ?HzÓ>’J?aå9ç,µJ?Â"ÚýØJ?¸±ìtýJ?‹Âò%˜"K?ÿ³ÿBjHK?édöînK?Ú†ê*–K?Sª}¾K?éd6MÓæK?¦¡Ë¬IL?¬T•ì†:L?bJù�eL?»‰ÿg‘L?¥Gº'¾L?·Õß›ëL?çI6M?ü 2dJIM?ÑËEÐ|yM?GýžªM?@#.å³ÜM?õ„5JÄN?Å%¨iÕCN?ˆ!¦íxN?¯Õš¯N?ó£NæN?åƒG¤O?Ø!·jXO?ujm!Á’O?ÎmM—ÎO?T¬ ÔP?¤`½üý$P?�S˜ÜÍDP?vëJX?wÛÝ0 ’X?rà4^^]?@ÄŒé‰?^?ÂaLI^?åælC�_?c¥Ñ�ã._?4ÁÓ»g~`?t’¥�ü_?L@Ýêºha?ÒtvÆÆG`?í„M�Ùšb?�Ù²T`?€u%¢Dd?jc[Ô_?; Þÿ’ºf?T0É�K]?OŸ(Ÿñ‘j?*Ä{ÅfÂW?Óx�ãhp?Û¯Â4 èK?‹»ù—£u?{6™ç½P?1•MàâŠ~?˳ÐÙÏk?i´¸ë†?ôõ^L1€?Ëæâ’?» ûü÷T�?\¿åú‡q�?–Œ} `Êž?Ó:m4†¨?Žg':õ«?õ¥=Щ´?ÊÜbù¿Ê¸?Ÿ¤¥=Ì|Á?oD|y…œÅ?stº¢“Í?gîN—g’Ò?ØùŸ¿JâØ?8C"¹|ß?õüá3Âä?®Í̳õMê?{Ð)»ñ?.Up1 šõ? 6Ž{¶û?Äk½\@"ä(@ªî@¯úë@ @‚I,QÊ@Ìf«3lR@fÓ—:r@!dU& ‹@Ë£bº Y @u,RãËÎ!@¤ý1¦a"@aõÍ/¢ @Åé8T!@‹�“Y’D?Æé8T!@aõÍ/¢ @¤ý1¦a"@u,RãËÎ!@Ë£bº Y @!dU& ‹@eÓ—:r@Ëf«3lR@‚I,QÊ@¯úë@ @"ä(@ªî@Äk½\@ 6Ž{¶û?0Up1 šõ?}Ð)»ñ?®Í̳õMê?õüá3Âä?6C"¹|ß?ÖùŸ¿JâØ?gîN—g’Ò?wtº¢“Í?mD|y…œÅ?±¤¥=Ì|Á?ÑÜbù¿Ê¸?õ¥=Щ´?¤g':õ«?Ú:m4†¨?£Œ} `Êž?€¿åú‡q�?› ûü÷T�?üÊæâ’?€õ^L1€?’´¸ë†?9µÐÙÏk?÷–MàâŠ~?Ñ4™ç½P?“»ù—£u?l³Â4 èK? x�ãhp?*Ä{ÅfÂW?ïž(Ÿñ‘j?‰ü/É�K]?À Þÿ’ºf?ùc[Ô_?úr%¢Dd?Ž‹Ù²T`?H†M�Ùšb?uvÆÆG`?½<Ýêºha?Ü’¥�ü_?^ÃÓ»g~`?+£Ñ�ã._?ÔélC�_?ÅaLI^?MÀŒé‰?^?Ðn>4^^]? }±Öl%]?ã“9è¯w\?ÆæÝ(\?÷gÏ è™[?A8šé@[?ì£"ÇZ?–%iŸ¤kZ??Õdž�ÿY?eÀcu¥Y?ä¶ëhCY?¿àX?.Û@ùÕêW?Ò…MÛ¯›W?K4(MW?`[5XþW?òX4•¸V?Þ„ wìpV?±.t5+V?°¶æCÆçU?沈éÞ¥U?bmÛ±íeU?²¯8§ƒ'U?; Ð\ÖêT?bÕ›�£¯T?B6L‰vT?–ûÞÉ=T?ú/žñT?Ðâ]GŒÑS?("k�S?GkKà‰jS?�ÉØã8S?ð�@jS?ûÊ1tÙR?I xŸÜªR?@LExµ}R?îþ» —QR?iÉßz&R?ÙÖ ÃUüQ?!™d&#ÓQ?¾‡ñ‚ÚªQ?ÿ)&uƒQ?”ØÑ ì\Q?YØù87Q? Ìt›UQ?3º<îP?½Õ‡æÊP?.Ò�O¨P?žå¾Óq†P?ÂðJ’J?Çõô¿voJ?&ôV;ŠMJ?o§C™8,J?ÕùÉØ~ J?VõZëI?�fÇËI?|îĬI?–¡>†MŽI?رáapI?�ZLüRI?åOåG6I?´Ú},¿I?Ún³tâýH?Ï7w݃âH?…-{3¡ÇH?ñ…ÁR8­H?ð &G“H?†§¦ËyH?̆ÛÃ`H?á9LÙ-HH?¡5 Á0H?ãáÂOH?÷Ù¿H?åÞ#êG?‰h„âªÓG?ÂÙ1š½G?ºïfàî§G?#þ=Þ§’G? »+Ã}G?΄±J@iG?0ÒUG?‘�°WAG?•ÿ¨ˆï-G?Í£âãG?\×qæ0G?ùf™ß×õF?`æßºÖãF?ëWáF,ÒF?TUZ×ÀF?%–ÒÖ¯F?løþ•)ŸF?`Œ(‘ÎŽF?ñæ ¸Ä~F? X{ oF?ˆä{ _F?<9$!„PF?(³+µAF?�~?23F?»ƒéæú$F?�ý¡F?Õ5 k F?»qÕÞüE?{ÎùÅþîE?˜ú3âE? 9"¸¯ÕE?O4BqÉE?Y‹ßw½E?“miܱE?Ö^ì‰Q¦E?¿Û>#›E?ÔüSS7�E?=k)�…E?À³ô"${E?"]+©ûpE?ƒ#/(gE?,&Àj]E?íGÊ×ÿSE?üjQöÓJE?9ä`éåAE?«óù159E?ΨúTÁ0E?ÄeÛ‰(E?5lÌPŽ E?Õ78FÎE?Ê9%OIE?ö™ñÿ E?ÛvüîE?¤*ÚüD?ǰ¸=|õD?ÖÙNÌïD?Îig.îèD?iŒ¿üâD?JuVBÝD?kWÀ×D?m•uÒD?BibÍD?V…ÍF†ÈD?RžÚìàÃD?î×r¿D?¢ôV›9»D?jXä/7·D?ÿƒö¤j³D?¢ÐêÇÓ¯D?Z;þhr¬D?ôJ[F©D?D"·tO¦D?ÔúŽ�£D?«’µ‚¡D?T1¨žD?ò54z„œD?*ÂÙA•šD?˜q‰nÚ˜D?v·‚éS—D?))²ž–D?/*½|ã”D?ÁÚñtù“D?VÌS{C“D?E‰†Á’D?§dï�s’D?SNOD¸]à]` < x`XTREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿHEAPX `Pà]` @__uuid$T @ __baseline ?@4 4ÿ H __curvestyleU 8__shade ?@4 4ÿ H __formatV P &__showlabelFALSETRUE 8 __number  0 __dict à7: H annotationsW @xlabel X @xunitY @ylabelZ @yunit[ P &autoscaleFALSETRUE P &xscalelogFALSETRUE H xscalemin\ H xscalemax] P &yscalelogFALSETRUE H yscalemin^ H yscalemax_`i`TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿŠHEAPX ixydatametadata8p�Ð @title`(ôô ?@4 4ÿÐj@xÞ2333ó(ÀÓ µ¦Ù(ÀÈêâ6À(À½Æº¸�¦(À²¢’:�(À§~j¼ts(À›ZB>èY(À�6À[@(À…òAÏ&(ÀzîÉÃB (ÀoÊ¡E¶ó'Àd¦yÇ)Ú'ÀY‚QI�À'ÀN^)˧'ÀB:M„�'À7ÙÎ÷s'À,ò°PkZ'À!ΈÒÞ@'Àª`TR''À †8ÖÅ 'ÀbX9ô&Àõ=èÙ¬Ú&ÀéÀ[ Á&ÀÞõ—Ý“§&ÀÓÑo_Ž&ÀÈ­Gázt&À½‰cîZ&À²e÷äaA&À§AÏfÕ'&À›§èH&À�ù~j¼ô%À…ÕVì/Û%Àz±.n£Á%Ào�ð¨%ÀdiÞqŠŽ%ÀYE¶óýt%ÀN!Žuq[%ÀBýe÷äA%À7Ù=yX(%À,µûË%À!‘í|?õ$ÀmÅþ²Û$À I�€&Â$À%uš¨$ÀõM„ �$ÀéÜ$�u$ÀÞ¸ü‡ô[$ÀÓ”Ô hB$ÀÈp¬‹Û($À½L„ O$À²(\�Âõ#À§46Ü#Àœà “©Â#À�¼ã©#À…˜»–��#Àzt“v#ÀoPkšw\#Àd,CëB#ÀYž^)#ÀNäòÒ#ÀBÀÊ¡Eö"À7œ¢#¹Ü"À,xz¥,Ã"À!TR' ©"À0*©�"À +‡v"ÀèÙ¬ú\"Àõñ.nC"À韉°á)"ÀÞ{a2U"ÀÓW9´Èö!ÀÈ36<Ý!À½é·¯Ã!À²ëÀ9#ª!À§Ç˜»–�!Àœ£p= w!À�H¿}]!À…[ AñC!Àz7øÂd*!ÀoÐDØ!Àdï§ÆK÷ ÀYËH¿Ý ÀN§WÊ2Ä ÀCƒ/L¦ª À7_Α À,;ßO�w À!·Ñ^ ÀóŽStD À ÏfÕç* À«>W[ Àé -²�ïÀÓÅܵ„¼À½}Œ¹k‰À¦5<½RVÀ�íëÀ9#Àz¥›Ä ðÀd]KȽÀMûËî‰À7ͪÏÕVÀ!…ZÓ¼#À = ×£ðÀôô¹ÚнÀÞ¬iÞqŠÀÈdâXWÀ²Éå?$À›Ôxé&ñÀ…Œ(í ¾ÀoDØðôŠÀYü‡ôÛWÀB´7øÂ$À,lçû©ñÀ$—ÿ�¾ÀÜFx‹Àé“ö_XÀÓK¦ F%À½V-òÀ§»¿À�sµû‹Àz+eâXÀdãÉ%ÀN›Ä °òÀ7St$—¿À! $(~ŒÀ ÃÓ+eYÀôzƒ/L&ÀÞ2333óÀÈêâ6ÀÀ²¢’:�À›ZB>èYÀ…òAÏ&ÀoÊ¡E¶óÀY‚QI�ÀÀB:M„�À,ò°PkZÀª`TR'ÀbX9ôÀéÀ[ ÁÀÓÑo_ŽÀ½‰cîZÀ§AÏfÕ'À�ù~j¼ôÀz±.n£ÁÀdiÞqŠŽÀN!Žuq[À7Ù=yX(À!‘í|?õÀ I�€&ÂÀõM„ �ÀÞ¸ü‡ô[ÀÈp¬‹Û(À²(\�ÂõÀœà “©ÂÀ…˜»–��ÀoPkšw\ÀYž^)ÀBÀÊ¡EöÀ,xz¥,ÃÀ0*©�ÀèÙ¬ú\À韉°á)ÀÓW9´ÈöÀ½é·¯ÃÀ§Ç˜»–�À�H¿}]Àz7øÂd*Àdï§ÆK÷ÀN§WÊ2ÄÀ7_ΑÀ!·Ñ^À ÏfÕç*Àé -²�ïÀ½}Œ¹k‰À�íëÀ9#Àd]KȽÀ7ͪÏÕVÀ = ×£ð ÀÞ¬iÞqŠ À²Éå?$ À…Œ(í ¾ ÀYü‡ôÛW À,lçû©ñ ÀÜFx‹ ÀÓK¦ F% À§»¿ Àz+eâX ÀN›Ä °ò À! $(~Œ Àôzƒ/L& ÀÈêâ6ÀÀ›ZB>èYÀoÊ¡E¶óÀB:M„�Àª`TR'ÀéÀ[ ÁÀ½‰cîZÀ�ù~j¼ôÀdiÞqŠŽÀ7Ù=yX(À I�€&ÂÀÞ¸ü‡ô[À²(\�ÂõÀ…˜»–��ÀYž^)À,xz¥,ÃÀèÙ¬ú\ÀÓW9´ÈöÀ§Ç˜»–�Àz7øÂd*ÀN§WÊ2ÄÀ!·Ñ^Àé -²�ïÿ¿�íëÀ9#ÿ¿7ͪÏÕVþ¿Þ¬iÞqŠý¿…Œ(í ¾ü¿,lçû©ñû¿ÓK¦ F%û¿z+eâXú¿! $(~Œù¿Èêâ6Àø¿oÊ¡E¶ó÷¿ª`TR'÷¿½‰cîZö¿diÞqŠŽõ¿ I�€&Âô¿²(\�Âõó¿Yž^)ó¿èÙ¬ú\ò¿§Ç˜»–�ñ¿N§WÊ2Äð¿é -²�ïï¿7ͪÏÕVî¿…Œ(í ¾ì¿ÓK¦ F%ë¿! $(~Œé¿oÊ¡E¶ó翽‰cîZæ¿ I�€&Âä¿Yž^)㿧ǘ»–�á¿é -²�ïß¿…Œ(í ¾Ü¿! $(~ŒÙ¿½‰cîZÖ¿Yž^)Ó¿é -²�ïÏ¿! $(~ŒÉ¿Yž^)ÿ! $(~Œ¹¿! $(~Œ©¿! $(~Œ©?! $(~Œ¹?Yž^)Ã?! $(~ŒÉ?é -²�ïÏ?Yž^)Ó?½‰cîZÖ?! $(~ŒÙ?…Œ(í ¾Ü?é -²�ïß?§Ç˜»–�á?Yž^)ã? I�€&Âä?½‰cîZæ?oÊ¡E¶óç?! $(~Œé?ÓK¦ F%ë?…Œ(í ¾ì?7ͪÏÕVî?é -²�ïï?N§WÊ2Äð?§Ç˜»–�ñ?èÙ¬ú\ò?Yž^)ó?²(\�Âõó? I�€&Âô?diÞqŠŽõ?½‰cîZö?ª`TR'÷?oÊ¡E¶ó÷?Èêâ6Àø?! $(~Œù?z+eâXú?ÓK¦ F%û?,lçû©ñû?…Œ(í ¾ü?Þ¬iÞqŠý?7ͪÏÕVþ?�íëÀ9#ÿ?é -²�ïÿ?!·Ñ^@N§WÊ2Ä@z7øÂd*@§Ç˜»–�@ÓW9´Èö@èÙ¬ú\@,xz¥,Ã@Yž^)@…˜»–��@²(\�Âõ@Þ¸ü‡ô[@ I�€&Â@7Ù=yX(@diÞqŠŽ@�ù~j¼ô@½‰cîZ@éÀ[ Á@ª`TR'@B:M„�@oÊ¡E¶ó@›ZB>èY@Èêâ6À@ôzƒ/L& @! $(~Œ @N›Ä °ò @z+eâX @§»¿ @ÓK¦ F% @ÜFx‹ @,lçû©ñ @Yü‡ôÛW @…Œ(í ¾ @²Éå?$ @Þ¬iÞqŠ @ = ×£ð @7ͪÏÕV@d]KȽ@�íëÀ9#@½}Œ¹k‰@é -²�ï@ ÏfÕç*@!·Ñ^@7_Α@N§WÊ2Ä@dï§ÆK÷@z7øÂd*@�H¿}]@§Ç˜»–�@½é·¯Ã@ÓW9´Èö@韉°á)@èÙ¬ú\@0*©�@,xz¥,Ã@BÀÊ¡Eö@Yž^)@oPkšw\@…˜»–��@œà “©Â@²(\�Âõ@Èp¬‹Û(@Þ¸ü‡ô[@õM„ �@ I�€&Â@!‘í|?õ@7Ù=yX(@N!Žuq[@diÞqŠŽ@z±.n£Á@�ù~j¼ô@§AÏfÕ'@½‰cîZ@ÓÑo_Ž@éÀ[ Á@bX9ô@ª`TR'@,ò°PkZ@B:M„�@Y‚QI�À@oÊ¡E¶ó@…òAÏ&@›ZB>èY@²¢’:�@Èêâ6À@Þ2333ó@ôzƒ/L&@ ÃÓ+eY@! $(~Œ@7St$—¿@N›Ä °ò@dãÉ%@z+eâX@�sµû‹@§»¿@½V-ò@ÓK¦ F%@é“ö_X@ÜFx‹@$—ÿ�¾@,lçû©ñ@B´7øÂ$@Yü‡ôÛW@oDØðôŠ@…Œ(í ¾@›Ôxé&ñ@²Éå?$@ÈdâXW@Þ¬iÞqŠ@ôô¹Úн@ = ×£ð@!…ZÓ¼#@7ͪÏÕV@MûËî‰@d]KȽ@z¥›Ä ð@�íëÀ9#@¦5<½RV@½}Œ¹k‰@ÓÅܵ„¼@é -²�ï@«>W[ @ ÏfÕç* @óŽStD @!·Ñ^ @,;ßO�w @7_Α @Cƒ/L¦ª @N§WÊ2Ä @YËH¿Ý @dï§ÆK÷ @oÐDØ!@z7øÂd*!@…[ AñC!@�H¿}]!@œ£p= w!@§Ç˜»–�!@²ëÀ9#ª!@½é·¯Ã!@È36<Ý!@ÓW9´Èö!@Þ{a2U"@韉°á)"@õñ.nC"@èÙ¬ú\"@ +‡v"@0*©�"@!TR' ©"@,xz¥,Ã"@7œ¢#¹Ü"@BÀÊ¡Eö"@NäòÒ#@Yž^)#@d,CëB#@oPkšw\#@zt“v#@…˜»–��#@�¼ã©#@œà “©Â#@§46Ü#@²(\�Âõ#@½L„ O$@Èp¬‹Û($@Ó”Ô hB$@Þ¸ü‡ô[$@éÜ$�u$@õM„ �$@%uš¨$@ I�€&Â$@mÅþ²Û$@!‘í|?õ$@,µûË%@7Ù=yX(%@Býe÷äA%@N!Žuq[%@YE¶óýt%@diÞqŠŽ%@o�ð¨%@z±.n£Á%@…ÕVì/Û%@�ù~j¼ô%@›§èH&@§AÏfÕ'&@²e÷äaA&@½‰cîZ&@È­Gázt&@ÓÑo_Ž&@Þõ—Ý“§&@éÀ[ Á&@õ=èÙ¬Ú&@bX9ô&@ †8ÖÅ '@ª`TR''@!ΈÒÞ@'@,ò°PkZ'@7ÙÎ÷s'@B:M„�'@N^)˧'@Y‚QI�À'@d¦yÇ)Ú'@oÊ¡E¶ó'@zîÉÃB (@…òAÏ&(@�6À[@(@›ZB>èY(@§~j¼ts(@²¢’:�(@½Æº¸�¦(@Èêâ6À(@Ó µ¦Ù(@âÝð3ðW=DÖ1ÅC�Ã>X1�5+�Ó>ŽÒpë‚SÝ>�ä�UÈŒã>-EØÕ�oè>¡ÉÜv5Rí>šAñ>Kè�#=‹ó>€öñküõ>Oæt šlø>uÃ� ïÜú>†©N%Mý>–µË<Ǽÿ>•ƒ’?>\¼_¯M?·”ùo…?Kº6B¼?_옙>ó?×OK“*?3=à Ž`?ÿòñ÷Ú– ?jUJçÌ ?Çcï¯ ?òù æ18 ?Ùè¼jm?l�~U¢?ˆåDˆxk?j÷fâœ?YÜJ–Ÿ?æ)Õ»È(?à¬ÙD )?­OñŽO)?ò�B›’)?0‰œXhÕ)?ªלõ*?X ÃuBZ*?­�iMNœ*?fæ€�Þ*?¨£ +?B7Iùå`+?wÄDè¡+?``(¦â+?â�Øá#,?ôþƒŸTc,?X&5ÖC£,?Rðúìâ,?<�X‡O"-? oðja-?õ+¼¶> -?ïPÊÞ-?Þ°wA .?Žs�[.?3ÕÏ ·˜.?ø™[Ö.?Z¦Øk8/?žò+©P/?UY�Œ/?—ðZÆÈ/?( Y0?:¤5Ë( 0?B ÎÑ=0??�óS[0?÷ãݯx0?uïÖâ•0?Wð.ï²0?` âÓÏ0?o CÀ�ì0?g ,œ% 1?ˆòTK’%1?À£�¡ÖA1?Y Kyò]1?ô¡R¨åy1?Í>ë °•1?q˜VÞ?þ6�Àõ+ã?ØÄßï³8è?‚Þ%®lî?õ†¾ó?•ƒ_»ÊÅ÷?ÅPølóˆý?—©ÿ”<@7-Þ9j@z¸íâô[ @ ?n“j—@•1y§²ô@4,N’Ì@YQ¨!@SRÁj¹` @Aç–*Õ"@:&.ØôM%@…Î34�'@Ñš¯ Á‰)@ïïÖ Á*@@Gl~Xç*@î›!À›�)@^šÈBºd&@PæÍ9!@…�ܧ:ü@ ,!aowü?ˆ2q  ? ,!aowü?…�ܧ:ü@PæÍ9!@^šÈBºd&@í›!À›�)@?Gl~Xç*@îïÖ Á*@Ñš¯ Á‰)@…Î34�'@:&.ØôM%@Aç–*Õ"@SRÁj¹` @[Q¨!@3,N’Ì@–1y§²ô@ ?n“j—@z¸íâô[ @8-Þ9j@˜©ÿ”<@ÅPølóˆý?’ƒ_»ÊÅ÷?ó†¾ó?~Þ%®lî?ØÄßï³8è?ù6�Àõ+ã?Ûh>q˜VÞ?Ý©ÌäØ×?´è£»ÇÒ?ñ›D„ãSÍ?y~mA<Ç?=eÚÁ?Ϧ(ä&÷»?‹Ïýnœ‰µ?W×Ašß°?E¸dëÁ©?ƒîä#@¤?8³Þ­G‰ž?2Ó¨·ú6˜?èÀÆ}¸í‘?]ÑBd\ëŒ? ²£¢Ï„?±;|ÿN�?nê<ÓçÄw?I_ûÒgàt?üj%!}j?Á3šJ“i?]U”œâH\?Jÿ›êk`?6Ù¡ ¿K?>Çýu³U?:ïØÕŸ6?èŸYûQM?ÁÞÂþˆ?Ö-ÆMÓE?P2TÑ ?)zE�vxA?|T\•w-?Ù47aWÞ=?ts¼ô7L2?RÈ!å:?g¿kƒE4?çom} !9?s›ðñT5?¸†Ž*28?žÚ-~_Þ5?ÊPá8¨`7?ßbÕ6,6?1CÂÍì6?dy*0,6?PÀCJ£™6?ß®ö*$6?�s*áX6?âñXHÇ 6?€R‡ô^"6?ŒyÑaï5?5˜S3vñ5?ò©¾»sË5?8$A€Ã5?pÜ-ûÙ¤5?@·}èù–5?w.ßìV|5?% H³k5?ô‚R5?DWŽa$?5?«ñ¦Ÿ±'5?æå*F 5?6§]üü4?WÆŽ8’æ4?ãñбµÏ4?�äT„œ¹4?™‘Ê ®¢4?¨r‚Œ4?ˆ_»Ju4?êeYg^4?ÆѬF4?­šáS/4?5<.¡´4?é̲»4?X&_è3? }uÐ3?3ocÔ·3?ËõœÿvŸ3?êÞÿê†3?HÝ9n3?0ù �ZU3?s8 jT<3?ùOµÒ"#3?¸ûU|È 3?Ë7fCð2?nªÌ•Ö2?p�]O¼¼2?—ຢ2?w›î¶�ˆ2?ð»áÄ7n2?\ŒÎó·S2?k°K’92?¥"Á‡;2?D½ ?2?ŠÍ¬è1?¤héÉÌ1?�P°�Q±1?ÎGë °•1?çªR¨åy1?dKyò]1?fÇ�¡ÖA1?Å UK’%1?k»,œ% 1?ÉøBÀ�ì0?•d âÓÏ0?Tð.ï²0?½îÖâ•0?Àõݯx0?|�óS[0?_ ÎÑ=0?Ô§5Ë( 0?Î$ Y0?�õZÆÈ/?@öY�Œ/?Vø+©P/??¦Øk8/?+‘[Ö.?Z·Ï ·˜.?oy�[.?´ywA .?“2PÊÞ-?%_¼¶> -?ÐOoðja-?ƒ‡X‡O"-?N[ðúìâ,?†5ÖC£,?;µƒŸTc,?*½Øá#,?•`(¦â+?°Dè¡+?Ð1Iùå`+?²½£ +?��Þ*?�˜iMNœ*?eîÂuBZ*?¬לõ*?�›œXhÕ)?^A�B›’)?£?ñŽO)?%ÑÙD )?àë>»È(?ä0�µô„(?딑ñ@(?º E²ü'?³¨m7¸'?x&­�s'?:o̦‘.'?¸ø˜úgé&?÷{íP¤&?€çJj^&?bý%�—&?8ŽRÇ�Ò%?‹ƒ˜MŒ%?6ó?š€º6B¼?oHûo…?X…¼_¯M?,c“?­¤Ë<Ǽÿ>ûM%Mý>¯â› ïÜú>=æt šlø>!uñküõ>ìª�#=‹ó>ΧšAñ>ÎÉÙv5Rí>W“ØÕ�oè>–‘UÈŒã>aôxë‚SÝ>�‹‰5+�Ó>V5ÅC�Ã>SNODX‹€‹ �Ài ŽXTREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿHEAPXÀ�P€‹ � @__uuid$a @ __baseline ?@4 4ÿ H __curvestyleb 8__shade ?@4 4ÿ H __formatc P &__showlabelFALSETRUE 8 __number  0 __dict Èfèh H annotationsd @xlabel e @xunitf @ylabelg @yunith P &autoscaleFALSETRUE P &xscalelogFALSETRUE H xscalemini H xscalemaxj P &yscalelogFALSETRUE H yscalemink H yscalemaxl°—`TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ`¸HEAPX ¨–xydatametadata8s007: magnitude_spectrum(s004)s008: magnitude_spectrum(s005)s009: magnitude_spectrum(s006)H˜¾Ð @titlem(ôô ?@4 4ÿ ™@xÞ2333ó(ÀÓ µ¦Ù(ÀÈêâ6À(À½Æº¸�¦(À²¢’:�(À§~j¼ts(À›ZB>èY(À�6À[@(À…òAÏ&(ÀzîÉÃB (ÀoÊ¡E¶ó'Àd¦yÇ)Ú'ÀY‚QI�À'ÀN^)˧'ÀB:M„�'À7ÙÎ÷s'À,ò°PkZ'À!ΈÒÞ@'Àª`TR''À †8ÖÅ 'ÀbX9ô&Àõ=èÙ¬Ú&ÀéÀ[ Á&ÀÞõ—Ý“§&ÀÓÑo_Ž&ÀÈ­Gázt&À½‰cîZ&À²e÷äaA&À§AÏfÕ'&À›§èH&À�ù~j¼ô%À…ÕVì/Û%Àz±.n£Á%Ào�ð¨%ÀdiÞqŠŽ%ÀYE¶óýt%ÀN!Žuq[%ÀBýe÷äA%À7Ù=yX(%À,µûË%À!‘í|?õ$ÀmÅþ²Û$À I�€&Â$À%uš¨$ÀõM„ �$ÀéÜ$�u$ÀÞ¸ü‡ô[$ÀÓ”Ô hB$ÀÈp¬‹Û($À½L„ O$À²(\�Âõ#À§46Ü#Àœà “©Â#À�¼ã©#À…˜»–��#Àzt“v#ÀoPkšw\#Àd,CëB#ÀYž^)#ÀNäòÒ#ÀBÀÊ¡Eö"À7œ¢#¹Ü"À,xz¥,Ã"À!TR' ©"À0*©�"À +‡v"ÀèÙ¬ú\"Àõñ.nC"À韉°á)"ÀÞ{a2U"ÀÓW9´Èö!ÀÈ36<Ý!À½é·¯Ã!À²ëÀ9#ª!À§Ç˜»–�!Àœ£p= w!À�H¿}]!À…[ AñC!Àz7øÂd*!ÀoÐDØ!Àdï§ÆK÷ ÀYËH¿Ý ÀN§WÊ2Ä ÀCƒ/L¦ª À7_Α À,;ßO�w À!·Ñ^ ÀóŽStD À ÏfÕç* À«>W[ Àé -²�ïÀÓÅܵ„¼À½}Œ¹k‰À¦5<½RVÀ�íëÀ9#Àz¥›Ä ðÀd]KȽÀMûËî‰À7ͪÏÕVÀ!…ZÓ¼#À = ×£ðÀôô¹ÚнÀÞ¬iÞqŠÀÈdâXWÀ²Éå?$À›Ôxé&ñÀ…Œ(í ¾ÀoDØðôŠÀYü‡ôÛWÀB´7øÂ$À,lçû©ñÀ$—ÿ�¾ÀÜFx‹Àé“ö_XÀÓK¦ F%À½V-òÀ§»¿À�sµû‹Àz+eâXÀdãÉ%ÀN›Ä °òÀ7St$—¿À! $(~ŒÀ ÃÓ+eYÀôzƒ/L&ÀÞ2333óÀÈêâ6ÀÀ²¢’:�À›ZB>èYÀ…òAÏ&ÀoÊ¡E¶óÀY‚QI�ÀÀB:M„�À,ò°PkZÀª`TR'ÀbX9ôÀéÀ[ ÁÀÓÑo_ŽÀ½‰cîZÀ§AÏfÕ'À�ù~j¼ôÀz±.n£ÁÀdiÞqŠŽÀN!Žuq[À7Ù=yX(À!‘í|?õÀ I�€&ÂÀõM„ �ÀÞ¸ü‡ô[ÀÈp¬‹Û(À²(\�ÂõÀœà “©ÂÀ…˜»–��ÀoPkšw\ÀYž^)ÀBÀÊ¡EöÀ,xz¥,ÃÀ0*©�ÀèÙ¬ú\À韉°á)ÀÓW9´ÈöÀ½é·¯ÃÀ§Ç˜»–�À�H¿}]Àz7øÂd*Àdï§ÆK÷ÀN§WÊ2ÄÀ7_ΑÀ!·Ñ^À ÏfÕç*Àé -²�ïÀ½}Œ¹k‰À�íëÀ9#Àd]KȽÀ7ͪÏÕVÀ = ×£ð ÀÞ¬iÞqŠ À²Éå?$ À…Œ(í ¾ ÀYü‡ôÛW À,lçû©ñ ÀÜFx‹ ÀÓK¦ F% À§»¿ Àz+eâX ÀN›Ä °ò À! $(~Œ Àôzƒ/L& ÀÈêâ6ÀÀ›ZB>èYÀoÊ¡E¶óÀB:M„�Àª`TR'ÀéÀ[ ÁÀ½‰cîZÀ�ù~j¼ôÀdiÞqŠŽÀ7Ù=yX(À I�€&ÂÀÞ¸ü‡ô[À²(\�ÂõÀ…˜»–��ÀYž^)À,xz¥,ÃÀèÙ¬ú\ÀÓW9´ÈöÀ§Ç˜»–�Àz7øÂd*ÀN§WÊ2ÄÀ!·Ñ^Àé -²�ïÿ¿�íëÀ9#ÿ¿7ͪÏÕVþ¿Þ¬iÞqŠý¿…Œ(í ¾ü¿,lçû©ñû¿ÓK¦ F%û¿z+eâXú¿! $(~Œù¿Èêâ6Àø¿oÊ¡E¶ó÷¿ª`TR'÷¿½‰cîZö¿diÞqŠŽõ¿ I�€&Âô¿²(\�Âõó¿Yž^)ó¿èÙ¬ú\ò¿§Ç˜»–�ñ¿N§WÊ2Äð¿é -²�ïï¿7ͪÏÕVî¿…Œ(í ¾ì¿ÓK¦ F%ë¿! $(~Œé¿oÊ¡E¶ó翽‰cîZæ¿ I�€&Âä¿Yž^)㿧ǘ»–�á¿é -²�ïß¿…Œ(í ¾Ü¿! $(~ŒÙ¿½‰cîZÖ¿Yž^)Ó¿é -²�ïÏ¿! $(~ŒÉ¿Yž^)ÿ! $(~Œ¹¿! $(~Œ©¿! $(~Œ©?! $(~Œ¹?Yž^)Ã?! $(~ŒÉ?é -²�ïÏ?Yž^)Ó?½‰cîZÖ?! $(~ŒÙ?…Œ(í ¾Ü?é -²�ïß?§Ç˜»–�á?Yž^)ã? I�€&Âä?½‰cîZæ?oÊ¡E¶óç?! $(~Œé?ÓK¦ F%ë?…Œ(í ¾ì?7ͪÏÕVî?é -²�ïï?N§WÊ2Äð?§Ç˜»–�ñ?èÙ¬ú\ò?Yž^)ó?²(\�Âõó? I�€&Âô?diÞqŠŽõ?½‰cîZö?ª`TR'÷?oÊ¡E¶ó÷?Èêâ6Àø?! $(~Œù?z+eâXú?ÓK¦ F%û?,lçû©ñû?…Œ(í ¾ü?Þ¬iÞqŠý?7ͪÏÕVþ?�íëÀ9#ÿ?é -²�ïÿ?!·Ñ^@N§WÊ2Ä@z7øÂd*@§Ç˜»–�@ÓW9´Èö@èÙ¬ú\@,xz¥,Ã@Yž^)@…˜»–��@²(\�Âõ@Þ¸ü‡ô[@ I�€&Â@7Ù=yX(@diÞqŠŽ@�ù~j¼ô@½‰cîZ@éÀ[ Á@ª`TR'@B:M„�@oÊ¡E¶ó@›ZB>èY@Èêâ6À@ôzƒ/L& @! $(~Œ @N›Ä °ò @z+eâX @§»¿ @ÓK¦ F% @ÜFx‹ @,lçû©ñ @Yü‡ôÛW @…Œ(í ¾ @²Éå?$ @Þ¬iÞqŠ @ = ×£ð @7ͪÏÕV@d]KȽ@�íëÀ9#@½}Œ¹k‰@é -²�ï@ ÏfÕç*@!·Ñ^@7_Α@N§WÊ2Ä@dï§ÆK÷@z7øÂd*@�H¿}]@§Ç˜»–�@½é·¯Ã@ÓW9´Èö@韉°á)@èÙ¬ú\@0*©�@,xz¥,Ã@BÀÊ¡Eö@Yž^)@oPkšw\@…˜»–��@œà “©Â@²(\�Âõ@Èp¬‹Û(@Þ¸ü‡ô[@õM„ �@ I�€&Â@!‘í|?õ@7Ù=yX(@N!Žuq[@diÞqŠŽ@z±.n£Á@�ù~j¼ô@§AÏfÕ'@½‰cîZ@ÓÑo_Ž@éÀ[ Á@bX9ô@ª`TR'@,ò°PkZ@B:M„�@Y‚QI�À@oÊ¡E¶ó@…òAÏ&@›ZB>èY@²¢’:�@Èêâ6À@Þ2333ó@ôzƒ/L&@ ÃÓ+eY@! $(~Œ@7St$—¿@N›Ä °ò@dãÉ%@z+eâX@�sµû‹@§»¿@½V-ò@ÓK¦ F%@é“ö_X@ÜFx‹@$—ÿ�¾@,lçû©ñ@B´7øÂ$@Yü‡ôÛW@oDØðôŠ@…Œ(í ¾@›Ôxé&ñ@²Éå?$@ÈdâXW@Þ¬iÞqŠ@ôô¹Úн@ = ×£ð@!…ZÓ¼#@7ͪÏÕV@MûËî‰@d]KȽ@z¥›Ä ð@�íëÀ9#@¦5<½RV@½}Œ¹k‰@ÓÅܵ„¼@é -²�ï@«>W[ @ ÏfÕç* @óŽStD @!·Ñ^ @,;ßO�w @7_Α @Cƒ/L¦ª @N§WÊ2Ä @YËH¿Ý @dï§ÆK÷ @oÐDØ!@z7øÂd*!@…[ AñC!@�H¿}]!@œ£p= w!@§Ç˜»–�!@²ëÀ9#ª!@½é·¯Ã!@È36<Ý!@ÓW9´Èö!@Þ{a2U"@韉°á)"@õñ.nC"@èÙ¬ú\"@ +‡v"@0*©�"@!TR' ©"@,xz¥,Ã"@7œ¢#¹Ü"@BÀÊ¡Eö"@NäòÒ#@Yž^)#@d,CëB#@oPkšw\#@zt“v#@…˜»–��#@�¼ã©#@œà “©Â#@§46Ü#@²(\�Âõ#@½L„ O$@Èp¬‹Û($@Ó”Ô hB$@Þ¸ü‡ô[$@éÜ$�u$@õM„ �$@%uš¨$@ I�€&Â$@mÅþ²Û$@!‘í|?õ$@,µûË%@7Ù=yX(%@Býe÷äA%@N!Žuq[%@YE¶óýt%@diÞqŠŽ%@o�ð¨%@z±.n£Á%@…ÕVì/Û%@�ù~j¼ô%@›§èH&@§AÏfÕ'&@²e÷äaA&@½‰cîZ&@È­Gázt&@ÓÑo_Ž&@Þõ—Ý“§&@éÀ[ Á&@õ=èÙ¬Ú&@bX9ô&@ †8ÖÅ '@ª`TR''@!ΈÒÞ@'@,ò°PkZ'@7ÙÎ÷s'@B:M„�'@N^)˧'@Y‚QI�À'@d¦yÇ)Ú'@oÊ¡E¶ó'@zîÉÃB (@…òAÏ&(@�6À[@(@›ZB>èY(@§~j¼ts(@²¢’:�(@½Æº¸�¦(@Èêâ6À(@Ó µ¦Ù(@0[AÏbu?«žÄ[ìùv?æF‡öŸ[{?� ]¾"€? ·”>’Lƒ?#•Cfk…?i¬¼d1Vˆ?~%ß\k‰?%MYFî­‹?ÅS¼Š…‹?®½lÀÝ�?Μ°¬C•‹?¸óð¤FŒ?|L?�Ÿ‰?²2Ùt ‰‰?¨á("Kóv?ö äsu?ŒÉy$~Zt?è¥Û>šu?‘OIOÌ9|?šCˆ_ÿ}?¦b¢ ; ‹?ø†³È…³†?ÕéÑ\¶ˆ?±nFãœvƒ?m­`޲„?�Xx�¨}? |•½ÃÐ~?ªð¾ß‡£s?vd×ís?ovñ’in?ôÿ´`× m?°3k?Üu?›ZàIs–t?‡®xNŸ€?ôB�hÝ}?LA` ^ä„?WN˜èƒ?Î(ÛQÒ§ˆ?¡´j�9†?¼ÄÜß‹?Á-ÿ×¥ç‡?W¸ÛbÞ‹?PØzyƇ?3tz9}œŠ?®tP=‚…?èwç备?J‰‘5�?é’ð°_�?°ã{FK¼u?À«x§Ïát?“â»2`? ý‰€�k?ª)nø·Ðn?…ÜIh|?ŽYôÅ}?þ?︜˄?"rêrL…?¶4ì BŠ?hˆG³�.Š?€\8˜Ù�?{ÁùIæìŒ?v¿ñ‹øŽ?ímòËôŒ?Ū¡ð#�?Íêýñ‰?ËeƒV>ˆ?à~u²„?s×Ê]²€?BìÉ"îÌw?@ECõ»–m?i¡¯?lÊT?vò¯|:lT?/–¥0(k?C|¸Z%x?—6$».—~?vMÆ]f„?’pÿ’ F†?<ˆì™WöŠ? ÷kj+9‹?«‘8‰ÉJ�?ŠúÔOl±�?‹}Â¥z�?¯Y¢¥L�?¤x£E�ª�?”uu‹2æ‰?·œ?iA¥?øDw?û(à´š˜?Mǘ8Dt?tI6…ã·”?%…©ž}~?mrº9S�?êµ.$v]ˆ?yòˆóIº†?êÍ[Gå‘?‰jT-�†y?zÞ6�K=˜?> ¦°_4s?ÕoŒ)qž?0¯•$ä¼€?õ¯R’Å¡?8/¡l‘‡?\ùœhT£?t׊�©PŠ?΃è�m‘£?ôị˜•‡?ÃÐ# :¢?û¦›YT}?J|Ëgð�?Ÿ0šqüs?H'–ûœ…’?¼IEÓM’?vÝ /¸l?JÓ¾e¤?f,Ë�tu›?Fˆe’#q²?LAC•·¤°?ÐrÓ •;¾?~»¨£©¾?&†`¿.‘Ç?ÉHÙÒó}É?úTî�pùÑ?#ôÉ{ NÔ?ˆQÿÖLÛ?ÅßÏŠÔß?òoHájÏä?DÏø­oÍè?D#ÁöJØï?£âèëq9ó?x¬m�Tø?x;»`ò„ý?m ˆèfm@^@;¨PP@馺Qu @OŒc”~@ˆýå½õ@nÓ¨@—ëÂg“ @TŒÚ�èJ @—t m;Î"@‡ŒþÀé6%@VÞ™‘Ë–'@á– /ôt)@¦ZÁº�¾*@Mòô¬•×*@ñú&®’)@šÜT’·œ?1’ê–.­€?1BH‹ Åž?gã$^ZT…?h]8Kf, ?_xÁs­‡?)öÊ# ?³užÓÆ?�³Qž?€]u¤‚?²‹ÂØœš?¢·I"JÅy?êÀcG••? -xÿæv?Žìd(åˆ�?ŒƒM¾j,‚?/ J´Ö¯ƒ?8ÍôÓ_ �?VµNRv?Ý«,+@”?Õ9ççnp|?z¥ßo™?T{}Áš•†?’ÁÔä)O�?fÈCçFùŒ?N~},³*Ÿ?§ï-d;�?d\ŽXGšž?ʉ-©ÂÑŒ?Í [Ðv«›? nšØn†?œ“ZÚ<Þ–?ß¹Õ98f|?­Ùr¿ø�?ì“ÓİÜt?t'‹fJ¾…? C Í[�?0ºËÙ¥½w?04Óé†Y‹?³Sb¾º‡v?Ö)û¾²M’?‹ŸÕñ‚?ž“*ðˆã•?í|½‘Kˆ?~û‰wù—?˜xÇúY‹?‚ û;H˜?M‹k’‹?KQ×~Ж?lµÇÔ»‡?Ú—oúÂã“?!åÆ<Ê)‚?åѽP �?„ô«áÁüw?9žæ•ª‡?@Œ¶ú}ƒp? ˆ+O< ?$ñvóA¢u?2 ~®*r?Å­ñç±€?.Þ@/Àp?Õ%�3´÷†?!�W`°iz?£§j¿¡–Œ?h~C@‰‚?ÆÁ^�f�?Ëói’¼x†?®¡P‘?3/kÒ*Oˆ?aîbír‘?q0å‡?iURlM�?àÿP1¦s…?“ȨsŒ?¿‰[u Z�?&‰’0dž?%å¨m{ìw?>«òŠÞ¶?�Â.Mh?¸®Ï¿IYp? ªºêfg?'|�¥qb?7”9‰f5z?°‰½%!Ñw?ÈÝWr î„?|ìâµÍƒ?ÍX5%rx‹?¤uu‹2æ‰?cy£E�ª�?)Y¢¥L�?Š}Â¥z�?ÈûÔOl±�?¬‘8‰ÉJ�?u÷kj+9‹?�‡ì™WöŠ?‘pÿ’ F†?tMÆ]f„?e4$».—~?T{¸Z%x?Б¥0(k?;õ¯|:lT?5©¯?lÊT?`HCõ»–m?ËìÉ"îÌw?ð×Ê]²€?E~u²„?°dƒV>ˆ?~Íêýñ‰?Þ©¡ð#�?“îmòËôŒ?®¿ñ‹øŽ?~ÁùIæìŒ?Á\8˜Ù�?l‡G³�.Š?s4ì BŠ?ŽqêrL…?÷?︜˄?ŽYôÅ}?sÜIh|?â(nø·Ðn?%ý‰€�k?$L< >2`??Ý»; ‹?ƒäâCˆ?�•,\Ï¡‹?�à–ô„!ˆ?��ò;€Š? X ÌY†?ƒåÁv$±‡?E· g®ƒ?-c¢ šu?nÈy$~Zt?ZŸäsu?_<("Kóv?šµÉ‘ì~?µi¨+á“€?Ñ.*L[…?dã’Lƒ?¸ ]¾"€?kD‡öŸ[{?¢¡Ä[ìùv?SNOD7¨¹È»˜TREEÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿHEAPXè»P¨¹È» @__uuid$n @ __baseline ?@4 4ÿ H __curvestyleo 8__shade ?@4 4ÿ H __formatp P &__showlabelFALSETRUE 8 __number  0 __dict h”ˆ– H annotationsq @xlabel r @xunits @ylabelt @yunitu P &autoscaleFALSETRUE P &xscalelogFALSETRUE H xscaleminv H xscalemaxw P &yscalelogFALSETRUE H yscaleminx H yscalemaxyDataLab-Platform-SigimaX-0182bf4/sigimax/env.py000066400000000000000000000436421524425144300212730ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX environment utilities (:mod:`sigimax.env`) ================================================== .. autoclass:: VerbosityLevels :members: .. autoclass:: SGMXExecEnv :members: """ from __future__ import annotations __all__ = [ "SGMXExecEnv", "VerbosityLevels", "execenv", ] import argparse import enum import os import platform import pprint import sys import traceback from contextlib import contextmanager from typing import Any, Generator from guidata.env import ExecEnv as GuiDataExecEnv from sigimax._metadata import __version__ # We could import DEBUG from sigimax.config, but is it really worth it? DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true") class VerbosityLevels(enum.Enum): """Print verbosity levels (for testing purpose)""" QUIET = "quiet" NORMAL = "normal" DEBUG = "debug" # TODO: [P3] Rewrite this class so that options are automatically associated with # environment variables and command line arguments. # # Use the unit test "datalab\tests\backbone\execenv_unit.py" to check that # everything still works as expected. # # This could be done using objects deriving from something like this (and # implementing integer, boolean, string, choices): # # class EnvVar: # """Descriptor for handling attributes # associated with environment variables""" # # def __init__( # self, name: str, default: Optional[str] = None, # argname: Optional[str] = None # ): # """ # Initialize the EnvVar descriptor. # # Args: # name: The name of the associated environment variable. # default: The default value for the attribute. # argname: The name of the command-line argument (optional). # """ # self.name = name # self.default = default # self.argname = argname # # def __get__(self, instance: Optional[object], # owner: type) -> Optional[str]: # """ # Get the value of the attribute. # # Args: # instance: The instance of the class. # owner: The class that owns the attribute. # # Returns: # The value of the attribute. # """ # if instance is None: # return self # value = os.environ.get(self.name) # if value is None: # return self.default # return self._envvar_to_value(value) # # def __set__(self, instance: object, value: Optional[str]) -> None: # """ # Set the value of the attribute. # # Args: # instance: The instance of the class. # value: The value to be set. # """ # if value is not None: # os.environ[self.name] = self._value_to_envvar(value) # elif self.name in os.environ: # os.environ.pop(self.name) # # def __delete__(self, instance: object) -> None: # """ # Delete the attribute and remove the associated environment variable. # # Args: # instance: The instance of the class. # """ # os.environ.pop(self.name, None) # # def add_argument(self, parser: argparse.ArgumentParser) -> None: # """ # Add the command-line argument to the given ArgumentParser. # # Args: # parser: The ArgumentParser to add the argument to. # """ # parser.add_argument( # f"--{self.argname}", # default=self.default, # help=f"{self.argname} (environment variable: {self.name})", # ) # # def _envvar_to_value(self, value: str) -> str: # """ # Convert the environment variable value to the attribute value. # This method must be implemented by subclasses. # """ # return value # # def _value_to_envvar(self, value: str) -> str: # """ # Convert the attribute value to the environment variable value. # This method must be implemented by subclasses. # """ # return value # class SGMXExecEnv: """Object representing SigimaX test environment""" UNATTENDED_ARG = "unattended" ACCEPT_DIALOGS_ARG = "accept_dialogs" VERBOSE_ARG = "verbose" SCREENSHOT_ARG = "screenshot" SCREENSHOT_PATH_ARG = "screenshot_path" DELAY_ARG = "delay" DO_NOT_QUIT_ENV = "SIGIMAX_DO_NOT_QUIT" UNATTENDED_ENV = GuiDataExecEnv.UNATTENDED_ENV ACCEPT_DIALOGS_ENV = GuiDataExecEnv.ACCEPT_DIALOGS_ENV VERBOSE_ENV = GuiDataExecEnv.VERBOSE_ENV SCREENSHOT_ENV = GuiDataExecEnv.SCREENSHOT_ENV SCREENSHOT_PATH_ENV = GuiDataExecEnv.SCREENSHOT_PATH_ENV DELAY_ENV = GuiDataExecEnv.DELAY_ENV CATCHER_TEST_ENV = "SIGIMAX_CATCHER_TEST" def __init__(self, parse_args: bool = True): self.h5files = None self.h5browser_file = None self.demo_mode = False # Check if "pytest" is in the command line arguments: if parse_args and "pytest" not in sys.argv[0]: # Do not parse command line arguments when running tests with pytest # (otherwise, pytest arguments are parsed as SigimaX arguments) self.parse_args() if self.unattended: # Do not run this code in production # Check that calling `to_dict` do not raise any exception self.to_dict() def iterate_over_attrs_envvars(self) -> Generator[tuple[str, str], None, None]: """Iterate over SigimaX environment variables Yields: A tuple (attribute name, environment variable name) """ for name in dir(self): if name.endswith("_ENV"): envvar: str = getattr(self, name) attrname = "_".join(name.split("_")[:-1]).lower() yield attrname, envvar def to_dict(self): """Return a dictionary representation of the object""" # The list of properties match the list of environment variable attribute names, # modulo the "_ENV" suffix: props = [attrname for attrname, _envvar in self.iterate_over_attrs_envvars()] # Check that all properties are defined in the class and that they are # really properties: for prop in props: assert hasattr(self, prop), ( f"Property {prop} is not defined in class {self.__class__.__name__}" ) assert isinstance(getattr(self.__class__, prop), property), ( f"Attribute {prop} is not a property in class {self.__class__.__name__}" ) # Add complementary properties: props += [ "h5files", "h5browser_file", "demo_mode", ] # Return a dictionary with the properties as keys and their values as values: return {p: getattr(self, p) for p in props} def __str__(self): """Return a string representation of the object""" return pprint.pformat(self.to_dict()) def enable_demo_mode(self, delay: int): """Enable demo mode Args: delay: Delay (ms) before quitting application in unattended mode """ self.demo_mode = True self.unattended = True self.delay = delay def disable_demo_mode(self): """Disable demo mode""" self.demo_mode = False self.unattended = False self.delay = 0 @staticmethod def __get_mode(env): """Get mode value""" env_val = os.environ.get(env) if env_val is None: return False return env_val.lower() in ("1", "true", "yes", "on", "enable", "enabled") @staticmethod def __set_mode(env, value): """Set mode value""" if env in os.environ: os.environ.pop(env) if value: os.environ[env] = "1" @property def do_not_quit(self): """Keep QApplication running (and widgets opened) after test execution, even in unattended mode (e.g. useful for testing the remote client API: we need to run SigimaX app in unattended mode [to avoid any user interaction during the test] but we also need to keep the QApplication running to be able to send commands to the remote client API). """ return self.__get_mode(self.DO_NOT_QUIT_ENV) @do_not_quit.setter def do_not_quit(self, value): """Set do_not_quit value""" self.__set_mode(self.DO_NOT_QUIT_ENV, value) @property def unattended(self): """Get unattended value""" return self.__get_mode(self.UNATTENDED_ENV) @unattended.setter def unattended(self, value): """Set unattended value""" self.__set_mode(self.UNATTENDED_ENV, value) @property def accept_dialogs(self): """Whether to accept dialogs in unattended mode""" return self.__get_mode(self.ACCEPT_DIALOGS_ENV) @accept_dialogs.setter def accept_dialogs(self, value): """Set whether to accept dialogs in unattended mode""" self.__set_mode(self.ACCEPT_DIALOGS_ENV, value) @property def catcher_test(self): """Get catcher_test value""" return self.__get_mode(self.CATCHER_TEST_ENV) @catcher_test.setter def catcher_test(self, value): """Set catcher_test value""" self.__set_mode(self.CATCHER_TEST_ENV, value) @property def screenshot(self): """Get screenshot value""" return self.__get_mode(self.SCREENSHOT_ENV) @screenshot.setter def screenshot(self, value): """Set screenshot value""" self.__set_mode(self.SCREENSHOT_ENV, value) @property def screenshot_path(self): """Get screenshot path""" return os.environ.get(self.SCREENSHOT_PATH_ENV, "") @screenshot_path.setter def screenshot_path(self, value): """Set screenshot path""" if value: os.environ[self.SCREENSHOT_PATH_ENV] = str(value) elif self.SCREENSHOT_PATH_ENV in os.environ: os.environ.pop(self.SCREENSHOT_PATH_ENV) @property def verbose(self): """Get verbosity level""" env_val = os.environ.get(self.VERBOSE_ENV) if env_val in (None, ""): return VerbosityLevels.NORMAL.value return env_val.lower() @verbose.setter def verbose(self, value): """Set verbosity level""" os.environ[self.VERBOSE_ENV] = value @property def delay(self): """Delay (ms) before quitting application in unattended mode""" try: return int(os.environ.get(self.DELAY_ENV)) except (TypeError, ValueError): return 0 @delay.setter def delay(self, value: int): """Set delay (ms) before quitting application in unattended mode""" os.environ[self.DELAY_ENV] = str(value) def parse_args(self): """Parse command line arguments""" # WARNING # Do not add an option '-c' to avoid any conflict with macro command # execution mecanism used with SigimaX standalone version (see start.pyw) parser = argparse.ArgumentParser(description="Run SigimaX application") parser.add_argument( "h5", nargs="?", type=str, help="HDF5 file names (separated by ';'), " "optionally with dataset name (separated by ',')", ) parser.add_argument( "-b", "--h5browser", required=False, type=str, metavar="path", help="path to open with HDF5 browser", ) parser.add_argument( "-v", "--version", action="store_true", help="show SigimaX version" ) parser.add_argument( "--reset", action="store_true", help="reset SigimaX configuration" ) parser.add_argument( "--" + self.UNATTENDED_ARG, action="store_true", help="non-interactive mode", default=None, ) parser.add_argument( "--" + self.ACCEPT_DIALOGS_ARG, action="store_true", help="accept dialogs in unattended mode", default=None, ) parser.add_argument( "--" + self.SCREENSHOT_ARG, action="store_true", help="automatic screenshots", default=None, ) parser.add_argument( "--" + self.SCREENSHOT_PATH_ARG, type=str, help="path to save screenshots", default=None, ) parser.add_argument( "--" + self.DELAY_ARG, type=int, default=None, help="delay (ms) before quitting application in unattended mode", ) parser.add_argument( "--" + self.VERBOSE_ARG, choices=[lvl.value for lvl in VerbosityLevels], required=False, default=None, help="verbosity level: for debugging/testing purpose", ) args, _unknown = parser.parse_known_args() if args.h5: self.h5files = args.h5.split(";") if args.h5browser: self.h5browser_file = args.h5browser if args.version: # Local import: `sigimax.config` pulls in the PlotPy/Qt stack, which # this module must not require just to parse command line arguments. # pylint: disable=import-outside-toplevel from sigimax.config import CONF as Conf print( ( f"{Conf.app_name} {Conf.app_version}," f" derivated from SigimaX {__version__} on {platform.system()}" ) ) sys.exit() if args.reset: # Local import: see the `--version` branch above. # pylint: disable=import-outside-toplevel from sigimax.config import CONF as Conf print("Resetting SigimaX configuration...", end=" ") try: Conf.reset_to_defaults() except Exception: # pylint: disable=broad-except print("Failed.") traceback.print_exc() finally: print("Done.") sys.exit() self.set_env_from_args(args) def set_env_from_args(self, args): """Set appropriate environment variables""" for argname in ( self.UNATTENDED_ARG, self.ACCEPT_DIALOGS_ARG, self.SCREENSHOT_ARG, self.SCREENSHOT_PATH_ARG, self.VERBOSE_ARG, self.DELAY_ARG, ): argvalue = getattr(args, argname) if argvalue is not None: setattr(self, argname, argvalue) def log(self, source: Any, *objects: Any) -> None: """Log text on screen Args: source: object from which the log is issued *objects: objects to log """ if DEBUG or self.verbose == VerbosityLevels.DEBUG.value: print(str(source) + ":", *objects) # TODO: [P4] Eventually, log in a file (optionally) def print(self, *objects, sep=" ", end="\n", file=sys.stdout, flush=False): """Print in file, depending on verbosity level""" if self.verbose != VerbosityLevels.QUIET.value or DEBUG: print(*objects, sep=sep, end=end, file=file, flush=flush) def pprint( self, obj, stream=None, indent=1, width=80, depth=None, compact=False, sort_dicts=True, ): """Pretty-print in stream, depending on verbosity level""" if self.verbose != VerbosityLevels.QUIET.value or DEBUG: pprint.pprint( obj, stream=stream, indent=indent, width=width, depth=depth, compact=compact, sort_dicts=sort_dicts, ) @contextmanager def context( self, unattended=None, accept_dialogs=None, screenshot=None, delay=None, verbose=None, xmlrpcport=None, catcher_test=None, ) -> Generator[None, None, None]: """Return a context manager that sets some execenv properties at enter, and restores them at exit. This is useful to run some code in a controlled environment, for example to accept dialogs in unattended mode, and restore the previous value at exit. Args: unattended: whether to run in unattended mode accept_dialogs: whether to accept dialogs in unattended mode screenshot: whether to take screenshots delay: delay (ms) before quitting application in unattended mode verbose: verbosity level xmlrpcport: XML-RPC port number catcher_test: whether to run catcher test .. note:: If a passed value is None, the corresponding property is not changed. """ old_values = self.to_dict() new_values = { "unattended": unattended, "accept_dialogs": accept_dialogs, "screenshot": screenshot, "delay": delay, "verbose": verbose, "xmlrpcport": xmlrpcport, "catcher_test": catcher_test, } for key, value in new_values.items(): if value is not None: setattr(self, key, value) try: yield finally: for key, value in old_values.items(): setattr(self, key, value) execenv = SGMXExecEnv(parse_args=False) DataLab-Platform-SigimaX-0182bf4/sigimax/h5/000077500000000000000000000000001524425144300204345ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/h5/__init__.py000066400000000000000000000021601524425144300225440ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX HDF5 I/O module (:mod:`sigimax.h5`) ============================================ The importer walks an HDF5 file and turns each dataset into a :class:`~sigimax.h5.common.BaseNode` subclass instance, selected by the module-level :data:`~sigimax.h5.common.NODE_FACTORY` registry. Applications extend the supported data model by subclassing :class:`~sigimax.h5.common.BaseNode` (or :class:`~sigimax.h5.common.GroupNode`) and registering it with ``NODE_FACTORY.register(MyNode)`` — see :doc:`../user_guide/hdf5_workspace` for a worked example. .. autoclass:: sigimax.h5.common.H5Importer :members: .. autoclass:: sigimax.h5.common.NodeFactory :members: .. autoclass:: sigimax.h5.common.BaseNode :members: .. autoclass:: sigimax.h5.common.GroupNode :members: .. autoclass:: sigimax.h5.common.RootNode :members: """ __all__ = [ "H5Importer", ] # pylint: disable=unused-import # Registering dynamic I/O features: from sigimax.h5 import generic # noqa: F401 from sigimax.h5.common import H5Importer # noqa: F401 DataLab-Platform-SigimaX-0182bf4/sigimax/h5/common.py000066400000000000000000000251031524425144300222770ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX Common tools for exogenous HDF5 format support """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations import abc import os.path as osp from collections.abc import Callable import h5py import numpy as np from guidata.utils.misc import to_string from sigima.io.common.converters import convert_array_to_valid_dtype from sigima.objects import ImageObj, SignalObj from sigimax.config import get_conf def data_to_xy(data: np.ndarray) -> list[np.ndarray]: """Convert 2-D array into a list of 1-D array data (x, y, dx, dy). This is useful for importing data and creating a SigimaX signal with it. Args: data (numpy.ndarray): 2-D array of data Returns: list[np.ndarray]: list of 1-D array data (x, y, dx, dy) """ if len(data.ravel()) == len(data): return np.arange(len(data)), data.ravel(), None, None rows, cols = data.shape for colnb in (2, 3, 4): if cols == colnb and rows > colnb: data = data.T break if len(data) == 1: data = data.T if len(data) not in (2, 3, 4): raise ValueError(f"Invalid data: len(data)={len(data)} (expected 2, 3 or 4)") x, y = data[:2] dx, dy = None, None if len(data) == 3: dy = data[2] if len(data) == 4: dx, dy = data[2:] return x, y, dx, dy class BaseNode(metaclass=abc.ABCMeta): """Object representing a HDF5 node""" IS_ARRAY = False def __init__(self, h5file, dname): self.h5file = h5file self.dset = h5file[dname] self.metadata = {} self.__obj = None self.children = [] self.uint32_wng = False @property def id(self): """Return node id""" return self.dset.name @property def name(self): """Return node name, constructed from dataset name""" return to_string(self.dset.name).split("/")[-1] @property def data(self): """Data associated to node, if available""" return None @property def icon_name(self): """Icon name associated to node""" @property def shape_str(self): """Return string representation of node shape, if any""" return "" @property def dtype_str(self): """Return string representation of node data type, if any""" return "" @property def text(self): """Return node textual representation""" return "" @property def description(self): """Return node description""" return "" @classmethod def match(cls, dset): """Return True if h5 dataset match node pattern""" def is_supported(self) -> bool: """Return True if node is associated to supported data""" return False def create_native_object(self): """Create native object, if supported""" return None def get_native_object(self): """Return native object, if supported""" if self.__obj is None: obj = self.create_native_object() # pylint: disable=assignment-from-none if obj is not None: self.__process_metadata(obj) self.__obj = obj return self.__obj def collect_attributes(self): """Collect attributes from node. HDF5 attributes are opportunistically copied to the object metadata. Values that cannot be safely serialized are skipped: HDF5 object or region *reference* attributes (e.g. ``DIMENSION_LIST`` / ``REFERENCE_LIST`` arrays produced by ``h4toh5convert``) are exposed by h5py as ``object``-dtype arrays of :class:`h5py.h5r.Reference`, which raise ``TypeError: no default __reduce__`` when DataLab pickles the object to run a computation in a worker process. """ for key, value in self.dset.attrs.items(): if isinstance(value, bytes): value = to_string(value) if isinstance(value, np.ndarray): # Keep only numeric, boolean and string arrays. ``object`` and # ``void`` dtypes (how h5py exposes reference and compound # attributes) are not picklable, hence skipped. if value.dtype.kind in "biufcSU": self.metadata[key] = value elif isinstance(value, (str, float, int, bool)) or np.isscalar(value): self.metadata[key] = value def __process_metadata(self, obj): """Process metadata from dataset to obj""" obj.reset_metadata_to_defaults() obj.set_metadata_option("HDF5Path", self.h5file.filename) obj.set_metadata_option("HDF5Dataset", self.id) obj.metadata.update(self.metadata) @property def object_title(self): """Return signal/image object title""" conf = get_conf() if conf.h5_fullpath_in_title.get(): title = self.id else: title = self.name if conf.h5_fname_in_title.get(): title += f" ({osp.basename(self.h5file.filename)})" return title def set_signal_data(self, obj: SignalObj) -> None: """Set signal data (handles various issues)""" data = self.data if data.dtype not in (float, np.complex128): data = np.array(data, dtype=float) data = convert_array_to_valid_dtype(data, SignalObj.VALID_DTYPES) if len(data.shape) == 1: obj.set_xydata(np.arange(data.size), data) else: x, y, dx, dy = data_to_xy(data) obj.set_xydata(x, y, dx, dy) def set_image_data(self, obj: ImageObj) -> None: """Set image data (handles various issues)""" data = self.data if data.dtype == np.uint32: self.uint32_wng = data.max() > np.iinfo(np.int32).max clipped_data = data.clip(0, np.iinfo(np.int32).max) data = np.array(clipped_data, dtype=np.int32) obj.data = convert_array_to_valid_dtype(data, ImageObj.VALID_DTYPES) class H5Importer: """SigimaX HDF5 importer class""" def __init__(self, filename): self.h5file = h5py.File(filename) self.__nodes = {} self.root = RootNode(self.h5file) self.__nodes[self.root.id] = self.root self.root.collect_children(self.__nodes) NODE_FACTORY.run_post_triggers(self) @property def nodes(self): """Return all nodes""" return self.__nodes.values() def get(self, node_id: str): """Return node associated to id""" return self.__nodes[node_id] def get_relative(self, node: BaseNode, relpath: str, ancestor: int = 0): """Return node using relative path to another node""" path = "/" + ( "/".join(node.id.split("/")[:-ancestor]) + "/" + relpath.strip("/") ).strip("/") return self.__nodes[path] def close(self): """Close HDF5 file""" self.__nodes = {} self.h5file.close() class NodeFactory: """Factory for node classes""" def __init__(self): self.__ignored_datasets = [] self.__generic_classes = [] self.__thirdparty_classes = [] self.__post_triggers = {} def add_ignored_datasets(self, names): """Add h5 dataset name to ignore list""" self.__ignored_datasets.extend(names) def add_post_trigger(self, nodecls: BaseNode, callback: Callable): """Add post trigger function, to be called at the end of the collect process. Callbacks take only one argument: H5Importer instance.""" triggers = self.__post_triggers.setdefault(nodecls, []) triggers.append(callback) def register(self, cls, is_generic=False): """Register node class. Generic classes are processed after specific classes (as a fallback solution)""" if is_generic: self.__generic_classes.append(cls) else: self.__thirdparty_classes.append(cls) def get(self, dset): """Return node class that matches h5 dataset""" for name in to_string(dset.name).split("/"): if name in self.__ignored_datasets: return None for cls in self.__thirdparty_classes + self.__generic_classes: try: if cls.match(dset): return cls except (UnicodeDecodeError, TypeError, ValueError, OSError) as exc: # Skip classes that can't match this dataset due to various issues print( f"Warning: Class {cls.__name__} can't match dataset " f"'{dset.name}': {exc}" ) continue if isinstance(dset, h5py.Group): return GroupNode return None def run_post_triggers(self, importer: H5Importer): """Run post-collect callbacks""" for node in importer.nodes: for nodecls, triggers in self.__post_triggers.items(): if isinstance(node, nodecls): for func in triggers: func(node, importer) NODE_FACTORY = NodeFactory() class GroupNode(BaseNode): """Object representing a HDF5 group node""" @property def icon_name(self): """Icon name associated to node""" return "h5group.svg" def collect_children(self, node_dict: dict[str, BaseNode]): """Construct tree""" for dset in self.dset.values(): try: child_cls = NODE_FACTORY.get(dset) if child_cls is not None: child = child_cls(self.h5file, dset.name) node_dict[child.id] = child self.children.append(child) if isinstance(child, GroupNode): child.collect_children(node_dict) child.collect_attributes() except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc: # Skip datasets that can't be processed due to various issues print(f"Warning: Skipping dataset '{dset.name}' due to error: {exc}") continue class RootNode(GroupNode): """Object representing a HDF5 root node""" def __init__(self, h5file): super().__init__(h5file, "/") @property def icon_name(self): """Icon name associated to node""" return "h5file.svg" @property def name(self): """Return node name, constructed from dataset name""" return osp.basename(self.h5file.filename) @property def description(self): """Return node description""" return self.h5file.filename DataLab-Platform-SigimaX-0182bf4/sigimax/h5/generic.py000066400000000000000000000472751524425144300224410ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX Generic HDF5 format support """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import h5py import numpy as np from guidata.utils.misc import to_string from sigima.objects import create_image, create_signal from sigimax.h5 import common, utils # ============================================================================= # Encoding and Data Reading Utilities # ============================================================================= def safe_decode_bytes(data, fallback=""): """Safely decode bytes to string using multiple encoding strategies.""" if isinstance(data, str): return data if not isinstance(data, bytes): return str(data) # Try encodings in order of preference for encoding in ["utf-8", "latin1", "cp1252", "iso-8859-1", "ascii"]: try: decoded = data.decode(encoding) # For legacy encodings, validate the result looks reasonable if encoding in ["latin1", "cp1252", "iso-8859-1"]: if _is_reasonable_text(decoded): return decoded else: return decoded except (UnicodeDecodeError, UnicodeError): continue # Final fallback with replacement characters try: return data.decode("utf-8", errors="replace") except Exception: # pylint: disable=broad-except return fallback def _is_reasonable_text(text): """Check if decoded text looks reasonable (mostly printable).""" if not text: return True printable_chars = sum(1 for c in text if c.isprintable() or c.isspace()) ratio = printable_chars / len(text) # Accept if mostly printable or short enough to be likely text return ratio >= 0.8 or len(text) < 20 def safe_read_dataset(dset, fallback_data=None): """Safely read HDF5 dataset with encoding error handling.""" try: return dset[()] except UnicodeDecodeError: # Try alternative reading strategies for problematic encodings return _try_alternative_read(dset, fallback_data) except (TypeError, ValueError, OSError): return fallback_data def _try_alternative_read(dset, fallback_data): """Try alternative strategies to read datasets with encoding issues.""" strategies = [ lambda d: d.asstr()[()], # Try string conversion lambda d: d.astype("S")[()], # Try reading as bytes ] for strategy in strategies: try: return strategy(dset) except Exception: # pylint: disable=broad-except continue return fallback_data # ============================================================================= # Text Formatting Utilities # ============================================================================= def format_text_data(data): """Format various types of data for text display.""" if data is None: return "" try: return to_string(data) except (UnicodeDecodeError, UnicodeError): return _handle_encoding_issues(data) def _handle_encoding_issues(data): """Handle data with encoding issues.""" if isinstance(data, bytes): return safe_decode_bytes(data) if isinstance(data, np.ndarray): if data.dtype.kind in ["S", "a", "U"]: # String arrays return _format_string_array(data) if data.dtype.names: # Compound data return _format_compound_data(data) return f"" def _format_string_array(data): """Format string arrays with encoding handling.""" try: if data.size == 1: return safe_decode_bytes(data.item()) # Show first few elements items = [] for i, item in enumerate(data.flat): if i >= 5: items.append("...") break items.append(safe_decode_bytes(item)) return f"[{', '.join(items)}]" except Exception: # pylint: disable=broad-except return f"" def _format_compound_data(data): """Format compound data with encoding handling.""" try: result_parts = [] for field_name in data.dtype.names: field_data = data[field_name] if hasattr(field_data, "item"): field_data = field_data.item() if isinstance(field_data, bytes): field_value = safe_decode_bytes(field_data) else: field_value = str(field_data) result_parts.append(f"{field_name}: {field_value}") return f"({', '.join(result_parts)})" except Exception: # pylint: disable=broad-except return f"" # ============================================================================= # Base Node Class # ============================================================================= class BaseGenericNode(common.BaseNode): """Base class for generic HDF5 data nodes with encoding support.""" @classmethod def match(cls, dset): """Return True if h5 dataset matches this node pattern.""" return not isinstance(dset, h5py.Group) @property def icon_name(self): """Icon name associated to node.""" return "h5scalar.svg" @property def data(self): """Data associated to node, if available.""" return safe_read_dataset(self.dset, fallback_data=None) @property def dtype_str(self): """Return string representation of node data type.""" try: return str(self.dset.dtype) except (UnicodeDecodeError, TypeError, ValueError): if self.data is None: return "unknown" try: return str(self.data.dtype) except Exception: # pylint: disable=broad-except return "unknown" @property def text(self): """Return node textual representation.""" return format_text_data(self.data) # ============================================================================= # Specialized Node Classes # ============================================================================= class GenericScalarNode(BaseGenericNode): """Node for scalar HDF5 data.""" @classmethod def match(cls, dset): """Match scalar numeric data.""" if not super().match(dset): return False data = safe_read_dataset(dset) return ( data is not None and isinstance(data, np.generic) and utils.is_supported_num_dtype(data) ) class GenericTextNode(BaseGenericNode): """Node for text/string HDF5 data.""" @classmethod def match(cls, dset): """Match text or string data.""" if not super().match(dset): return False data = safe_read_dataset(dset) if data is None: # Try to match based on dtype for unreadable data try: dtype = dset.dtype return dtype.kind in ["S", "a", "U"] or "str" in str(dtype) except Exception: # pylint: disable=broad-except return False return isinstance(data, bytes) or utils.is_supported_str_dtype(data) @property def dtype_str(self): """Return simplified dtype for text data.""" return "string" @property def text(self): """Return formatted text with special handling for single arrays.""" if self.data is None: return "" try: if utils.is_single_str_array(self.data): item = self.data[0] return safe_decode_bytes(item) if isinstance(item, bytes) else str(item) return format_text_data(self.data) except (UnicodeDecodeError, UnicodeError): return _handle_text_encoding_issues(self.data) def _handle_text_encoding_issues(data): """Handle encoding issues specific to text nodes.""" if isinstance(data, bytes): return safe_decode_bytes(data) if isinstance(data, np.ndarray) and data.dtype.kind in ["S", "a"]: try: if data.size == 1: return safe_decode_bytes(data.item()) decoded = [safe_decode_bytes(item) for item in data.flat] return str(decoded[:10]) # Show first 10 elements except Exception: # pylint: disable=broad-except return f"" return "" class GenericArrayNode(BaseGenericNode): """Node for array HDF5 data, including numeric arrays from compound data.""" IS_ARRAY = True @classmethod def match(cls, dset): """Match numeric array data, including convertible compound data.""" if not super().match(dset): return False data = safe_read_dataset(dset) if data is None: return False # First check direct numeric arrays if ( utils.is_supported_num_dtype(data) and isinstance(data, np.ndarray) and len(data.shape) in (1, 2) ): return True # Then check compound data that can be converted to numeric arrays if ( isinstance(data, np.ndarray) and hasattr(data.dtype, "names") and data.dtype.names is not None ): return cls._can_convert_compound_to_numeric(data) return False @classmethod def _can_convert_compound_to_numeric(cls, data): """Check if compound data can be converted to a supported numeric array.""" try: numeric_array = cls.extract_numeric_from_compound(data) return ( numeric_array is not None and utils.is_supported_num_dtype(numeric_array) and isinstance(numeric_array, np.ndarray) and len(numeric_array.shape) in (1, 2) ) except Exception: # pylint: disable=broad-except return False @classmethod def extract_numeric_from_compound(cls, data): """Extract a numeric array from compound data.""" if not (hasattr(data.dtype, "names") and data.dtype.names): return None # Find ALL fields and check if they are numeric all_fields = list(data.dtype.names) numeric_fields = [] for field_name in all_fields: field_dtype = data.dtype.fields[field_name][0] if np.issubdtype(field_dtype, np.number): numeric_fields.append(field_name) # Only convert if ALL fields are numeric (preserve all information) # or if there's a single numeric field and no important string data if len(numeric_fields) == 0: return None if len(numeric_fields) != len(all_fields): # Mixed data - check if non-numeric fields contain meaningful data for field_name in all_fields: if field_name not in numeric_fields: field_data = data[field_name] # If there's meaningful string data, don't convert if cls._has_meaningful_string_data(field_data): return None try: # If single numeric field, extract it directly if len(numeric_fields) == 1: return data[numeric_fields[0]] # Multiple numeric fields: stack them if compatible shapes field_data = [data[field] for field in numeric_fields] # Check if all fields have the same shape shapes = [arr.shape for arr in field_data] if len(set(shapes)) == 1: # Stack along new axis to create 2D array return np.stack(field_data, axis=-1) except Exception: # pylint: disable=broad-except pass return None @classmethod def _has_meaningful_string_data(cls, field_data): """Check if string field contains meaningful data worth preserving.""" try: if hasattr(field_data, "flat"): # Check if most entries are non-empty and meaningful non_empty_count = 0 for item in field_data.flat: if isinstance(item, bytes): decoded = item.decode("utf-8", errors="ignore").strip() if decoded and len(decoded) > 0: non_empty_count += 1 elif isinstance(item, str) and item.strip(): non_empty_count += 1 # If most entries have meaningful content, preserve it return non_empty_count / field_data.size > 0.5 return True # Default to preserving unknown string data except Exception: # pylint: disable=broad-except return True # Conservative: preserve if we can't determine @property def data(self): """Data associated to node, if available.""" raw_data = safe_read_dataset(self.dset, fallback_data=None) # If this is compound data, try to extract numeric array if ( raw_data is not None and isinstance(raw_data, np.ndarray) and hasattr(raw_data.dtype, "names") and raw_data.dtype.names is not None ): numeric_data = self.extract_numeric_from_compound(raw_data) if numeric_data is not None: return numeric_data return raw_data def is_supported(self) -> bool: """Return True if node is associated to supported data""" return self.data.size > 1 @property def __is_signal(self): """Return True if array represents a signal""" shape = self.data.shape return len(shape) == 1 or shape[0] in (1, 2) or shape[1] in (1, 2) @property def icon_name(self): """Icon name associated to node""" if self.is_supported(): return "signal.svg" if self.__is_signal else "image.svg" return "h5array.svg" @property def shape_str(self): """Return string representation of node shape, if any""" return " x ".join([str(size) for size in self.data.shape]) @property def dtype_str(self): """Return string representation of node data type, if any""" return str(self.data.dtype) @property def text(self): """Return node textual representation""" return str(self.data) def create_native_object(self): """Create native object, if supported""" if self.__is_signal: obj = create_signal(self.object_title) try: self.set_signal_data(obj) except ValueError: obj = None else: obj = create_image(self.object_title) try: self.set_image_data(obj) except ValueError: obj = None return obj class GenericCompoundNode(BaseGenericNode): """Node for compound/structured HDF5 data that can't convert to numeric arrays.""" IS_ARRAY = True @classmethod def match(cls, dset): """Match compound/structured data that cannot be converted to numeric arrays.""" if not super().match(dset): return False data = safe_read_dataset(dset) if data is None: # Try to match based on dtype if we can't read the data try: return dset.dtype.names is not None except Exception: # pylint: disable=broad-except return False # Check if it's compound data (structured array) if not ( isinstance(data, np.ndarray) and hasattr(data.dtype, "names") and data.dtype.names is not None ): return False # IMPORTANT: Only match if GenericArrayNode cannot handle this data # Try to convert to a numeric array first if cls._can_convert_to_numeric_array(data): return False # Let GenericArrayNode handle it return True # We handle compound data that can't be converted @classmethod def _can_convert_to_numeric_array(cls, data): """Check if compound data can be converted to a numeric array.""" try: # Try to extract numeric fields and create a pure numeric array numeric_array = cls.extract_numeric_array(data) if numeric_array is None: return False # Check if the resulting array would be supported by GenericArrayNode return ( utils.is_supported_num_dtype(numeric_array) and isinstance(numeric_array, np.ndarray) and len(numeric_array.shape) in (1, 2) ) except Exception: # pylint: disable=broad-except return False @classmethod def extract_numeric_array(cls, data): """Try to extract a pure numeric array from compound data.""" # Use the same logic as GenericArrayNode return GenericArrayNode.extract_numeric_from_compound(data) @property def dtype_str(self): """Return detailed compound dtype information.""" try: dtype = self.dset.dtype if dtype.names: field_info = [] for name in dtype.names: field_dtype = dtype.fields[name][0] field_info.append(f"{name}: {field_dtype}") return f"compound({', '.join(field_info)})" return str(dtype) except Exception: # pylint: disable=broad-except return super().dtype_str @property def text(self): """Return formatted compound data.""" if self.data is None: return "" try: return self._format_compound_data() except Exception: # pylint: disable=broad-except return f"" def _format_compound_data(self): """Format compound data for display.""" if not (hasattr(self.data.dtype, "names") and self.data.dtype.names): return super().text if self.data.size == 1: return self._format_single_record() return self._format_multiple_records() def _format_single_record(self): """Format a single compound record.""" parts = [] for field_name in self.data.dtype.names: field_value = self.data[field_name].item() if isinstance(field_value, bytes): field_value = safe_decode_bytes(field_value) parts.append(f"{field_name}: {field_value}") return f"({', '.join(parts)})" def _format_multiple_records(self): """Format multiple compound records.""" records = [] for i, record in enumerate(self.data.flat): if i >= 3: # Show max 3 records records.append("...") break parts = [] for field_name in self.data.dtype.names: field_value = record[field_name] if isinstance(field_value, bytes): field_value = safe_decode_bytes(field_value) parts.append(f"{field_name}: {field_value}") records.append(f"({', '.join(parts)})") return f"[{', '.join(records)}]" # ============================================================================= # Node Registration # ============================================================================= # Register all node types with the factory common.NODE_FACTORY.register(GenericScalarNode, is_generic=True) common.NODE_FACTORY.register(GenericTextNode, is_generic=True) common.NODE_FACTORY.register(GenericArrayNode, is_generic=True) common.NODE_FACTORY.register(GenericCompoundNode, is_generic=True) DataLab-Platform-SigimaX-0182bf4/sigimax/h5/utils.py000066400000000000000000000053131524425144300221500ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX Utilities for exogenous HDF5 format support """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import numpy as np from guidata.utils.misc import to_string def fix_ldata(fuzzy): """Fix label data""" if fuzzy is not None: if fuzzy and isinstance(fuzzy, np.void) and len(fuzzy) > 1: # Shouldn't happen (invalid LMJ fmt) fuzzy = fuzzy[0] if isinstance(fuzzy, (np.bytes_, bytes)): fuzzy = to_string(fuzzy) if isinstance(fuzzy, str): return fuzzy return "" def fix_ndata(fuzzy): """Fix numeric data""" if fuzzy is not None: if fuzzy and isinstance(fuzzy, np.void) and len(fuzzy) > 1: # Shouldn't happen (invalid LMJ fmt) fuzzy = fuzzy[0] try: if float(fuzzy) == int(fuzzy): return int(fuzzy) return float(fuzzy) except (TypeError, ValueError): pass return None def process_scalar_value(dset, name, callback): """Process dataset numeric/str value `name`""" try: scdata = dset[name][()] if isinstance(scdata, np.ndarray): scdata = scdata[0] if scdata is not None: return callback(scdata) except (KeyError, ValueError): pass return None def process_label(dset, name): """Process dataset label `name`""" try: ldata = dset[name][()] if ldata is not None: xldata, yldata, zldata = "", "", "" if len(ldata) == 2: xldata, yldata = ldata elif len(ldata) == 3: xldata, yldata, zldata = ldata return fix_ldata(xldata), fix_ldata(yldata), fix_ldata(zldata) except KeyError: pass return "", "", "" def process_xy_values(dset, name): """Process dataset x,y values `name`""" try: ldata = dset[name][()] if ldata is not None: return fix_ndata(ldata[0]), fix_ndata(ldata[1]) except (KeyError, ValueError): pass return None, None def is_supported_num_dtype(data): """Return True if data type is a numerical type supported by SigimaX""" return data.dtype.name.startswith(("int", "uint", "float", "complex")) def is_single_str_array(data): """Return True if data is a single-item string array""" return ( isinstance(data, np.generic) and data.shape == (1,) and isinstance(data[0], str) ) def is_supported_str_dtype(data): """Return True if data type is a string type supported by preview""" return data.dtype.name.startswith("string") or is_single_str_array(data) DataLab-Platform-SigimaX-0182bf4/sigimax/locale/000077500000000000000000000000001524425144300213575ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/locale/fr/000077500000000000000000000000001524425144300217665ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/locale/fr/LC_MESSAGES/000077500000000000000000000000001524425144300235535ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/locale/fr/LC_MESSAGES/sigimax.po000066400000000000000000000402551524425144300255620ustar00rootroot00000000000000# French translations for sigimax. # Copyright (C) 2026 DataLab Platform Developers # This file is distributed under the same license as the sigimax project. # msgid "" msgstr "" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "" "SigimaX is a GUI library working with Sigima and PlotPyStack.\n" " It provides a App configuration system, a generic MainWindow class and a\n" " set of widgets to build applications on top of Sigima and PlotPyStack." msgstr "" "SigimaX est une bibliothèque graphique fonctionnant avec Sigima et PlotPyStack.\n" " Elle fournit un système de configuration d'application, une classe MainWindow générique et un\n" " ensemble de widgets pour construire des applications basées sur Sigima et PlotPyStack." msgid "General" msgstr "Général" msgid "Console" msgstr "Console" msgid "I/O" msgstr "E/S" msgid "Processing" msgstr "Traitement" msgid "Visualization" msgstr "Visualisation" msgid "Warning" msgstr "Attention" #, python-format msgid "Available memory is below %d MB.

Do you want to continue?" msgstr "La mémoire disponible est inférieure à %d Mo.

Voulez-vous continuer ?" msgid "This software is in the beta stage of its release cycle. The focus of beta testing is providing a feature complete software for users interested in trying new features before the final release. However, beta software may not behave as expected and will probably have more bugs or performance issues than completed software." msgstr "Ce logiciel est au stade bêta de son cycle de publication. L'objectif des tests bêta est de fournir un logiciel complet en fonctionnalités pour les utilisateurs souhaitant essayer de nouvelles fonctionnalités avant la version finale. Cependant, un logiciel bêta peut ne pas se comporter comme prévu et aura probablement plus de bogues ou de problèmes de performances qu'un logiciel finalisé." msgid "This software is in the alpha stage of its release cycle. The focus of alpha testing is providing an incomplete software for early testing of specific features by users. Please note that alpha software was not thoroughly tested by the developer before it is released." msgstr "Ce logiciel est au stade alpha de son cycle de publication. L'objectif des tests alpha est de fournir un logiciel incomplet pour des tests précoces de fonctionnalités spécifiques par les utilisateurs. Veuillez noter que le logiciel alpha n'a pas été testé de manière approfondie par le développeur avant sa publication." msgid "This is not a stable release." msgstr "Ceci n'est pas une version stable." msgid "Do you want to see available log files?" msgstr "Voulez-vous voir les fichiers journaux disponibles ?" #, python-format msgid "Welcome to %s!" msgstr "Bienvenue dans %s !" msgid "Main Toolbar" msgstr "Barre d'outils principale" msgid "Open HDF5 files..." msgstr "Ouvrir des fichiers HDF5..." msgid "Open one or more HDF5 files" msgstr "Ouvrir un ou plusieurs fichiers HDF5" msgid "Save to HDF5 file..." msgstr "Enregistrer dans un fichier HDF5..." msgid "Save to HDF5 file" msgstr "Enregistrer dans un fichier HDF5" msgid "Browse HDF5 file..." msgstr "Parcourir un fichier HDF5..." msgid "Browse an HDF5 file" msgstr "Parcourir un fichier HDF5" msgid "Hide window" msgstr "Masquer la fenêtre" #, python-format msgid "Hide %s window" msgstr "Masquer la fenêtre %s" msgid "Quit" msgstr "Quitter" msgid "Quit application" msgstr "Quitter l'application" msgid "&File" msgstr "&Fichier" msgid "&View" msgstr "&Affichage" #, python-format msgid "" "Welcome to %s console!\n" "You can access the main window with the 'win' variable.\n" "Modules imported at startup: os, sys, os.path as osp, time, numpy as np, scipy.signal as sps, scipy.ndimage as spi" msgstr "" "Bienvenue dans la console %s !\n" "Vous pouvez accéder à la fenêtre principale avec la variable 'win'.\n" "Modules importés au démarrage : os, sys, os.path as osp, time, numpy as np, scipy.signal as sps, scipy.ndimage as spi" msgid "Online documentation" msgstr "Documentation en ligne" msgid "PDF documentation" msgstr "Documentation PDF" msgid "Test segfault/Python error" msgstr "Tester erreur de segmentation/Python" msgid "Log files" msgstr "Journaux de bord" msgid "Project home page" msgstr "Page d'accueil du projet" msgid "Bug report or feature request" msgstr "Rapport d'anomalie ou demande de nouvelle fonctionnalité" msgid "About..." msgstr "À propos..." msgid "Save" msgstr "Enregistrer" msgid "Open" msgstr "Ouvrir" msgid "HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)" msgstr "Fichiers HDF5 (*.h5 *.hdf5 *.hdf *.he5);;Tous les fichiers (*)" msgid "Do you want to clear current workspace before importing data from HDF5 files?" msgstr "Voulez-vous effacer l'espace de travail actuel avant d'importer des données depuis des fichiers HDF5 ?" msgid "Note: If you choose No, when importing workspace files, objects with conflicting identifiers will have their processing history lost (features like 'Show source' and 'Recompute' will not work for those objects). Non-conflicting objects will preserve their processing history." msgstr "Note : Si vous choisissez Non, lors de l'importation de fichiers d'espace de travail, les objets avec des identifiants en conflit perdront leur historique de traitement (les fonctionnalités telles que 'Afficher la source' et 'Recalculer' ne fonctionneront pas pour ces objets). Les objets sans conflit conserveront leur historique de traitement." #, python-format msgid "Choosing to ignore this message will prevent it from being displayed again, and will use the current setting (%s)." msgstr "Choisir d'ignorer ce message empêchera son réaffichage et utilisera le paramètre actuel (%s)." msgid "Yes" msgstr "Oui" msgid "No" msgstr "Non" #, python-format msgid "%d object(s) imported successfully" msgstr "%d objet(s) importé(s) avec succès" msgid "Developed and maintained by DataLab open-source project team" msgstr "Développé et maintenu par l'équipe du projet open-source DataLab" msgid "About" msgstr "À propos" msgid "Do you want to save all signals and images to an HDF5 file before quitting the application?" msgstr "Voulez-vous enregistrer tous les signaux et images dans un fichier HDF5 avant de quitter l'application ?" msgid "Curve Viewer" msgstr "Visionneuse de courbes" msgid "&Tools" msgstr "&Outils" msgid "Generate sine wave" msgstr "Générer une onde sinusoïddale" msgid "Generate a sample sine wave and display it" msgstr "Générer un exemple d'onde sinusoïddale et l'afficher" msgid "Clear plot" msgstr "Effacer le graphique" msgid "Remove all curves from the plot" msgstr "Supprimer toutes les courbes du graphique" msgid "Show configuration" msgstr "Afficher la configuration" msgid "Print all current configuration options to the console" msgstr "Afficher toutes les options de configuration actuelles dans la console" msgid "MyApp Tools" msgstr "Outils MyApp" msgid "Sine" msgstr "Sinus" msgid "Generate a sine wave" msgstr "Générer une onde sinusoïddale" #, python-format msgid "Generated sine wave with %d points" msgstr "Onde sinusoïddale générée avec %d points" msgid "Plot cleared" msgstr "Graphique effacé" msgid "New project" msgstr "Nouveau projet" msgid "Create a new empty project" msgstr "Créer un nouveau projet vide" msgid "Import CSV..." msgstr "Importer CSV..." msgid "Import data from a CSV file" msgstr "Importer des données depuis un fichier CSV" msgid "Web API status" msgstr "État de l'API Web" msgid "Show Web API connection status" msgstr "Afficher l'état de connexion de l'API Web" msgid "Preferences..." msgstr "Préférences..." msgid "Edit application preferences" msgstr "Modifier les préférences de l'application" msgid "Release notes" msgstr "Notes de version" msgid "Show release notes" msgstr "Afficher les notes de version" msgid "Settings..." msgstr "Paramètres..." msgid "Open settings dialog" msgstr "Ouvrir la boîte de dialogue des paramètres" msgid "My Action" msgstr "Mon action" msgid "A custom action" msgstr "Une action personnalisée" msgid "Cancel" msgstr "Annuler" msgid "Context" msgstr "Contexte" msgid "Error:" msgstr "Erreur:" #, python-format msgid "The file %s could not be read:" msgstr "Le fichier %s n'a pas pu être lu :" #, python-format msgid "The file %s could not be written:" msgstr "Le fichier %s n'a pas pu être écrit :" msgid "in this folder" msgstr "dans ce dossier" msgid "Open tab menu" msgstr "Ouvrir le menu de l'onglet" msgid "Connection" msgstr "Connexion" msgid "Waiting for connection..." msgstr "En attente de connexion..." msgid "Connecting to server..." msgstr "Connexion au serveur..." msgid "Connection successful!" msgstr "Connexion réussie !" #, python-format msgid "Connection failed: %s" msgstr "Connexion échouée : %s" msgid "Contents of file" msgstr "Contenu du fichier" msgid "Polymomial fit" msgstr "Ajustement polynomial" msgid "Amplitude" msgstr "Amplitude" msgid "Base line" msgstr "Ligne de base" msgid "Std-dev" msgstr "Écart-type" msgid "Mean" msgstr "Moyenne" msgid "Gaussian fit" msgstr "Ajustement gaussien" msgid "Lorentzian fit" msgstr "Ajustement lorentzien" msgid "Voigt fit" msgstr "Ajustement de Voigt" msgid "Y0" msgstr "Y0" msgid "Multi-Gaussian fit" msgstr "Ajustement multi-gaussien" msgid "Multi-Lorentzian fit" msgstr "Ajustement multi-lorentzien" msgid "A coefficient" msgstr "Coefficient A" msgid "B coefficient" msgstr "Coefficient B" msgid "y0 constant" msgstr "Constante y0" msgid "Exponential fit" msgstr "Ajustement exponentiel" msgid "Frequency" msgstr "Fréquence" msgid "Phase" msgstr "Phase" msgid "Continuous component" msgstr "Composante continue" msgid "Sinusoidal fit" msgstr "Ajustement sinusoïdal" msgid "CDF fit" msgstr "Ajustement CDF" msgid "Scale factor" msgstr "Facteur d'échelle" msgid "Width factor" msgstr "Facteur de largeur" msgid "Planckian fit" msgstr "Ajustement planckien" msgid "Left amplitude" msgstr "Amplitude gauche" msgid "Right amplitude" msgstr "Amplitude droite" msgid "Left width" msgstr "Largeur gauche" msgid "Right width" msgstr "Largeur droite" msgid "Center" msgstr "Centre" msgid "Left baseline" msgstr "Ligne de base gauche" msgid "Right baseline" msgstr "Ligne de base droite" msgid "Two half-Gaussian fit" msgstr "Ajustement bi-demi-gaussien" msgid "Center position" msgstr "Position du centre" msgid "Left rate" msgstr "Amplitude gauche" msgid "Right rate" msgstr "Amplitude droite" msgid "Piecewise exponential (raise-decay) fit" msgstr "Ajustement exponentiel par morceaux (montée-décroissance)" msgid "Collapse all" msgstr "Tout réduire" msgid "Expand all" msgstr "Tout développer" msgid "Restore" msgstr "Restaurer" msgid "Restore original tree layout" msgstr "Restaurer la disposition originale de l'arborescence" msgid "Collapse selection" msgstr "Réduire la sélection" msgid "Expand selection" msgstr "Développer la sélection" msgid "HDF5 Browser" msgstr "Explorateur HDF5" msgid "Value" msgstr "Valeur" msgid "Name" msgstr "Nom" msgid "Size" msgstr "Taille" msgid "Type" msgstr "Type" msgid "Unsupported data" msgstr "Données non prises en charge" msgid "Group" msgstr "Groupe" msgid "Attributes" msgstr "Attributs" msgid "Show array" msgstr "Afficher le tableau" msgid "Path" msgstr "Chemin" msgid "Description" msgstr "Description" msgid "Textual preview" msgstr "Aperçu textuel" msgid "Close" msgstr "Fermer" msgid "Select HDF5 file" msgstr "Sélectionner un fichier HDF5" msgid "Check all" msgstr "Tout cocher" msgid "Uncheck all" msgstr "Tout décocher" msgid "Show only supported data" msgstr "Afficher uniquement les données prises en charge" msgid "Show values" msgstr "Afficher les valeurs" msgid "Image background selection" msgstr "Sélection du fond d'image" msgid "Background area" msgstr "Zone de fond" msgid "Background value:" msgstr "Valeur du fond :" msgid "Log files were generated during current session." msgstr "Des fichiers journaux ont été générés pendant la session en cours." msgid "Log files were generated during last session." msgstr "Des fichiers journaux ont été générés pendant la dernière session." msgid "Log files are currently empty." msgstr "Les fichiers journaux sont actuellement vides." msgid "Select object" msgstr "Sélectionner l'objet" msgid "Signals" msgstr "Signaux" msgid "Images" msgstr "Images" msgid "Active panel:" msgstr "Panneau actif :" msgid "Signal baseline selection" msgstr "Sélection de la ligne de base du signal" msgid "Select X value with cursor" msgstr "Sélectionner la valeur X avec le curseur" msgid "Select Y value with cursor" msgstr "Sélectionner la valeur Y avec le curseur" msgid "Apply" msgstr "Appliquer" msgid "Apply cursor position" msgstr "Appliquer la position du curseur" msgid "Cursor position" msgstr "Position du curseur" msgid "Minimum distance:" msgstr "Distance minimale :" msgid "Signal peak detection" msgstr "Détection de pics du signal" msgid "Internal console" msgstr "Console interne" msgid "" "Click to show the internal console.\n" "The icon will turn red if an error or warning is logged." msgstr "" "Cliquer pour afficher la console interne.\n" "L'icône deviendra rouge si une erreur ou un avertissement est enregistré." msgid "" "Click to show the internal console.\n" "An error or warning has been logged." msgstr "" "Cliquer pour afficher la console interne.\n" "Une erreur ou un avertissement a été enregistré." msgid "" "Click to show the internal console.\n" "No error or warning has been logged." msgstr "" "Cliquer pour afficher la console interne.\n" "Aucune erreur ou avertissement n'a été enregistré." msgid "Memory available:" msgstr "Mémoire disponible :" msgid "Memory used:" msgstr "Mémoire utilisée :" msgid "Alarm threshold:" msgstr "Seuil d'alarme :" msgid "Memory:" msgstr "Mémoire :" msgid "Error message" msgstr "Message d'erreur" msgid "The following traceback may help to understand the problem:" msgstr "Le traceback suivant peut aider à comprendre le problème :" msgid "Warning message" msgstr "Message d'avertissement" msgid "Please take into account the following warning message:" msgstr "Veuillez prendre en compte le message d'avertissement suivant :" msgid "An error has occured during the following context:" msgstr "Une erreur s'est produite dans le contexte suivant :" msgid "Tip" msgstr "Astuce" msgid "Please click on the 'Ignore' button to ignore this warning next time." msgstr "Veuillez cliquer sur le bouton « Ignorer » pour ignorer cet avertissement la prochaine fois." msgid "Back" msgstr "Précédent" msgid "Next" msgstr "Suivant" msgid "Finish" msgstr "Terminer" msgid "Welcome to the Example Wizard" msgstr "Bienvenue dans l'assistant d'exemple" msgid "This wizard will guide you through the process of importing data." msgstr "Cet assistant vous guidera tout au long du processus d'importation de données." msgid "Select the Source of the Data" msgstr "Sélectionner la source des données" msgid "Select the source of the data to be imported (clipboard or file)." msgstr "Sélectionner la source des données à importer (presse-papiers ou fichier)." msgid "Clipboard" msgstr "Presse-papiers" msgid "File" msgstr "Fichier" msgid "Browse..." msgstr "Parcourir..." msgid "Select the File to Import" msgstr "Sélectionner le fichier à importer" msgid "CSV Files (*.csv);;Text Files (*.txt);;All Files (*)" msgstr "Fichiers CSV (*.csv);;Fichiers texte (*.txt);;Tous les fichiers (*)" msgid "Error" msgstr "Erreur" msgid "Please select the file to import." msgstr "Veuillez sélectionner le fichier à importer." msgid "Example Wizard" msgstr "Assistant d'exemple" msgid "Signal Viewer" msgstr "Visionneuse de signaux" msgid "&Analysis" msgstr "&Analyse" msgid "Run analysis" msgstr "Exécuter l'analyse" msgid "Export report..." msgstr "Exporter le rapport..." msgid "Running analysis..." msgstr "Analyse en cours..." msgid "Exporting report..." msgstr "Export du rapport en cours" msgid "Idle" msgstr "Inactif" msgid "Generate signal" msgstr "Générer un signal" msgid "Analysis" msgstr "Analyse" msgid "Generate" msgstr "Générer" msgid "Signal generated" msgstr "Signal généré" DataLab-Platform-SigimaX-0182bf4/sigimax/mainwindow.py000066400000000000000000001437441524425144300226630ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Main window =========== The :mod:`sigimax.mainwindow` module provides a generic main window for derived applications. It is designed to be flexible and extensible, allowing to easily add new panels, actions, menus and toolbars. It also provides a set of signals to communicate with other parts of the application. .. autoclass:: SGMXMainWindow :members: """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations __all__ = [ "SGMXMainWindow", ] import abc import base64 import os import os.path as osp import sys import time import webbrowser from typing import TYPE_CHECKING import numpy as np import scipy.ndimage as spi import scipy.signal as sps from guidata import qthelpers as guidata_qth from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action, exec_dialog from guidata.widgets.console import DockableConsole from plotpy import config as plotpy_config from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from qtpy.compat import getopenfilenames, getsavefilename from sigimax._metadata import __homeurl__, __version__ from sigimax.config import ( DEBUG, MOD_DESC, MOD_TITLE, TEST_SEGFAULT_ERROR, _, get_conf, ) from sigimax.env import execenv from sigimax.h5 import H5Importer from sigimax.utils import qthelpers as qth from sigimax.utils.qthelpers import ( add_corner_menu, bring_to_front, configure_menu_about_to_show, qt_handle_error_message, ) from sigimax.widgets import logviewer, status from sigimax.widgets.h5browser import H5BrowserDialog from sigimax.widgets.plotdock import DockablePlotWidget from sigimax.widgets.warningerror import go_to_error if TYPE_CHECKING: from typing import Literal class SGMXMainWindowMeta(type(QW.QMainWindow), abc.ABCMeta): """Mixed metaclass to avoid conflicts""" class SGMXMainWindow(QW.QMainWindow, metaclass=SGMXMainWindowMeta): """SigimaX generic main window Args: console: enable internal console hide_on_close: True to hide window on close """ __instance = None #: Bump this whenever dock widget object names change, so that layouts saved #: by an older version are discarded instead of being partially restored. WINDOW_STATE_VERSION = 1 SIG_READY = QC.Signal() SIG_SEND_OBJECT = QC.Signal(object) SIG_SEND_OBJECTLIST = QC.Signal(object) SIG_CLOSING = QC.Signal() @classmethod def get_instance(cls, console=None, hide_on_close=False): """Return the singleton instance for this window class.""" if not isinstance(SGMXMainWindow.__instance, cls): return cls(console, hide_on_close) return SGMXMainWindow.__instance def __init__(self, console=None, hide_on_close=False): """Initialize main window""" SGMXMainWindow.__instance = self super().__init__() conf = get_conf() self.setObjectName(conf.app_name.get()) self.setWindowIcon(get_icon(conf.app_logo_path.get())) execenv.log(self, "Starting initialization") self.ready_flag = True self.hide_on_close = hide_on_close self.__old_size: tuple[int, int] | None = None self.__memory_warning = False self.memorystatus: status.MemoryStatus | None = None self.consolestatus: status.ConsoleStatus | None = None self.console: DockableConsole | None = None self.main_toolbar: QW.QToolBar | None = None self.tabwidget: QW.QTabWidget | None = None self.tabmenu: QW.QMenu | None = None self.docks: dict[QW.QWidget, QW.QDockWidget] = {} self.openh5_action: QW.QAction | None = None self.saveh5_action: QW.QAction | None = None self.browseh5_action: QW.QAction | None = None self.quit_action: QW.QAction | None = None self.showfirstonly_action: QW.QAction | None = None self.showlabel_action: QW.QAction | None = None self.file_menu: QW.QMenu | None = None self.view_menu: QW.QMenu | None = None self.help_menu: QW.QMenu | None = None # Setup actions and menus if console is None: console = conf.console_enabled.get() self._before_setup(console) self._update_color_mode(startup=True) self.__is_modified = False self.set_modified(False) self.setup(console) self._after_setup(console) self._restore_pos_and_size() execenv.log(self, "Initialization done") def _before_setup(self, console: bool) -> None: """Initialize derived-application state before :meth:`setup`. Args: console: Whether the internal console will be created. """ def _after_setup(self, console: bool) -> None: """Finalize derived-application state after :meth:`setup`. Args: console: Whether the internal console was created. """ def _set_low_memory_state(self, state: bool) -> None: """Set memory warning state""" self.__memory_warning = state def confirm_memory_state(self) -> bool: # pragma: no cover """Check memory warning state and eventually show a warning dialog Returns: True if memory state is ok """ if not execenv.unattended and self.__memory_warning: threshold = get_conf().available_memory_threshold.get() answer = QW.QMessageBox.critical( self, _("Warning"), _("Available memory is below %d MB.

Do you want to continue?") % threshold, QW.QMessageBox.Yes | QW.QMessageBox.No, ) return answer == QW.QMessageBox.Yes return True def check_stable_release(self) -> None: # pragma: no cover """Check if this is a stable release""" conf = get_conf() app_version = conf.app_version.get() if app_version.replace(".", "").isdigit(): # This is a stable release return if "b" in app_version: # This is a beta release rel = _( "This software is in the beta stage of its release cycle. " "The focus of beta testing is providing a feature complete " "software for users interested in trying new features before " "the final release. However, beta software may not behave as " "expected and will probably have more bugs or performance issues " "than completed software." ) else: # This is an alpha release rel = _( "This software is in the alpha stage of its release cycle. " "The focus of alpha testing is providing an incomplete software " "for early testing of specific features by users. " "Please note that alpha software was not thoroughly tested " "by the developer before it is released." ) txtlist = [ f"{conf.app_name.get()} v{app_version}:", "", _("This is not a stable release."), "", rel, ] if not execenv.unattended: QW.QMessageBox.warning( self, conf.app_name.get(), "
".join(txtlist), QW.QMessageBox.Ok ) def check_for_previous_crash(self) -> None: # pragma: no cover """Check for previous crash""" conf = get_conf() if execenv.unattended and not execenv.do_not_quit: # Showing the log viewer for testing purpose (unattended mode) but only # if option 'do_not_quit' is not set, to avoid blocking the test suite self._show_logviewer() elif execenv.do_not_quit: # If 'do_not_quit' is set, we do not show any message box to avoid blocking # the test suite return elif ( conf.faulthandler_log_available.get() or conf.traceback_log_available.get() ): txt = "
".join( [ logviewer.get_log_prompt_message(), "", _("Do you want to see available log files?"), ] ) btns = QW.QMessageBox.StandardButton.Yes | QW.QMessageBox.StandardButton.No choice = QW.QMessageBox.warning(self, conf.app_name.get(), txt, btns) if choice == QW.QMessageBox.StandardButton.Yes: self._show_logviewer() def execute_post_show_actions(self) -> None: """Execute post-show actions""" self.check_stable_release() self.check_for_previous_crash() def take_screenshot(self, name: str) -> None: # pragma: no cover """Take main window screenshot""" # For esthetic reasons, we set the central widget width to a lower value: old_width = self.tabwidget.maximumWidth() self.tabwidget.setMaximumWidth(500) # To avoid having screenshot depending on memory status, we set demo mode ON: self.memorystatus.set_demo_mode(True) qth.grab_save_window(self, f"{name}") # Restore previous state: self.memorystatus.set_demo_mode(False) self.tabwidget.setMaximumWidth(old_width) def take_menu_screenshots(self) -> None: # pragma: no cover """Take menu screenshots""" for name in ( "file", "view", "help", ): menu = getattr(self, f"{name}_menu") menu.popup(self.pos()) qth.grab_save_window(menu, f"{name}_menu") menu.close() # ------GUI setup def _restore_pos_and_size(self) -> None: """Restore main window position and size from configuration""" conf = get_conf() pos = conf.window_position.get() if pos is not None: posx, posy = pos self.move(QC.QPoint(posx, posy)) size = conf.window_size.get() if size is None: sgeo = self.screen().availableGeometry() sw, sh = sgeo.width(), sgeo.height() w = max(1200, min(1800, int(sw * 0.8))) h = max(700, min(1100, int(sh * 0.8))) size = (w, h) if pos is None: cx = sgeo.x() + (sw - w) // 2 cy = sgeo.y() + (sh - h) // 2 self.move(QC.QPoint(cx, cy)) width, height = size self.resize(QC.QSize(width, height)) if pos is not None and size is not None: sgeo = self.screen().availableGeometry() out_inf = posx < -int(0.9 * width) or posy < -int(0.9 * height) out_sup = posx > int(0.9 * sgeo.width()) or posy > int(0.9 * sgeo.height()) if len(QW.QApplication.screens()) == 1 and (out_inf or out_sup): # Main window is offscreen posx = min(max(posx, 0), sgeo.width() - width) posy = min(max(posy, 0), sgeo.height() - height) self.move(QC.QPoint(posx, posy)) def _restore_state(self) -> None: """Restore main window state from configuration""" state = get_conf().window_state.get() if state: state = base64.b64decode(state) self.restoreState(QC.QByteArray(state), self.WINDOW_STATE_VERSION) for widget in self.children(): if isinstance(widget, QW.QDockWidget): self.restoreDockWidget(widget) def _save_pos_size_and_state(self) -> None: """Save main window position, size and state to configuration""" if execenv.unattended: # Running tests must not overwrite the user's window layout return conf = get_conf() is_maximized = self.windowState() == QC.Qt.WindowMaximized conf.window_maximized.set(is_maximized) if not is_maximized: size = self.size() conf.window_size.set((size.width(), size.height())) pos = self.pos() conf.window_position.set((pos.x(), pos.y())) # Encoding window state into base64 string to avoid sending binary data # to the configuration file: state = self.saveState(self.WINDOW_STATE_VERSION).data() conf.window_state.set(base64.b64encode(state).decode("ascii")) def setup(self, console: bool = False) -> None: """Setup main window The sequence is fixed: derived applications extend it by overriding the hooks it calls, not by overriding this method. :meth:`_restore_state` must stay last, since dock widgets added afterwards keep their default geometry instead of the persisted one. Args: console: True to setup console """ self._configure_statusbar(console) self._setup_global_actions() self._setup_panels() self._setup_central_widget() self._add_menus() if console: self._setup_console() self._setup_docks() self._post_setup(console) # Now that everything is set up, we can restore the window state: self._restore_state() def _setup_panels(self) -> None: """Create the application panels and views. Called after the global actions, so that panel toolbars are added after the main toolbar, and before the central widget is set up. """ def _setup_docks(self) -> None: """Add the application dock widgets. Called before :meth:`_restore_state`, so that the persisted layout is applied to them. """ def _post_setup(self, console: bool) -> None: """Finalize the user interface. Called once panels, central widget, menus, console and docks all exist. Args: console: Whether the internal console was created. """ def _configure_statusbar(self, console: bool) -> None: """Configure status bar Args: console: True if console is enabled """ conf = get_conf() self.statusBar().showMessage(_("Welcome to %s!") % conf.app_name.get(), 5000) if console: # Console status self.consolestatus = status.ConsoleStatus() self.statusBar().addPermanentWidget(self.consolestatus) for widget in self._get_extra_status_widgets(): self.statusBar().addPermanentWidget(widget) # Memory status threshold = conf.available_memory_threshold.get() self.memorystatus = status.MemoryStatus(threshold) self.memorystatus.SIG_MEMORY_ALARM.connect(self._set_low_memory_state) self.statusBar().addPermanentWidget(self.memorystatus) def _get_extra_status_widgets(self) -> list[QW.QWidget]: """Return the application-specific status bar widgets. They are added between the console status and the memory status. Returns: List of widgets """ return [] def _add_toolbar( self, title: str, position: Literal["top", "bottom", "left", "right"], name: str ) -> QW.QToolBar: """Add toolbar to main window Args: title: toolbar title position: toolbar position name: toolbar name (Qt object name) """ toolbar = QW.QToolBar(title, self) toolbar.setObjectName(name) area = getattr(QC.Qt, f"{position.capitalize()}ToolBarArea") self.addToolBar(area, toolbar) return toolbar def _setup_global_actions(self) -> None: """Setup global actions""" self._create_global_actions() self.main_toolbar = self._add_toolbar(_("Main Toolbar"), "left", "main_toolbar") add_actions(self.main_toolbar, self._get_main_toolbar_actions()) def _create_global_actions(self) -> None: """Create global actions (H5, quit, etc.). Override in subclasses to create additional actions or replace defaults. Call ``super()._create_global_actions()`` first to create the standard H5 and quit actions. """ self.openh5_action = create_action( self, _("Open HDF5 files..."), icon=get_icon("fileopen_h5.svg"), tip=_("Open one or more HDF5 files"), triggered=lambda checked=False: self.open_h5_files(import_all=True), ) self.saveh5_action = create_action( self, _("Save to HDF5 file..."), icon=get_icon("filesave_h5.svg"), tip=_("Save to HDF5 file"), triggered=self.save_to_h5_file, ) self.browseh5_action = create_action( self, _("Browse HDF5 file..."), icon=get_icon("h5browser.svg"), tip=_("Browse an HDF5 file"), triggered=lambda checked=False: self.open_h5_files(import_all=None), ) # Quit action for "File menu" (added when populating menu on demand) if self.hide_on_close: quit_text = _("Hide window") quit_tip = _("Hide %s window") % get_conf().app_name.get() else: quit_text = _("Quit") quit_tip = _("Quit application") if sys.platform != "darwin": # On macOS, the "Quit" action is automatically added to the application menu self.quit_action = create_action( self, quit_text, shortcut=QG.QKeySequence(QG.QKeySequence.Quit), icon=get_icon("libre-gui-close.svg"), tip=quit_tip, triggered=self.close, ) def _get_main_toolbar_actions(self) -> list[QW.QAction | None]: """Return the list of actions for the main toolbar. Override in subclasses to customize which actions appear in the main toolbar and their order. Return a list of :class:`QAction` instances (or ``None`` for separators). Returns: List of actions and separators """ return [ self.openh5_action, self.saveh5_action, self.browseh5_action, ] def _setup_central_widget(self) -> None: """Setup central widget (main panel)""" # Apply enhanced tab bar styling self.tabwidget = QW.QTabWidget() self.tabmenu = add_corner_menu(self.tabwidget) tab_bar = self.tabwidget.tabBar() font = tab_bar.font() font.setPointSize(10) tab_bar.setFont(font) # Use QTimer to ensure tab bar is properly sized first QC.QTimer.singleShot(0, self._update_tab_icon_size) self.setCentralWidget(self.tabwidget) def _update_tab_icon_size(self) -> None: """Update tab icon size based on tab bar height""" if self.tabwidget is not None: tab_bar = self.tabwidget.tabBar() if tab_bar.height() > 0: # Use approximately 80% of tab height for icon size icon_size = int(tab_bar.height() * 0.8) self.tabwidget.setIconSize(QC.QSize(icon_size, icon_size)) @staticmethod def __get_local_doc_path() -> str | None: """Return local documentation path, if it exists. Uses the ``app_local_doc_path`` config field. When the path pattern contains ``{lang}``, the system locale prefix is tried first (e.g. ``fr``), then ``en`` as fallback. If the pattern does not contain ``{lang}``, it is used as-is. Returns: Resolved file path, or None if not configured / not found. """ pattern = get_conf().app_local_doc_path.get() if not pattern: return None if "{lang}" in pattern: locale = QC.QLocale.system().name() for lang in (locale[:2], "en"): path = pattern.format(lang=lang) if osp.isfile(path): return path else: if osp.isfile(pattern): return pattern return None def _get_menubar_layout(self) -> list[tuple[str, str]]: """Return the menu bar layout, as ``(attribute name, title)`` pairs. Each pair creates a menu stored as ``self._menu``. Override in subclasses to insert application menus while keeping the standard ones, which the base implementation relies on. Returns: Ordered list of (attribute name, menu title) """ return [("file", _("&File")), ("view", _("&View")), ("help", "?")] def _add_menus(self) -> None: """Adding menus""" for name, title in self._get_menubar_layout(): setattr(self, f"{name}_menu", self.menuBar().addMenu(title)) configure_menu_about_to_show(self.file_menu, self._update_file_menu) configure_menu_about_to_show(self.view_menu, self._update_view_menu) add_actions(self.help_menu, self._get_help_menu_actions()) def _update_console_show_mode(self) -> None: """Update console show mode from configuration option Console show mode is whether the console is shown or not when an error occurs. """ if self.console is not None: state = get_conf().show_console_on_error.get() cdock = self.docks[self.console] if not state and cdock.isVisible(): cdock.hide() if state: self.console.exception_occurred.connect(self.console.show_console) else: self.console.exception_occurred.disconnect(self.console.show_console) def _get_console_namespace(self) -> dict[str, object]: """Return the namespace dict exposed in the internal console. The default namespace provides ``win`` (the main window) and commonly used scientific modules. Override in subclasses to add application-specific variables. Returns: Namespace dictionary """ return { "win": self, "np": np, "sps": sps, "spi": spi, "os": os, "sys": sys, "osp": osp, "time": time, } def _get_console_message(self) -> str: """Return the welcome message displayed in the internal console. Override in subclasses to provide application-specific examples. Returns: Welcome message string """ app = get_conf().app_name.get() return ( _( "Welcome to %s console!\n" "You can access the main window with the 'win' variable.\n" "Modules imported at startup: " "os, sys, os.path as osp, time, " "numpy as np, scipy.signal as sps, scipy.ndimage as spi" ) % app ) def _configure_console(self) -> None: """Configure application-specific console signals after creation.""" def _setup_console(self) -> None: """Add an internal console""" ns = self._get_console_namespace() msg = self._get_console_message() self.console = DockableConsole(self, namespace=ns, message=msg, debug=DEBUG) self.console.setMaximumBlockCount(get_conf().console_max_line_count.get()) self.console.go_to_error.connect(go_to_error) cdock = self._add_dockwidget(self.console, _("Console"), name="console") cdock.hide() self._update_console_show_mode() self.console.exception_occurred.connect(self.consolestatus.exception_occurred) cdock.visibilityChanged.connect(self.consolestatus.console_visibility_changed) self.consolestatus.SIG_SHOW_CONSOLE.connect(self.console.show_console) self._configure_console() def _normalize_modified_state(self, state: bool) -> bool: """Normalize a requested modified state for the application model.""" return state def set_modified(self, state: bool = True) -> None: """Set mainwindow modified state""" state = self._normalize_modified_state(state) self.__is_modified = state if self.saveh5_action is not None: self.saveh5_action.setEnabled(self._is_save_enabled()) conf = get_conf() title = conf.app_name.get() + ("*" if state else "") if not conf.app_version.get().replace(".", "").isdigit(): title += f" [{conf.app_version.get()}]" self.setWindowTitle(title) def is_modified(self) -> bool: """Return True if mainwindow is modified""" return self.__is_modified def _add_dockwidget( self, child, title: str, *, name: str | None = None, key: QW.QWidget | None = None, tabify_with: QW.QWidget | None = None, ) -> QW.QDockWidget: """Add a dock widget to the main window and register it in ``self.docks``. Args: child: dockable widget, providing a ``create_dockwidget`` method title: dock widget title, displayed to the user (translated) name: stable Qt object name used to persist the dock layout. Defaults to ``title``, but a non-translated name should be passed so that the layout survives a language change. key: key used to register the dock in ``self.docks``, when the logical owner differs from the dockable widget itself. Defaults to ``child``. tabify_with: key of an already registered dock to tabify with Returns: Created dock widget """ dockwidget, location = child.create_dockwidget(title) dockwidget.setObjectName(title if name is None else name) self.addDockWidget(location, dockwidget) if tabify_with is not None: self.tabifyDockWidget(self.docks[tabify_with], dockwidget) self.docks[child if key is None else key] = dockwidget return dockwidget def _is_save_enabled(self) -> bool: """Return whether the 'Save' action should be enabled. The base implementation returns ``True`` only when the workspace has been modified and the derived application overrides :meth:`save_h5_workspace`. Override in subclasses to add domain-specific conditions (e.g., whether the workspace contains any objects). Returns: True if save action should be enabled """ return self._has_h5_workspace_persistence() and self.is_modified() def _get_file_menu_actions(self) -> list[QW.QAction | None]: """Return the list of actions for the file menu. Override in subclasses to fully customize which actions appear and their order. Return a list of :class:`QAction` instances (or ``None`` for separators). Returns: List of actions and separators """ return [ None, self.openh5_action, self.saveh5_action, self.browseh5_action, ] def _update_file_menu(self) -> None: """Update file menu before showing up. Updates action states (via :meth:`_is_save_enabled`) and populates the menu with actions returned by :meth:`_get_file_menu_actions`. Override in subclasses to add extra logic (e.g., appending a submenu). Call ``super()._update_file_menu()`` first to populate the default actions. """ self.saveh5_action.setEnabled(self._is_save_enabled()) add_actions(self.file_menu, self._get_file_menu_actions()) if self.quit_action is not None: add_actions(self.file_menu, [self.quit_action]) def _get_view_menu_actions(self) -> list[QW.QAction | None]: """Return the list of actions for the view menu. Override in subclasses to customize which actions appear. Returns: List of actions and separators """ return [None] + self.createPopupMenu().actions() def _update_view_menu(self) -> None: """Update view menu before showing up. Override in subclasses to add extra logic. Call ``super()._update_view_menu()`` first to populate the default actions. """ add_actions(self.view_menu, self._get_view_menu_actions()) def _get_help_doc_actions(self) -> list[QW.QAction | None]: """Return the documentation actions of the help menu. Override in subclasses to append application-specific entries such as a tour or a demo. Returns: List of actions and separators """ actions: list[QW.QAction | None] = [ create_action( self, _("Online documentation"), icon=get_icon("libre-gui-help.svg"), triggered=lambda: webbrowser.open(get_conf().app_docurl.get()), ), ] localdocpath = self.__get_local_doc_path() if localdocpath is not None: actions.append( create_action( self, _("PDF documentation"), icon=get_icon("help_pdf.svg"), triggered=lambda: webbrowser.open(localdocpath), ), ) return actions def _get_help_support_actions(self) -> list[QW.QAction | None]: """Return the troubleshooting actions of the help menu. Override in subclasses to append application-specific entries such as an installation and configuration viewer. Returns: List of actions and separators """ actions: list[QW.QAction | None] = [] if TEST_SEGFAULT_ERROR: actions.append( create_action( self, _("Test segfault/Python error"), triggered=self.test_segfault_error, ) ) actions.append( create_action( self, _("Log files") + "...", icon=get_icon("logs.svg"), triggered=self._show_logviewer, ) ) return actions def _get_help_about_actions(self) -> list[QW.QAction | None]: """Return the project and about actions of the help menu. Returns: List of actions and separators """ return [ None, create_action( self, _("Project home page"), icon=get_icon("libre-gui-globe.svg"), triggered=lambda: webbrowser.open(get_conf().app_homeurl.get()), ), create_action( self, _("Bug report or feature request"), icon=get_icon("libre-gui-globe.svg"), triggered=lambda: webbrowser.open(get_conf().app_supporturl.get()), ), create_action( self, _("About..."), icon=get_icon("libre-gui-about.svg"), triggered=self._about, ), ] def _get_help_menu_actions(self) -> list[QW.QAction | None]: """Return the list of actions for the help menu. Override in subclasses to wrap the standard groups, which are returned by :meth:`_get_help_doc_actions`, :meth:`_get_help_support_actions` and :meth:`_get_help_about_actions`. Returns: List of actions and separators """ return ( self._get_help_doc_actions() + self._get_help_support_actions() + self._get_help_about_actions() ) @staticmethod def _check_h5file(filename: str, operation: str) -> str: """Check HDF5 filename""" filename = osp.abspath(osp.normpath(filename)) bname = osp.basename(filename) if operation == "load" and not osp.isfile(filename): raise IOError(f'File not found "{bname}"') get_conf().base_dir.set(filename) return filename def _has_h5_workspace_persistence(self) -> bool: """Return whether the derived window implements workspace persistence.""" return type(self).save_h5_workspace is not SGMXMainWindow.save_h5_workspace def _on_h5_save_requested(self, filename: str) -> None: """Notify that a workspace save is about to be performed. Called by :meth:`save_to_h5_file` once *filename* is resolved and before the workspace is written. Override in derived applications to react to the operation (e.g. record it in an application history). Args: filename: Resolved HDF5 filename """ def _on_h5_open_requested( self, h5files: list[str], import_all: bool | None, reset_all: bool | None, ) -> None: """Notify that an HDF5 open/import is about to be performed. Called by :meth:`open_h5_files` once all arguments are resolved and before any file is read. Override in derived applications to react to the operation (e.g. record it in an application history). Args: h5files: Resolved HDF5 filenames (optionally with dataset name, separated by ",") import_all: Import all datasets from HDF5 files reset_all: Reset all application data before importing """ def save_to_h5_file(self, filename=None) -> None: """Save to a HDF5 file Args: filename: HDF5 filename. If None, a file dialog is opened. Raises: IOError: if filename is invalid or file cannot be saved. """ if filename is None: basedir = get_conf().base_dir.get() with qth.save_restore_stds(): filename, _fl = getsavefilename( self, _("Save"), basedir, "HDF5 (*.h5 *.hdf5 *.hdf *.he5);;All files (*)", ) if not filename: return self._on_h5_save_requested(filename) with qth.qt_try_loadsave_file(self, filename, "save"): self.save_h5_workspace(filename) def open_h5_files( self, h5files: list[str] | None = None, import_all: bool | None = None, reset_all: bool | None = None, ) -> None: """Open/import HDF5 files. Args: h5files: HDF5 filenames (optionally with dataset name, separated by ",") import_all: Import all datasets from HDF5 files reset_all: Reset all application data before importing """ if not self.confirm_memory_state(): return conf = get_conf() if reset_all is None: # When workspace is empty, always preserve UUIDs (reset_all=True) # since there's no risk of conflicts if self._is_workspace_empty(): reset_all = True else: reset_all = conf.h5_clear_workspace.get() if conf.h5_clear_workspace_ask.get(): answer = QW.QMessageBox.question( self, _("Warning"), self._get_clear_workspace_message(import_all, reset_all), QW.QMessageBox.Yes | QW.QMessageBox.No | QW.QMessageBox.Ignore, ) if answer == QW.QMessageBox.Yes: reset_all = True elif answer == QW.QMessageBox.No: reset_all = False elif answer == QW.QMessageBox.Ignore: conf.h5_clear_workspace_ask.set(False) if h5files is None: basedir = conf.base_dir.get() with qth.save_restore_stds(): h5files, _fl = getopenfilenames( self, _("Open"), basedir, _("HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"), ) if not h5files: return self._on_h5_open_requested(h5files, import_all, reset_all) filenames, dsetnames = [], [] for fname_with_dset in h5files: if "," in fname_with_dset: filename, dsetname = fname_with_dset.split(",") dsetnames.append(dsetname) else: filename = fname_with_dset dsetnames.append(None) filenames.append(filename) if import_all is None and all(dsetname is None for dsetname in dsetnames): self.browse_h5_files(filenames, reset_all) return for filename, dsetname in zip(filenames, dsetnames): if import_all is None and dsetname is None: self.import_all_from_h5_file(filename, reset_all) else: with qth.qt_try_loadsave_file(self, filename, "load"): filename = self._check_h5file(filename, "load") self.import_dataset_from_file( filename, dsetname, import_all, reset_all ) reset_all = False def _is_workspace_empty(self) -> bool: """Return whether the application workspace holds no object. When the workspace is empty, importing cannot cause any identifier conflict, so the user is not asked whether it should be cleared. The base window has no data model, so the base implementation returns False. Returns: True if the workspace holds no object """ return False def _get_clear_workspace_message( self, import_all: bool | None, reset_all: bool ) -> str: """Return the confirmation message shown before clearing the workspace. Override in subclasses to use application-specific wording. Args: import_all: Whether all datasets are imported without browsing reset_all: Current default answer, taken from the configuration Returns: HTML message """ msg = _( "Do you want to clear current workspace " "before importing data from " "HDF5 files?" ) if import_all: msg += "

" + _( "Note: If you choose No, when importing " "workspace files, objects with conflicting " "identifiers will have their processing history lost " "(features like 'Show source' and 'Recompute' will not " "work for those objects). Non-conflicting objects will " "preserve their processing history." ) msg += "

" + _( "Choosing to ignore this message will prevent it " "from being displayed again, and will use the " "current setting (%s)." ) % (_("Yes") if reset_all else _("No")) return msg def _handle_imported_objects(self, objects: list, reset_all: bool) -> None: """Handle the objects imported from an HDF5 file. Single convergence point of the import primitives. The base implementation clears the workspace when requested, then emits :data:`SIG_SEND_OBJECTLIST`. Override in subclasses to insert the objects into an application data model directly. Args: objects: Imported native objects reset_all: Whether the workspace must be cleared beforehand """ if not objects: return if reset_all: self.reset_all() self.SIG_SEND_OBJECTLIST.emit(objects) self.set_modified(True) self.statusBar().showMessage( _("%d object(s) imported successfully") % len(objects), 5000, ) def import_dataset_from_file( self, filename: str, dsetname: str | None, import_all: bool | None, reset_all: bool, ) -> None: """Import a specific dataset from an HDF5 file. This is a hook for derived applications to handle dataset-specific import logic. The base implementation is a no-op; subclasses should override this method to implement their own import strategy. Args: filename: Path to the HDF5 file (already validated) dsetname: Dataset name to import, or ``None`` to import all import_all: If ``True``, import all datasets without browsing reset_all: If ``True``, clear workspace before importing """ def browse_h5_files( self, filenames: list[str], reset_all: bool | None = None ) -> None: """Browse HDF5 files Opens an :class:`H5BrowserDialog ` pre-loaded with the given *filenames*, lets the user check datasets to import, converts the checked nodes into native objects (:class:`SignalObj ` / :class:`ImageObj `), and hands them over to :meth:`_handle_imported_objects`. Args: filenames: HDF5 filenames reset_all: Reset all application data before importing """ for filename in filenames: self._check_h5file(filename, "load") dialog = H5BrowserDialog(self) dialog.open_files(filenames) if exec_dialog(dialog) == QW.QDialog.Accepted: nodes = dialog.get_nodes() if not nodes: dialog.cleanup() return objects = [] for node in nodes: try: obj = node.get_native_object() if obj is not None: objects.append(obj) except (OSError, ValueError) as exc: qt_handle_error_message(self, exc) dialog.cleanup() self._handle_imported_objects(objects, bool(reset_all)) else: dialog.cleanup() def save_h5_workspace(self, filename: str) -> None: """Save current workspace to an HDF5 file. Subclasses that manage a data model must override this method to perform the actual serialization (e.g. using :class:`guidata.io.HDF5Writer`). The base implementation never clears the modified state because it cannot persist an application workspace. Args: filename: HDF5 filename to save to Raises: NotImplementedError: Always, because the base window has no application workspace to serialize. """ del filename raise NotImplementedError( "Override save_h5_workspace() to serialize the application workspace." ) def import_all_from_h5_file( self, filename: str, reset_all: bool | None = None ) -> None: """Import every supported dataset of an HDF5 file, without any dialog. Uses :class:`H5Importer ` to scan the file, converts every supported node into a native object (:class:`SignalObj ` / :class:`ImageObj `), and hands them over to :meth:`_handle_imported_objects`. Override in subclasses that import through their own browser or progress dialog. Args: filename: HDF5 filename reset_all: Reset all application data before importing """ with qth.qt_try_loadsave_file(self, filename, "load"): filename = self._check_h5file(filename, "load") importer = H5Importer(filename) objects = [] for node in importer.nodes: if not node.is_supported(): continue try: obj = node.get_native_object() if obj is not None: objects.append(obj) except Exception as exc: # pylint: disable=broad-except qt_handle_error_message(self, exc) importer.close() self._handle_imported_objects(objects, bool(reset_all)) def reset_all(self) -> None: """Reset all application data. The base implementation is a **no-op**. Subclasses should override this method to clear their data model (e.g. remove all objects from panels). """ def close_application(self) -> None: """Close SigimaX application""" self.close() def raise_window(self) -> None: """Raise SigimaX main window""" bring_to_front(self) def _about(self) -> None: # pragma: no cover """About dialog box. Override this method in subclasses to fully customize the About dialog. """ self.check_stable_release() conf = get_conf() app_name = conf.app_name.get() app_version = conf.app_version.get() app_desc = conf.app_desc.get() app_homeurl = conf.app_homeurl.get() app_docurl = conf.app_docurl.get() app_supporturl = conf.app_supporturl.get() dev_by = conf.app_developer.get() cprght = conf.app_copyright.get() # -- Application header about_parts = [f"{app_name} v{app_version}"] if app_desc: about_parts.append(f"
{app_desc}") if dev_by: about_parts.append(f"

" + " | ".join(links)) # -- SigimaX credits sgmx_dev_by = _("Developed and maintained by DataLab open-source project team") sgmx_cprght = "2023 DataLab Platform Developers" about_parts.extend( [ f'

Based on {MOD_TITLE} v{__version__}', f"
{MOD_DESC}", f"
{sgmx_dev_by}", f"
Copyright © {sgmx_cprght}", ] ) QW.QMessageBox.about( self, _("About") + " " + app_name, "".join(about_parts), ) def _update_color_mode(self, startup: bool = False) -> None: """Update color mode Args: startup: True if method is called during application startup (in that case, color theme is applied only if mode != "auto") """ mode = get_conf().color_mode.get() if startup and mode == "auto": guidata_qth.win32_fix_title_bar_background(self) return # Prevent Qt from refreshing the window when changing the color mode: self.setUpdatesEnabled(False) plotpy_config.set_plotpy_color_mode(mode) get_conf().apply_plotpy_defaults() if self.console is not None: self.console.update_color_mode() for dock in self.docks.values(): widget = dock.widget() if isinstance(widget, DockablePlotWidget): widget.update_color_mode() self._update_extra_color_mode() # Allow Qt to refresh the window: self.setUpdatesEnabled(True) def _update_extra_color_mode(self) -> None: """Update the color mode of application-specific widgets. Called with window updates disabled, after the console and the plot docks have been updated. The base implementation is a no-op. """ def _show_logviewer(self) -> None: """Show error logs""" logviewer.exec_sigimax_logviewer_dialog(self) @staticmethod def test_segfault_error() -> None: """Generate errors (both fault and traceback)""" import ctypes # pylint: disable=import-outside-toplevel ctypes.string_at(0) raise RuntimeError("!!! Testing RuntimeError !!!") def show(self) -> None: """Reimplement QMainWindow method""" super().show() if self.__old_size is not None: self.resize(self.__old_size) # ------Close window def _get_save_before_quit_message(self) -> str: """Return the confirmation message shown before closing modified data.""" return _( "Do you want to save all signals and images " "to an HDF5 file before quitting the application?" ) def _close_managed_widgets(self) -> None: """Close widgets owned by the generic application shell.""" if self.console is not None: try: self.console.close() except RuntimeError: # The Qt object may already be deleted when restarting a window # in the same test process. pass def _cleanup_before_reset(self) -> None: """Clean up derived services before resetting application data.""" def _cleanup_after_state_save(self) -> None: """Finalize derived shutdown after saving the window state.""" def close_properly(self) -> bool: """Close properly Returns: True if closed properly, False otherwise """ if not execenv.unattended and self.is_modified(): answer = QW.QMessageBox.warning( self, _("Quit"), self._get_save_before_quit_message(), QW.QMessageBox.Yes | QW.QMessageBox.No | QW.QMessageBox.Cancel, ) if answer == QW.QMessageBox.Yes: self.save_to_h5_file() if self.is_modified(): return False elif answer == QW.QMessageBox.Cancel: return False self.hide() # Avoid showing individual widgets closing one after the other self._close_managed_widgets() self._cleanup_before_reset() self.reset_all() self._save_pos_size_and_state() self._cleanup_after_state_save() execenv.log(self, "closed properly") return True def closeEvent(self, event: QG.QCloseEvent) -> None: """Reimplement QMainWindow method""" if self.hide_on_close: self.__old_size = self.size() self.hide() else: if self.close_properly(): self.SIG_CLOSING.emit() event.accept() else: event.ignore() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/000077500000000000000000000000001524425144300212625ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/tests/__init__.py000066400000000000000000000053661524425144300234050ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests (:mod:`sigimax.tests`) ------------------------ The SigimaX test suite is based on the `pytest `_ framework. The test suite modules are organized in subpackages according to their purpose. The following subpackages are available: """ from __future__ import annotations __all__ = [ "run", "sigimax_test_app_context", ] import os import os.path as osp import sys from contextlib import contextmanager from typing import Generator from guidata.guitest import run_testlauncher from sigima.tests import helpers import sigimax from sigimax.config import MOD_NAME from sigimax.mainwindow import SGMXMainWindow from sigimax.utils import qthelpers as qth # Add test data files and folders for the SigimaX module: helpers.add_test_module_path(MOD_NAME, osp.join("data", "tests")) @contextmanager def sigimax_test_app_context( size: tuple[int, int] = None, maximized: bool = False, save: bool = False, console: bool | None = None, exec_loop: bool = True, ) -> Generator[SGMXMainWindow, None, None]: """Context manager handling SigimaX mainwindow creation and Qt event loop with optional HDF5 file save and other options for testing purposes Args: size: mainwindow size (default: (950, 600)) maximized: whether to maximize mainwindow (default: False) save: whether to save HDF5 file (default: False) console: whether to show console (default: None) exec_loop: whether to execute Qt event loop (default: True) """ if size is None: size = 1200, 700 with qth.sigimax_app_context(exec_loop=exec_loop): win: SGMXMainWindow | None = None try: win = SGMXMainWindow(console=console) if maximized: win.showMaximized() else: width, height = size win.resize(width, height) win.showNormal() win.show() win.setObjectName(helpers.get_default_test_name()) # screenshot name yield win finally: if save: path = helpers.get_output_data_path("h5") try: os.remove(path) win.save_to_h5_file(path) except (FileNotFoundError, PermissionError): pass has_exception_occurred = sys.exc_info()[0] is not None if not exec_loop or has_exception_occurred and win is not None: # Closing main window properly win.set_modified(False) win.close() def run() -> None: """Run SigimaX test launcher""" run_testlauncher(sigimax) if __name__ == "__main__": run() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/adapters_plotpy/000077500000000000000000000000001524425144300244745ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/tests/adapters_plotpy/__init__.py000066400000000000000000000005341524425144300266070ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Adapter PlotPy tests (:mod:`sigimax.tests.adapters_plotpy`) ----------------------------------------------------------- Unit tests for the :mod:`sigimax.adapters_plotpy` package, which adapts Sigima objects (signals, images, ROIs) to PlotPy plot items. """ DataLab-Platform-SigimaX-0182bf4/sigimax/tests/adapters_plotpy/test_coordutils.py000066400000000000000000000151201524425144300302730ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Coordinate utilities unit tests -------------------------------- Covers :mod:`sigimax.adapters_plotpy.coordutils`: rounding of signal/image coordinates and ROI parameters to a resolution-dependent precision. """ from __future__ import annotations import numpy as np import pytest from sigima.objects import ( CircularROI, PolygonalROI, RectangularROI, SegmentROI, create_image, create_signal, ) from sigimax.adapters_plotpy.coordutils import ( round_image_coords, round_image_roi_param, round_signal_coords, round_signal_roi_param, ) pytestmark = pytest.mark.unit def test_round_signal_coords(): """Test signal coordinate rounding""" # Create a signal with sampling period of 0.1 x = np.arange(0, 10, 0.1) y = np.sin(x) sig = create_signal("test", x, y) # Test basic rounding coords = [1.23456789, 5.87654321] rounded = round_signal_coords(sig, coords) # With sampling period 0.1 and precision_factor 0.1, precision = 0.01 # Should round to 2 decimal places assert rounded == [1.23, 5.88] # Test with custom precision factor rounded = round_signal_coords(sig, coords, precision_factor=1.0) # precision = 0.1, should round to 1 decimal place assert rounded == [1.2, 5.9] # Test with signal that has too few points sig_short = create_signal("test", np.array([1.0]), np.array([2.0])) coords = [1.23456789] rounded = round_signal_coords(sig_short, coords) # Should return coords as-is assert rounded == coords # Test with constant x (zero sampling period) sig_const = create_signal("test", np.ones(10), np.ones(10)) rounded = round_signal_coords(sig_const, coords) # Should return coords as-is assert rounded == coords def test_round_image_coords(): """Test image coordinate rounding""" # Create an image with dx=dy=1.0 (uniform) data = np.ones((100, 100)) img = create_image("test", data) # Test basic rounding coords = [10.123456, 20.987654, 30.555555, 40.444444] rounded = round_image_coords(img, coords) # With pixel spacing 1.0 and precision_factor 0.1, precision = 0.1 # Should round to 1 decimal place assert rounded == [10.1, 21.0, 30.6, 40.4] # Test with custom precision factor rounded = round_image_coords(img, coords, precision_factor=1.0) # precision = 1.0, should round to 0 decimal places assert rounded == [10.0, 21.0, 31.0, 40.0] # Test with empty coords assert not round_image_coords(img, []) # Test error for odd number of coordinates with pytest.raises(ValueError, match="even number of elements"): round_image_coords(img, [1.0, 2.0, 3.0]) def test_round_signal_roi_param(): """Test signal ROI parameter rounding""" # Create a signal with sampling period of 0.1 x = np.arange(0, 10, 0.1) y = np.sin(x) sig = create_signal("test", x, y) # Create a segment ROI roi = SegmentROI([1.23456789, 5.87654321], False) param = roi.to_param(sig, 0) # Round the parameter round_signal_roi_param(sig, param) # Check that coordinates are rounded assert param.xmin == 1.23 assert param.xmax == 5.88 def test_round_image_roi_param_rectangle(): """Test image ROI parameter rounding for rectangular ROI""" # Create an image with dx=dy=1.0 data = np.ones((100, 100)) img = create_image("test", data) # Create a rectangular ROI with floating-point errors roi = RectangularROI([10.0, 20.0, 50.29999999999995, 75.19999999999999], False) param = roi.to_param(img, 0) # Verify we have the floating-point errors before rounding assert param.dx == 50.29999999999995 assert param.dy == 75.19999999999999 # Round the parameter round_image_roi_param(img, param) # Check that coordinates are rounded assert param.x0 == 10.0 assert param.y0 == 20.0 assert param.dx == 50.3 assert param.dy == 75.2 def test_round_image_roi_param_circle(): """Test image ROI parameter rounding for circular ROI""" # Create an image with dx=dy=1.0 data = np.ones((100, 100)) img = create_image("test", data) # Create a circular ROI with floating-point errors roi = CircularROI([50.123456, 50.987654, 25.555555], False) param = roi.to_param(img, 0) # Round the parameter round_image_roi_param(img, param) # Check that coordinates are rounded assert param.xc == 50.1 assert param.yc == 51.0 assert param.r == 25.6 def test_round_image_roi_param_polygon(): """Test image ROI parameter rounding for polygonal ROI""" # Create an image with dx=dy=1.0 data = np.ones((100, 100)) img = create_image("test", data) # Create a polygonal ROI with floating-point errors coords = [10.123456, 20.987654, 30.555555, 40.444444, 50.111111, 60.999999] roi = PolygonalROI(coords, False) param = roi.to_param(img, 0) # Round the parameter round_image_roi_param(img, param) # Check that coordinates are rounded expected = np.array([10.1, 21.0, 30.6, 40.4, 50.1, 61.0]) np.testing.assert_array_equal(param.points, expected) def test_round_coords_non_uniform_image(): """Test coordinate rounding for non-uniform image coordinates""" # Create an image with non-uniform coordinates data = np.ones((10, 10)) img = create_image("test", data) # Set non-uniform coordinates img.xcoords = np.array([0, 1, 3, 6, 10, 15, 21, 28, 36, 45]) # varying spacing img.ycoords = np.array([0, 2, 4, 6, 8, 10, 12, 14, 16, 18]) # uniform spacing of 2 # Test rounding - should use average spacing coords = [5.123456, 7.987654, 25.555555, 13.444444] rounded = round_image_coords(img, coords) # Average dx ≈ 5.0, average dy = 2.0 # With precision_factor=0.1: precision_x=0.5, precision_y=0.2 # Should round to 1 decimal place for both assert rounded == [5.1, 8.0, 25.6, 13.4] def test_round_coords_preserves_structure(): """Test that coordinate rounding preserves the structure of coordinates""" # Create an image data = np.ones((100, 100)) img = create_image("test", data) # Test with multiple coordinate pairs coords = [ 10.111, 20.222, 30.333, 40.444, 50.555, 60.666, 70.777, 80.888, ] rounded = round_image_coords(img, coords) # Should have same length assert len(rounded) == len(coords) # Each coordinate should be rounded independently assert all(isinstance(c, (int, float)) for c in rounded) if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/adapters_plotpy/test_factory.py000066400000000000000000000073261524425144300275640ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tier 1 — Factory and pure-unit tests (no Qt) --------------------------------------------- Tests for :func:`create_adapter_from_object`, unsupported types, :meth:`iterate_metadata_shape_items` default hook, and annotation roundtrip logic that do **not** require a running Qt application. """ from __future__ import annotations import pytest from sigima.objects import ( CircularROI, PolygonalROI, RectangularROI, SegmentROI, create_image_roi, create_signal_roi, ) from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal from sigimax.adapters_plotpy.converters import create_adapter_from_object from sigimax.adapters_plotpy.objects.image import ImageObjPlotPyAdapter from sigimax.adapters_plotpy.objects.signal import SignalObjPlotPyAdapter from sigimax.adapters_plotpy.roi.image import ( CircularROIPlotPyAdapter, ImageROIPlotPyAdapter, PolygonalROIPlotPyAdapter, RectangularROIPlotPyAdapter, ) from sigimax.adapters_plotpy.roi.signal import ( SegmentROIPlotPyAdapter, SignalROIPlotPyAdapter, ) pytestmark = pytest.mark.unit __all__ = [ "test_factory_core_types", "test_factory_unsupported_type", "test_iterate_metadata_hook_default", ] # --------------------------------------------------------------------------- # test_factory_core_types # --------------------------------------------------------------------------- _EXPECTED_ADAPTERS = [ # (factory_input_builder, expected_adapter_class) (create_paracetamol_signal, SignalObjPlotPyAdapter), (create_multigaussian_image, ImageObjPlotPyAdapter), ( lambda: create_signal_roi([7.5, 10.0]), SignalROIPlotPyAdapter, ), ( lambda: SegmentROI([7.5, 10.0], indices=False), SegmentROIPlotPyAdapter, ), ( lambda: RectangularROI([10, 20, 30, 40], indices=False), RectangularROIPlotPyAdapter, ), ( lambda: CircularROI([10, 20, 5], indices=False), CircularROIPlotPyAdapter, ), ( lambda: PolygonalROI([0, 0, 10, 0, 5, 8], indices=False), PolygonalROIPlotPyAdapter, ), ( lambda: create_image_roi("rectangle", [10, 20, 30, 40]), ImageROIPlotPyAdapter, ), ] @pytest.mark.parametrize( "builder, expected_cls", _EXPECTED_ADAPTERS, ids=[ "SignalObj", "ImageObj", "SignalROI", "SegmentROI", "RectangularROI", "CircularROI", "PolygonalROI", "ImageROI", ], ) def test_factory_core_types(builder, expected_cls): """create_adapter_from_object() returns the correct adapter for each type.""" obj = builder() adapter = create_adapter_from_object(obj) assert isinstance(adapter, expected_cls) # --------------------------------------------------------------------------- # test_factory_unsupported_type # --------------------------------------------------------------------------- def test_factory_unsupported_type(): """create_adapter_from_object() raises TypeError for unknown types.""" with pytest.raises(TypeError, match="Unsupported object type"): create_adapter_from_object("not a sigima object") # --------------------------------------------------------------------------- # test_iterate_metadata_hook_default # --------------------------------------------------------------------------- def test_iterate_metadata_hook_default(): """BaseObjPlotPyAdapter.iterate_metadata_shape_items() yields nothing.""" sig = create_paracetamol_signal() adapter = create_adapter_from_object(sig) items = list(adapter.iterate_metadata_shape_items("some_key", "val", "%g", True)) assert not items DataLab-Platform-SigimaX-0182bf4/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py000066400000000000000000000121361524425144300321260ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tier 4 — iterate_shape_items integration tests (offscreen Qt) -------------------------------------------------------------- Tests that verify :meth:`iterate_shape_items` yields the expected plot items when the underlying object carries ROI metadata, annotations, or neither. Also includes the annotation roundtrip test (conceptually Tier 1 but needs Qt because PlotPy items are QGraphicsObject subclasses). """ from __future__ import annotations import numpy as np import pytest from guidata.qthelpers import qt_app_context from plotpy.items import AnnotatedRectangle, AnnotatedXRange from sigima.objects import create_image_roi, create_signal_roi from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal from sigimax.adapters_plotpy.converters import create_adapter_from_object pytestmark = pytest.mark.gui __all__ = [ "test_annotations_roundtrip", "test_iterate_shape_items_empty", "test_iterate_shape_items_with_annotations", "test_iterate_shape_items_with_roi", ] # --------------------------------------------------------------------------- # Annotation roundtrip (Tier 1 concept, needs Qt for PlotPy items) # --------------------------------------------------------------------------- def test_annotations_roundtrip(): """add_annotations_from_items() → get_items() preserves annotation data.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() adapter = create_adapter_from_object(sig) # Create a PlotPy annotation item x0, y0, x1, y1 = 1.0, 2.0, 5.0, 8.0 rect = AnnotatedRectangle(x0, y0, x1, y1) # Store via adapter adapter.add_annotations_from_items([rect]) assert sig.has_annotations() # Retrieve via annotation adapter recovered = adapter.annotation_adapter.get_items() assert len(recovered) == 1 rec_rect = recovered[0] assert isinstance(rec_rect, AnnotatedRectangle) # Verify coordinates roundtrip r_x0, r_y0, r_x1, r_y1 = rec_rect.get_rect() np.testing.assert_allclose([r_x0, r_y0, r_x1, r_y1], [x0, y0, x1, y1]) # --------------------------------------------------------------------------- # iterate_shape_items — with ROI # --------------------------------------------------------------------------- def test_iterate_shape_items_with_roi(): """Object with ROI metadata → iterate_shape_items yields ROI plot items.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() # Attach a signal ROI (physical coordinates) xmin, xmax = float(sig.x[50]), float(sig.x[100]) sig.roi = create_signal_roi([xmin, xmax]) adapter = create_adapter_from_object(sig) items = list(adapter.iterate_shape_items(editable=False)) # At least one item should have been produced for the ROI assert len(items) >= 1 # The item should be an AnnotatedXRange (signal ROI) assert isinstance(items[0], AnnotatedXRange) def test_iterate_shape_items_with_image_roi(): """Image with ROI metadata → iterate_shape_items yields ROI plot items.""" with qt_app_context(exec_loop=False): img = create_multigaussian_image() # Attach a rectangular ROI (physical coordinates) img.roi = create_image_roi("rectangle", [2.0, 3.0, 4.0, 5.0]) adapter = create_adapter_from_object(img) items = list(adapter.iterate_shape_items(editable=False)) assert len(items) >= 1 assert isinstance(items[0], AnnotatedRectangle) # --------------------------------------------------------------------------- # iterate_shape_items — with annotations # --------------------------------------------------------------------------- def test_iterate_shape_items_with_annotations(): """Object with annotations → iterate_shape_items yields annotation items.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() adapter = create_adapter_from_object(sig) # Add an annotation rect = AnnotatedRectangle(0.0, 0.0, 5.0, 5.0) adapter.add_annotations_from_items([rect]) items = list(adapter.iterate_shape_items(editable=False)) # Should contain at least the annotation item assert len(items) >= 1 # Find the AnnotatedRectangle among yielded items rects = [it for it in items if isinstance(it, AnnotatedRectangle)] assert len(rects) == 1 # --------------------------------------------------------------------------- # iterate_shape_items — empty # --------------------------------------------------------------------------- def test_iterate_shape_items_empty(): """No metadata → iterate_shape_items yields nothing.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() # Ensure no ROI and no annotations sig.roi = None sig.annotations = "" adapter = create_adapter_from_object(sig) items = list(adapter.iterate_shape_items(editable=False)) assert not items DataLab-Platform-SigimaX-0182bf4/sigimax/tests/adapters_plotpy/test_plot_items.py000066400000000000000000000125711524425144300302720ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tier 2 — Plot-item tests (offscreen Qt) ----------------------------------------- Tests that exercise :meth:`make_item`, :meth:`update_item`, and the plot-item-parameter roundtrip for signals and images. Qt is required because PlotPy plot items are QGraphicsObject subclasses. The tests use ``guidata.qthelpers.qt_app_context(exec_loop=False)`` so no event-loop interaction is needed. """ from __future__ import annotations from unittest.mock import MagicMock import numpy as np import pytest from guidata.qthelpers import qt_app_context from plotpy.items import CurveItem, MaskedXYImageItem from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal from sigimax.adapters_plotpy.converters import create_adapter_from_object from sigimax.adapters_plotpy.objects.image import ImageObjPlotPyAdapter from sigimax.adapters_plotpy.objects.signal import SignalObjPlotPyAdapter pytestmark = pytest.mark.gui __all__ = [ "test_image_make_item", "test_image_update_item", "test_signal_make_item", "test_signal_metadata_options", "test_signal_update_item", ] # --------------------------------------------------------------------------- # Signal — make_item # --------------------------------------------------------------------------- def test_signal_make_item(): """SignalObjPlotPyAdapter.make_item() returns a CurveItem with correct data.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() adapter: SignalObjPlotPyAdapter = create_adapter_from_object(sig) item = adapter.make_item() assert isinstance(item, CurveItem) # Verify that the plot item carries the expected data x_item, y_item = item.get_data()[:2] x_obj, y_obj = sig.xydata[:2] np.testing.assert_array_equal(x_item, x_obj.real) np.testing.assert_array_equal(y_item, y_obj.real) # --------------------------------------------------------------------------- # Image — make_item # --------------------------------------------------------------------------- def test_image_make_item(): """ ImageObjPlotPyAdapter.make_item() returns a MaskedXYImageItem with correct data. """ with qt_app_context(exec_loop=False): img = create_multigaussian_image() adapter: ImageObjPlotPyAdapter = create_adapter_from_object(img) item = adapter.make_item() assert isinstance(item, MaskedXYImageItem) # Verify that the underlying data matches item_data = item.data np.testing.assert_array_equal(item_data, img.data.real) # --------------------------------------------------------------------------- # Signal — update_item # --------------------------------------------------------------------------- def test_signal_update_item(): """update_item() refreshes an existing CurveItem with new data.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() adapter: SignalObjPlotPyAdapter = create_adapter_from_object(sig) item = adapter.make_item() # Mutate the signal data sig.y = sig.y * 2.0 adapter.update_item(item, data_changed=True) x_item, y_item = item.get_data()[:2] x_obj, y_obj = sig.xydata[:2] np.testing.assert_array_equal(x_item, x_obj.real) np.testing.assert_array_equal(y_item, y_obj.real) # --------------------------------------------------------------------------- # Image — update_item # --------------------------------------------------------------------------- def test_image_update_item(): """update_item() refreshes an existing MaskedXYImageItem with new data.""" with qt_app_context(exec_loop=False): img = create_multigaussian_image() adapter: ImageObjPlotPyAdapter = create_adapter_from_object(img) item = adapter.make_item() # Mutate the image data img.data = (img.data * 0.5).astype(img.data.dtype) # update_item() calls item.plot().update_colormap_axis() which requires # the item to be attached to a plot widget. Patch item.plot to avoid the # AttributeError in this headless test. item.plot = MagicMock() adapter.update_item(item, data_changed=True) np.testing.assert_array_equal(item.data, img.data.real) # --------------------------------------------------------------------------- # Signal — metadata options roundtrip # --------------------------------------------------------------------------- def test_signal_metadata_options(): """update_plot_item_parameters ↔ update_metadata_from_plot_item preserves data.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() adapter: SignalObjPlotPyAdapter = create_adapter_from_object(sig) item = adapter.make_item() # Snapshot metadata that was written into the item adapter.update_plot_item_parameters(item) # Read metadata back from the item adapter.update_metadata_from_plot_item(item) # Create a fresh item from the same (now updated) object item2 = adapter.make_item() # The two items should have identical curve parameters assert item.param.label == item2.param.label assert item.param.line.color == item2.param.line.color assert item.param.line.style == item2.param.line.style DataLab-Platform-SigimaX-0182bf4/sigimax/tests/adapters_plotpy/test_roi_roundtrip.py000066400000000000000000000111031524425144300310000ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tier 3 — ROI conversion roundtrip tests (offscreen Qt) ------------------------------------------------------- Tests that verify converting a Sigima ROI to a PlotPy plot item and back preserves the original coordinates for both signal and image ROI types. """ from __future__ import annotations import numpy as np import pytest from guidata.qthelpers import qt_app_context from sigima.objects import ( CircularROI, PolygonalROI, RectangularROI, SegmentROI, create_image_roi, create_signal_roi, ) from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal from sigimax.adapters_plotpy.converters import ( plotitem_to_singleroi, singleroi_to_plotitem, ) pytestmark = pytest.mark.gui __all__ = [ "test_image_roi_roundtrip_circle", "test_image_roi_roundtrip_polygon", "test_image_roi_roundtrip_rectangle", "test_signal_roi_roundtrip", ] # --------------------------------------------------------------------------- # Signal ROI roundtrip # --------------------------------------------------------------------------- def test_signal_roi_roundtrip(): """Signal SegmentROI → plot item → SegmentROI preserves coordinates.""" with qt_app_context(exec_loop=False): sig = create_paracetamol_signal() # Use physical coordinates xmin, xmax = float(sig.x[50]), float(sig.x[100]) roi = create_signal_roi([xmin, xmax]) original = roi.get_single_roi(0) # ROI → plot item item = singleroi_to_plotitem(original, sig) # Plot item → ROI recovered = plotitem_to_singleroi(item, sig) assert isinstance(recovered, SegmentROI) orig_coords = original.get_physical_coords(sig) rec_coords = recovered.get_physical_coords(sig) np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6) # --------------------------------------------------------------------------- # Image ROI roundtrip — Rectangle # --------------------------------------------------------------------------- def test_image_roi_roundtrip_rectangle(): """Image RectangularROI → plot item → RectangularROI preserves coords.""" with qt_app_context(exec_loop=False): img = create_multigaussian_image() roi = create_image_roi("rectangle", [2.0, 3.0, 4.0, 5.0]) original = roi.get_single_roi(0) item = singleroi_to_plotitem(original, img) recovered = plotitem_to_singleroi(item, img) assert isinstance(recovered, RectangularROI) orig_coords = np.array(original.get_physical_coords(img), dtype=float) rec_coords = np.array(recovered.get_physical_coords(img), dtype=float) np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6) # --------------------------------------------------------------------------- # Image ROI roundtrip — Circle # --------------------------------------------------------------------------- def test_image_roi_roundtrip_circle(): """Image CircularROI → plot item → CircularROI preserves coords.""" with qt_app_context(exec_loop=False): img = create_multigaussian_image() roi = create_image_roi("circle", [0.0, 0.0, 3.0]) original = roi.get_single_roi(0) item = singleroi_to_plotitem(original, img) recovered = plotitem_to_singleroi(item, img) assert isinstance(recovered, CircularROI) orig_coords = np.array(original.get_physical_coords(img), dtype=float) rec_coords = np.array(recovered.get_physical_coords(img), dtype=float) np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6) # --------------------------------------------------------------------------- # Image ROI roundtrip — Polygon # --------------------------------------------------------------------------- def test_image_roi_roundtrip_polygon(): """Image PolygonalROI → plot item → PolygonalROI preserves coords.""" with qt_app_context(exec_loop=False): img = create_multigaussian_image() coords = [0.0, 0.0, 5.0, 0.0, 5.0, 5.0, 0.0, 5.0] roi = create_image_roi("polygon", coords) original = roi.get_single_roi(0) item = singleroi_to_plotitem(original, img) recovered = plotitem_to_singleroi(item, img) assert isinstance(recovered, PolygonalROI) orig_coords = np.array(original.get_physical_coords(img), dtype=float) rec_coords = np.array(recovered.get_physical_coords(img), dtype=float) np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/config/000077500000000000000000000000001524425144300225275ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/tests/config/__init__.py000066400000000000000000000000021524425144300246300ustar00rootroot00000000000000# DataLab-Platform-SigimaX-0182bf4/sigimax/tests/config/test_config_fields.py000066400000000000000000000414611524425144300267410ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for config.py option field classes and helpers ---------------------------------------------------- Covers: - TupleOptionField: set/get, list→tuple conversion, validation - FontOptionField: set/get, validation - AppOptionsContainer: to_dict/from_dict roundtrip, reset_to_defaults - get_old_log_fname, is_frozen, get_mod_source_dir - SigimaXOptions.list_options completeness """ from __future__ import annotations import os.path as osp import sys import tempfile import guidata.dataset as gds import pytest from sigimax.config import ( AppOptionsContainer, ConfigPathOptionField, DataSetOptionField, EnumOptionField, FontOptionField, SigimaXOptions, TupleOptionField, TypedOptionField, WorkingDirOptionField, get_mod_source_dir, get_old_log_fname, is_frozen, ) pytestmark = pytest.mark.unit class _SampleParam(gds.DataSet): """Simple DataSet used to exercise DataSetOptionField.""" value = gds.IntItem("Value", default=3) # --------------------------------------------------------------------------- # Helpers: minimal container for isolated field tests # --------------------------------------------------------------------------- class _MiniContainer(AppOptionsContainer): APP_NAME = "MiniTest" def __init__(self): super().__init__() self.changed_options = [] self.my_tuple = TupleOptionField( self, "my_tuple", default=(10, 20), description="A tuple option" ) self.my_font = FontOptionField( self, "my_font", default=("Arial", 12, False), description="A font option" ) self.my_enum = EnumOptionField( self, "my_enum", default="a", choices=["a", "b", "c"], description="An enum option", ) self.my_str = TypedOptionField( self, "my_str", default="hello", expected_type=str, description="A string" ) def option_changed(self, name): """Record changed options.""" self.changed_options.append(name) # ============================== TupleOptionField ============================== class TestTupleOptionField: """Tests for TupleOptionField.""" def test_get_default(self): """Getting the default value should return the initial tuple.""" c = _MiniContainer() assert c.my_tuple.get() == (10, 20) def test_get_optional_default_returns_exact_value(self): """A missing field returns the supplied default before normalization.""" c = _MiniContainer() default = [30, 40] assert c.my_tuple.get(default) is default assert c.my_tuple.get() == (30, 40) def test_get_optional_default_does_not_replace_set_value(self): """An explicitly set value takes precedence over a later default.""" c = _MiniContainer() c.my_tuple.set((50, 60)) assert c.my_tuple.get((1, 2)) == (50, 60) def test_get_none_does_not_initialize(self): """None is a non-persisting fallback and leaves the field uninitialized.""" c = _MiniContainer() assert c.my_tuple.get(None) == (10, 20) assert not c.is_option_initialized("my_tuple") def test_context_restores_uninitialized_state(self): """A temporary override must not persist initialization state.""" c = _MiniContainer() with c.my_tuple.context((30, 40)): assert c.my_tuple.get() == (30, 40) assert c.is_option_initialized("my_tuple") assert c.my_tuple.get() == (10, 20) assert not c.is_option_initialized("my_tuple") def test_context_restores_state_after_exception(self): """Context restoration also applies when the body raises.""" c = _MiniContainer() with pytest.raises(RuntimeError, match="stop"): with c.my_tuple.context((30, 40)): raise RuntimeError("stop") assert c.my_tuple.get() == (10, 20) assert not c.is_option_initialized("my_tuple") def test_set_tuple(self): """Setting a new tuple value should update the stored value.""" c = _MiniContainer() c.my_tuple.set((100, 200)) assert c.my_tuple.get() == (100, 200) def test_set_list_converts_to_tuple(self): """Setting a list should convert it to a tuple and store it correctly.""" c = _MiniContainer() c.my_tuple.set([5, 6]) assert c.my_tuple.get() == (5, 6) assert isinstance(c.my_tuple.get(), tuple) def test_set_none(self): """Setting None should store None without error.""" c = _MiniContainer() c.my_tuple.set(None) assert c.my_tuple.get() is None def test_set_invalid_type_raises(self): """Setting a non-iterable or non-list/tuple should raise a ValueError.""" c = _MiniContainer() with pytest.raises(ValueError, match="expected tuple"): c.my_tuple.set("bad") def test_set_invalid_int_raises(self): """Setting an integer should raise a ValueError since it's not iterable.""" c = _MiniContainer() with pytest.raises(ValueError, match="expected tuple"): c.my_tuple.set(42) # ============================== FontOptionField =============================== class TestFontOptionField: """Tests for FontOptionField.""" def test_get_default(self): """Getting the default value should return the initial font tuple.""" c = _MiniContainer() assert c.my_font.get() == ("Arial", 12, False) def test_set_tuple(self): """Setting a new font tuple should update the stored value.""" c = _MiniContainer() c.my_font.set(("Courier", 10, True)) assert c.my_font.get() == ("Courier", 10, True) def test_set_list_converts_to_tuple(self): """Setting a list should convert it to a tuple and store it correctly.""" c = _MiniContainer() c.my_font.set(["Mono", 14, False]) result = c.my_font.get() assert result == ("Mono", 14, False) assert isinstance(result, tuple) def test_set_none(self): """Setting None should store None without error.""" c = _MiniContainer() c.my_font.set(None) assert c.my_font.get() is None def test_set_invalid_length_raises(self): """Setting a tuple/list of incorrect length should raise a ValueError.""" c = _MiniContainer() with pytest.raises(ValueError, match="expected.*family.*size.*bold"): c.my_font.set(("one", "two")) def test_set_invalid_first_element_raises(self): """Setting a non-string first element should raise a ValueError.""" c = _MiniContainer() with pytest.raises(ValueError, match="expected.*family.*size.*bold"): c.my_font.set((123, 12, False)) def test_set_invalid_type_raises(self): """Setting a non-iterable or non-list/tuple should raise a ValueError.""" c = _MiniContainer() with pytest.raises(ValueError, match="expected.*family.*size.*bold"): c.my_font.set("bad") def test_get_font_builds_qfont(self): """get_font returns a QFont matching the stored specification.""" # pylint: disable-next=import-outside-toplevel from qtpy.QtWidgets import QApplication _app = QApplication.instance() or QApplication([]) c = _MiniContainer() c.my_font.set(["Courier New", 10, True]) font = c.my_font.get_font() assert font.family() == "Courier New" assert font.pointSize() == 10 assert font.bold() is True # ======================== ConfigPathOptionField =============================== class TestConfigPathOptionField: """Tests for ConfigPathOptionField.""" def test_resolves_basename_and_roundtrips_raw_value(self): """ConfigPathOptionField resolves basenames and round-trips raw values.""" container = _MiniContainer() field = ConfigPathOptionField( container, "traceback_log_path", ".SigimaX_tb.log" ) resolved = field.get() assert osp.basename(resolved) == ".SigimaX_tb.log" assert osp.isabs(resolved) # Storage accessors expose the bare basename (no path resolution). assert field.to_storage() == ".SigimaX_tb.log" field.from_storage(".Other.log") assert field.to_storage() == ".Other.log" assert osp.basename(field.get()) == ".Other.log" # A full path (not a bare basename) is rejected on get(). field.from_storage(osp.join("sub", "dir", "file.log")) with pytest.raises(ValueError): field.get() # ======================== WorkingDirOptionField =============================== class TestWorkingDirOptionField: """Tests for WorkingDirOptionField.""" def test_validates_directories_and_tolerates_missing_ones(self, tmp_path): """WorkingDirOptionField validates directories and tolerates missing ones.""" container = _MiniContainer() field = WorkingDirOptionField(container, "base_dir", "") # Setting an existing directory stores it and get() returns it. field.set(str(tmp_path)) assert field.get() == str(tmp_path) # Setting a file path stores its parent directory. a_file = tmp_path / "data.txt" a_file.write_text("x", encoding="utf-8") field.set(str(a_file)) assert field.get() == str(tmp_path) # Setting an invalid directory raises. with pytest.raises(FileNotFoundError): field.set(str(tmp_path / "does_not_exist" / "child")) # get() returns "" when the stored directory no longer exists, but the raw # value is preserved. missing = str(tmp_path / "gone") field.from_storage(missing) assert field.get() == "" assert field.to_storage() == missing # ======================== DataSetOptionField =================================== class TestDataSetOptionField: """Tests for DataSetOptionField.""" def test_falls_back_to_default_and_roundtrips_json(self): """ DataSetOptionField falls back to the default instance and round-trips JSON. """ container = _MiniContainer() default = _SampleParam() default.value = 7 field = DataSetOptionField(container, "sample_param", default_instance=default) # Without an explicit value, get() returns the default instance. assert field.get() is default assert field.get_raw() is None assert field.to_json() is None # Setting an explicit value takes precedence. param = _SampleParam() param.value = 42 field.set(param) assert field.get() is param assert field.get_raw() is param # JSON round-trip restores the stored value. json_str = field.to_json() assert json_str is not None field.from_storage(None) field.from_json(json_str) assert field.get().value == 42 # set_default_instance updates the fallback used when no value is set. field.from_storage(None) new_default = _SampleParam() new_default.value = 99 field.set_default_instance(new_default) assert field.get() is new_default def test_invalid_json_uses_default(self): """An unresolved DataSet class is discarded and falls back to the default.""" container = _MiniContainer() default = _SampleParam() field = DataSetOptionField(container, "sample_param", default_instance=default) field.from_json( '{"class_module": "missing_module", "class_name": "MissingParam"}' ) assert field.get() is default assert not container.is_option_initialized("sample_param") # ======================== AppOptionsContainer ================================ class TestAppOptionsContainer: """Tests for AppOptionsContainer to_dict / from_dict / reset.""" def test_to_dict_roundtrip(self): """ Setting some values and converting to dict should produce a dict that can be loaded back to the same values. """ c = _MiniContainer() c.my_str.set("world") c.my_tuple.set((1, 2)) d = c.to_dict() assert d["my_str"] == "world" assert d["my_tuple"] == (1, 2) c2 = _MiniContainer() c2.from_dict(d) assert c2.my_str.get() == "world" assert c2.my_tuple.get() == (1, 2) def test_from_dict_ignores_unknown_keys(self): """ Providing unknown keys in from_dict should not raise an error and should ignore them. """ c = _MiniContainer() c.from_dict({"unknown_key": 999, "my_str": "ok"}) assert c.my_str.get() == "ok" def test_from_dict_marks_option_initialized(self): """Loaded values take precedence over later optional defaults.""" c = _MiniContainer() c.from_dict({"my_str": "loaded"}) assert c.my_str.get("fallback") == "loaded" def test_option_changed_hook_tracks_set_and_context_restore(self): """Option changes and context restoration call the container hook.""" c = _MiniContainer() c.my_str.set("set") with c.my_str.context("temporary"): pass assert c.changed_options == ["my_str", "my_str", "my_str"] def test_generate_rst_doc_uses_sigimax_option_api(self): """RST generation reads SigimaX options without changing their state.""" c = _MiniContainer() rst = c.generate_rst_doc() assert "``my_str``" in rst assert "``'hello'``" in rst assert not c.changed_options assert not c.is_option_initialized("my_str") def test_from_dict_invalid_value_warning(self, capsys): """Providing an invalid value in from_dict should produce a warning.""" c = _MiniContainer() c.from_dict({"my_enum": "invalid_choice"}) captured = capsys.readouterr() assert "Warning" in captured.out or "invalid" in captured.out.lower() def test_list_options(self): """list_options should return the names of all defined options.""" c = _MiniContainer() names = c.list_options() assert "my_tuple" in names assert "my_font" in names assert "my_enum" in names assert "my_str" in names def test_save_load_roundtrip(self): """Saving and loading should preserve the values.""" c = _MiniContainer() c.my_str.set("persisted") c.my_tuple.set((99, 100)) with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: path = tmp.name c.save(path) c2 = _MiniContainer() c2.load(path) assert c2.my_str.get() == "persisted" assert c2.my_tuple.get() == (99, 100) # ======================== SigimaXOptions ===================================== class TestSigimaXOptions: """Tests for the full SigimaXOptions CONF singleton.""" def test_list_options_contains_expected(self): """list_options should include all expected option names.""" opts = SigimaXOptions() names = opts.list_options() # Check a representative sample of expected options expected = [ "app_name", "color_mode", "console_enabled", "window_maximized", "ima_def_colormap", ] for name in expected: assert name in names, f"Expected option '{name}' not in list_options()" def test_reset_to_defaults(self): """reset_to_defaults should restore default values for all options.""" opts = SigimaXOptions() original = opts.ima_def_colormap.get() opts.ima_def_colormap.set("gray") assert opts.ima_def_colormap.get() == "gray" opts.reset_to_defaults() assert opts.ima_def_colormap.get() == original # ======================== Module-level helpers =============================== class TestModuleHelpers: """Tests for get_old_log_fname, is_frozen, get_mod_source_dir.""" def test_get_old_log_fname(self): """get_old_log_fname should insert .1 before the extension.""" assert get_old_log_fname("app.log") == "app.1.log" assert get_old_log_fname("/path/to/my.log") == "/path/to/my.1.log" def test_is_frozen_returns_bool(self): """is_frozen should return a boolean value.""" result = is_frozen("sigimax") assert isinstance(result, bool) def test_get_mod_source_dir_not_none_in_dev(self): """get_mod_source_dir should return a directory path in development installs.""" # In a development install, get_mod_source_dir should return a directory result = get_mod_source_dir() # Could be None in frozen builds, but in dev it should not be if not hasattr(sys, "_MEIPASS"): assert result is not None if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/config/test_options_subclass.py000066400000000000000000000021661524425144300275370ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Quick test of SigimaX options subclassing.""" # guitest: show import pytest from sigimax.config import SigimaXOptions, TypedOptionField class MyAppOptions(SigimaXOptions): """ Docstring for MyAppOptions """ APP_NAME = "MyApp" def __init__(self): super().__init__() self.rpc_enabled = TypedOptionField( self, "rpc_enabled", default=True, expected_type=bool, description="RPC server", ) self.rpc_port = TypedOptionField( self, "rpc_port", default=8080, expected_type=int, description="RPC port" ) @pytest.mark.unit def test_options_subclass(): """Test that SigimaXOptions can be subclassed with custom options.""" o = MyAppOptions() assert len(o.list_options()) > 0 assert o.rpc_enabled.get() is True assert o.rpc_port.get() == 8080 # Inherited option from SigimaXOptions must be accessible assert o.color_mode.get() is not None if __name__ == "__main__": test_options_subclass() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/conftest.py000066400000000000000000000077421524425144300234730ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX pytest configuration ---------------------------- This file contains the configuration for running pytest in SigimaX. It is executed before running any tests. """ import os import os.path as osp import guidata import h5py import numpy import plotpy import pytest import qtpy import qwt import scipy import sigima import skimage from guidata.config import ValidationMode, set_validation_mode from guidata.utils.gitreport import format_git_info_for_pytest, get_git_info_for_modules from sigima.tests import helpers import sigimax from sigimax.env import execenv # Set validation mode to STRICT for all tests set_validation_mode(ValidationMode.STRICT) # Turn on unattended mode for executing tests without user interaction execenv.unattended = True execenv.verbose = "quiet" INITIAL_CWD = os.getcwd() def pytest_addoption(parser): """Add custom command line options to pytest.""" parser.addoption( "--show-windows", action="store_true", default=False, help="Display Qt windows during tests (disables QT_QPA_PLATFORM=offscreen)", ) def pytest_report_header(config): # pylint: disable=unused-argument """Add additional information to the pytest report header.""" qtbindings_version = qtpy.PYSIDE_VERSION if qtbindings_version is None: qtbindings_version = qtpy.PYQT_VERSION infolist = [ f" sigimax {sigimax.__version__},", f" sigima {sigima.__version__},", f" guidata {guidata.__version__}, PlotPy {plotpy.__version__}", f" PythonQwt {qwt.__version__}, " f"{qtpy.API_NAME} {qtbindings_version} [Qt version: {qtpy.QT_VERSION}]", f" NumPy {numpy.__version__}, SciPy {scipy.__version__}, " f"h5py {h5py.__version__}, scikit-image {skimage.__version__}", ] envlist = [] for vname in ("PYTHONPATH", "DEBUG", "QT_API", "QT_QPA_PLATFORM"): value = os.environ.get(vname, "") if value: if vname == "PYTHONPATH": pathlist = value.split(os.pathsep) envlist.append(f" {vname}:") envlist.extend(f" {p}" for p in pathlist if p) else: envlist.append(f" {vname}: {value}") if envlist: infolist.append("Environment variables:") infolist.extend(envlist) infolist.append("Test paths:") for test_path in helpers.get_test_paths(): test_path = osp.abspath(test_path) infolist.append(f" {test_path}") # Git information for all modules using the new gitreport module modules_config = [ ("SigimaX", sigimax, "."), # SigimaX uses current directory ("guidata", guidata, None), ("PlotPy", plotpy, None), ("Sigima", sigima, None), ] git_repos = get_git_info_for_modules(modules_config) git_info_lines = format_git_info_for_pytest(git_repos, "SigimaX") if git_info_lines: infolist.extend(git_info_lines) return infolist def pytest_configure(config): """Add custom markers to pytest.""" if config.option.durations is None: config.option.durations = 20 # Default to showing 20 slowest tests config.addinivalue_line( "markers", "validation: mark a test as a validation test (ground truth or analytical)", ) config.addinivalue_line( "markers", "unit: pure logic test, no Qt application context needed", ) config.addinivalue_line( "markers", "app: requires full application context (SGMXMainWindow)", ) config.addinivalue_line( "markers", "gui: requires visible Qt window (use --show-windows)", ) if not config.getoption("--show-windows"): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @pytest.fixture(autouse=True) def reset_cwd(request): # pylint: disable=unused-argument """Reset the current working directory to the initial one after each test.""" yield os.chdir(INITIAL_CWD) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/000077500000000000000000000000001524425144300221105ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/__init__.py000066400000000000000000000000021524425144300242110ustar00rootroot00000000000000# DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/_h5browser_memoryleak.py000066400000000000000000000030541524425144300267700ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ HDF5 browser unit tests 2 ------------------------- Testing for memory leak """ # guitest: show,skip import os import time import numpy as np import psutil from guidata.qthelpers import qt_app_context from sigima.viz import view_curves from sigimax.env import execenv from sigimax.tests import helpers from sigimax.widgets.h5browser import H5BrowserDialog def test_memoryleak(fname, iterations=20): """Memory leak test""" with qt_app_context(): proc = psutil.Process(os.getpid()) fname = helpers.get_test_fnames(fname)[0] dlg = H5BrowserDialog(None) memlist = [] for i in range(iterations): t0 = time.time() dlg.open_file(fname) memdata = proc.memory_info().vms / 1024**2 memlist.append(memdata) execenv.print(i + 1, ":", memdata, "MB") dlg.browser.tree.select_all(True) dlg.browser.tree.toggle_all(True) execenv.print(i + 1, ":", proc.memory_info().vms / 1024**2, "MB") dlg.show() dlg.accept() dlg.close() execenv.print(i + 1, ":", proc.memory_info().vms / 1024**2, "MB") dlg.cleanup() execenv.print(i + 1, ":", f"{(time.time() - t0):.1f} s") view_curves( np.array(memlist), title="Memory leak test for HDF5 browser dialog", ylabel="Memory (MB)", ) if __name__ == "__main__": test_memoryleak("scenario*.h5") DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/test_h5_common_collect_attributes.py000066400000000000000000000071751524425144300313720ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ HDF5 reference-attribute unit tests ------------------------------------ Headless regression tests for :meth:`sigimax.h5.common.BaseNode.collect_attributes`. Files converted from HDF4 with ``h4toh5convert`` carry HDF5 object/region *reference* attributes (e.g. ``DIMENSION_LIST`` / ``REFERENCE_LIST``). h5py exposes those as :class:`h5py.h5r.Reference` values, which are **not** picklable (``TypeError: no default __reduce__ due to non-trivial __cinit__``). Copying them into an object's metadata used to crash the first computation, because DataLab pickles the object to run it in a worker process. """ from __future__ import annotations import pickle import uuid import h5py import numpy as np import pytest from sigimax.h5.common import BaseNode pytestmark = pytest.mark.unit class _LeafNode(BaseNode): """Minimal concrete node exposing :meth:`collect_attributes`.""" def _new_memory_file() -> h5py.File: """Return a fresh in-memory HDF5 file with a unique name.""" return h5py.File(f"{uuid.uuid4()}.h5", "w", driver="core", backing_store=False) def test_collect_attributes_keeps_serialisable_values() -> None: """Numeric, boolean and string attributes are copied to metadata.""" h5file = _new_memory_file() try: dset = h5file.create_dataset("data", data=np.zeros((4, 4))) dset.attrs["gain"] = 2.5 dset.attrs["count"] = np.int32(7) dset.attrs["enabled"] = True dset.attrs["label"] = b"detector" dset.attrs["profile"] = np.array([1.0, 2.0, 3.0]) dset.attrs["tags"] = np.array([b"a", b"b"]) node = _LeafNode(h5file, "data") node.collect_attributes() assert node.metadata["gain"] == 2.5 assert node.metadata["count"] == 7 assert node.metadata["enabled"] assert node.metadata["label"] == "detector" assert np.array_equal(node.metadata["profile"], [1.0, 2.0, 3.0]) assert list(node.metadata["tags"]) == [b"a", b"b"] # The whole metadata mapping must stay picklable. pickle.dumps(node.metadata) finally: h5file.close() def test_collect_attributes_skips_reference_attributes() -> None: """HDF5 reference attributes are dropped (they are not picklable).""" ref_dtype = h5py.special_dtype(ref=h5py.Reference) h5file = _new_memory_file() try: h5file.create_dataset("data", data=np.zeros((4, 4))) h5file.create_dataset("scale0", data=np.arange(4)) h5file.create_dataset("scale1", data=np.arange(4)) # Sanity check: a raw HDF5 reference is indeed not picklable. with pytest.raises(TypeError): pickle.dumps(h5file["scale0"].ref) # Array of references, as produced by h4toh5convert (DIMENSION_LIST). refs = np.array([h5file["scale0"].ref, h5file["scale1"].ref], dtype=ref_dtype) h5file["data"].attrs.create("DIMENSION_LIST", refs) # Scalar reference attribute. h5file["data"].attrs["REFERENCE"] = h5file["scale0"].ref # A regular attribute alongside the reference ones. h5file["data"].attrs["gain"] = 1.5 node = _LeafNode(h5file, "data") node.collect_attributes() assert "DIMENSION_LIST" not in node.metadata assert "REFERENCE" not in node.metadata assert node.metadata["gain"] == 1.5 # The surviving metadata is picklable (the whole point of the fix). pickle.dumps(node.metadata) finally: h5file.close() if __name__ == "__main__": test_collect_attributes_keeps_serialisable_values() test_collect_attributes_skips_reference_attributes() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/test_h5_derived_app.py000066400000000000000000000527261524425144300264130ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Derived application with HDF5 workspace serialization test ----------------------------------------------------------- This test demonstrates how a derived application can: 1. Connect :data:`SIG_SEND_OBJECTLIST` to populate a simple data model. 2. Override :meth:`save_h5_workspace` to serialize objects using :class:`guidata.io.HDF5Writer`. 3. Re-open the saved file with :class:`guidata.io.HDF5Reader` and verify the round-trip. The data model is intentionally minimal — a plain list of :class:`SignalObj` / :class:`ImageObj` — to serve as a starting point for downstream applications. """ # guitest: show from __future__ import annotations import os.path as osp import tempfile import h5py import numpy as np import pytest from guidata.io import HDF5Reader, HDF5Writer from plotpy.constants import PlotType from sigima import ImageObj, SignalObj from sigimax.config import CONF as Conf from sigimax.env import execenv from sigimax.mainwindow import SGMXMainWindow from sigimax.tests import helpers from sigimax.utils import qthelpers as qth from sigimax.widgets.plotdock import DockablePlotWidget # Workspace HDF5 key used to store the application version _VERSION_ATTR = "app_version" # Group names inside the workspace file _SIGNALS_GROUP = "signals" _IMAGES_GROUP = "images" # ============================================================================= # Minimal data model # ============================================================================= class SimpleObjectStore: """Minimal object store: two ordered lists (signals + images). This is the simplest useful data model for a SigimaX-based application. Downstream projects can replace it with a richer structure (UUID-keyed dict, groups, etc.) without changing the serialization contract. """ def __init__(self) -> None: self.signals: list[SignalObj] = [] self.images: list[ImageObj] = [] # -- Mutation ---------------------------------------------------------- def add_objects(self, objects: list[SignalObj | ImageObj]) -> None: """Dispatch a list of mixed objects into the appropriate sub-list. Args: objects: list of :class:`SignalObj` and/or :class:`ImageObj` """ for obj in objects: if isinstance(obj, SignalObj): self.signals.append(obj) elif isinstance(obj, ImageObj): self.images.append(obj) def clear(self) -> None: """Remove all objects.""" self.signals.clear() self.images.clear() # -- Query ------------------------------------------------------------- @property def count(self) -> int: """Total number of objects.""" return len(self.signals) + len(self.images) # -- Serialization ----------------------------------------------------- def serialize(self, writer: HDF5Writer) -> None: """Write all objects into the currently open HDF5 writer. Layout:: /signals/ 000/ â†� SignalObj.serialize() 001/ /images/ 000/ â†� ImageObj.serialize() Args: writer: an open :class:`guidata.io.HDF5Writer` """ with writer.group(_SIGNALS_GROUP): for idx, sig in enumerate(self.signals): with writer.group(f"{idx:03d}"): sig.serialize(writer) with writer.group(_IMAGES_GROUP): for idx, ima in enumerate(self.images): with writer.group(f"{idx:03d}"): ima.serialize(writer) def deserialize(self, reader: HDF5Reader) -> None: """Read all objects from the currently open HDF5 reader. Clears the store before loading. Args: reader: an open :class:`guidata.io.HDF5Reader` """ self.clear() # Signals if _SIGNALS_GROUP in reader.h5: with reader.group(_SIGNALS_GROUP): idx = 0 while True: group_name = f"{idx:03d}" current = reader.h5["/" + "/".join(reader.option)] if group_name not in current: break with reader.group(group_name): obj = SignalObj() obj.deserialize(reader) self.signals.append(obj) idx += 1 # Images if _IMAGES_GROUP in reader.h5: with reader.group(_IMAGES_GROUP): idx = 0 while True: group_name = f"{idx:03d}" current = reader.h5["/" + "/".join(reader.option)] if group_name not in current: break with reader.group(group_name): obj = ImageObj() obj.deserialize(reader) self.images.append(obj) idx += 1 # ============================================================================= # Derived main window # ============================================================================= class DerivedAppWindow(SGMXMainWindow): """Example derived main window with a data model and workspace save/load. Demonstrates: - Connecting ``SIG_SEND_OBJECTLIST`` to populate the data model - Overriding ``save_h5_workspace`` using ``guidata.io.HDF5Writer`` - A helper ``load_h5_workspace`` method using ``guidata.io.HDF5Reader`` """ def __init__( self, console: bool | None = None, hide_on_close: bool = False, ) -> None: # Configure global Conf before super().__init__() Conf.app_name.set("DerivedH5App") Conf.app_version.set("0.1.0") self.curve_dock = None super().__init__(console=console, hide_on_close=hide_on_close) # --- Wire the signal emitted by browse_h5_files / import_all_from_h5_file --- self.SIG_SEND_OBJECTLIST.connect(self._on_objects_received) def _setup_docks(self) -> None: """Add a curve dock for visual feedback.""" self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) self._add_dockwidget(self.curve_dock, "Preview", name="preview") def _before_setup(self, console: bool) -> None: """Create the data model before generic setup hooks may use it.""" super()._before_setup(console) self.object_store = SimpleObjectStore() # ------------------------------------------------------------------ # Signal handler # ------------------------------------------------------------------ def _on_objects_received(self, objects: list[SignalObj | ImageObj]) -> None: """Slot connected to :data:`SIG_SEND_OBJECTLIST`. Stores objects in the data model and updates the status bar. """ self.object_store.add_objects(objects) execenv.print( f"Object store now contains {self.object_store.count} object(s) " f"({len(self.object_store.signals)} signals, " f"{len(self.object_store.images)} images)" ) # ------------------------------------------------------------------ # Workspace serialization (override) # ------------------------------------------------------------------ def save_h5_workspace(self, filename: str) -> None: """Save workspace to HDF5 using :class:`guidata.io.HDF5Writer`. Overrides the base no-op to actually serialize all objects held in :attr:`object_store`. Args: filename: HDF5 filename to save to """ filename = self._check_h5file(filename, "save") with HDF5Writer(filename) as writer: writer.h5.attrs[_VERSION_ATTR] = Conf.app_version.get() self.object_store.serialize(writer) self.set_modified(False) execenv.print( f"Workspace saved to '{filename}' ({self.object_store.count} object(s))" ) def load_h5_workspace(self, filename: str) -> None: """Load workspace from an HDF5 file previously saved by this app. Args: filename: HDF5 filename to load from """ filename = self._check_h5file(filename, "load") with HDF5Reader(filename) as reader: self.object_store.deserialize(reader) self.set_modified(False) execenv.print( f"Workspace loaded from '{filename}' ({self.object_store.count} object(s))" ) def import_dataset_from_file( self, filename: str, dsetname: str | None, import_all: bool | None, reset_all: bool, ) -> None: """Import a specific dataset from a generic HDF5 file. Reads a raw HDF5 dataset by name and wraps it as a :class:`SignalObj` (1-D) or :class:`ImageObj` (2-D). Args: filename: Path to the HDF5 file (already validated) dsetname: Dataset name to import, or ``None`` to import all import_all: If ``True``, import all datasets without browsing reset_all: If ``True``, clear workspace before importing """ if reset_all: self.object_store.clear() objects: list[SignalObj | ImageObj] = [] with h5py.File(filename, "r") as h5: if dsetname is not None: names = [dsetname] else: names = [k for k, v in h5.items() if isinstance(v, h5py.Dataset)] for name in names: if name not in h5: execenv.print(f"Dataset '{name}' not found in '{filename}'") continue node = h5[name] if not isinstance(node, h5py.Dataset): continue data = node[()] if data.ndim == 1: obj = SignalObj() obj.set_xydata( np.arange(len(data), dtype=float), data.astype(float) ) obj.title = name objects.append(obj) elif data.ndim == 2: obj = ImageObj() obj.data = data obj.title = name objects.append(obj) if objects: self.SIG_SEND_OBJECTLIST.emit(objects) self.set_modified(True) execenv.print(f"Imported {len(objects)} dataset(s) from '{filename}'") # ============================================================================= # Test helpers # ============================================================================= def _create_test_signal(index: int) -> SignalObj: """Create a simple test signal.""" x = np.linspace(0, 10, 200) y = np.sin(x * (index + 1)) + 0.05 * np.random.randn(len(x)) obj = SignalObj() obj.set_xydata(x, y) obj.title = f"Test signal #{index}" return obj def _create_test_image(index: int) -> ImageObj: """Create a simple test image.""" data = np.random.randint(0, 255, (64, 64), dtype=np.uint8) obj = ImageObj() obj.data = data obj.title = f"Test image #{index}" return obj def _create_h5_with_datasets(path: str) -> None: """Create an HDF5 file with raw named datasets for dataset-import tests. Layout:: /sine (1-D float64, 200 points) /cosine (1-D float64, 200 points) /checkerboard (2-D uint8, 64×64) """ x = np.linspace(0, 2 * np.pi, 200) with h5py.File(path, "w") as h5: h5.create_dataset("sine", data=np.sin(x)) h5.create_dataset("cosine", data=np.cos(x)) h5.create_dataset( "checkerboard", data=np.indices((64, 64)).sum(axis=0).astype(np.uint8) % 2 * 255, ) # ============================================================================= # Tests # ============================================================================= @pytest.mark.app def test_base_window_cannot_acknowledge_workspace_save() -> None: """Test that base save keeps the workspace marked as modified.""" with qth.sigimax_app_context(exec_loop=False): win = SGMXMainWindow(console=False) win.set_modified(True) assert not win._is_save_enabled() # pylint: disable=protected-access with pytest.raises(NotImplementedError, match="save_h5_workspace"): win.save_h5_workspace("workspace.h5") assert win.is_modified() win.close() @pytest.mark.unit def test_object_store_serialize_roundtrip() -> None: """Test SimpleObjectStore serialize/deserialize without GUI.""" store = SimpleObjectStore() store.add_objects( [ _create_test_signal(0), _create_test_signal(1), _create_test_image(0), ] ) assert store.count == 3 assert len(store.signals) == 2 assert len(store.images) == 1 with tempfile.TemporaryDirectory() as tmpdir: path = osp.join(tmpdir, "roundtrip_test.h5") # Write with HDF5Writer(path) as writer: store.serialize(writer) # Read into a fresh store store2 = SimpleObjectStore() with HDF5Reader(path) as reader: store2.deserialize(reader) assert store2.count == 3 assert len(store2.signals) == 2 assert len(store2.images) == 1 # Verify data integrity np.testing.assert_array_almost_equal( store.signals[0].xydata, store2.signals[0].xydata ) np.testing.assert_array_almost_equal( store.signals[1].xydata, store2.signals[1].xydata ) np.testing.assert_array_equal(store.images[0].data, store2.images[0].data) # Verify titles assert store2.signals[0].title == "Test signal #0" assert store2.signals[1].title == "Test signal #1" assert store2.images[0].title == "Test image #0" execenv.print("Object store round-trip test passed.") @pytest.mark.app def test_derived_app_h5_workspace() -> None: """Test derived app: import → save → reload round-trip.""" with qth.sigimax_app_context(exec_loop=False): win = DerivedAppWindow(console=False) win.resize(1200, 700) win.show() # Populate the data model via the signal test_objects = [ _create_test_signal(0), _create_test_signal(1), _create_test_signal(2), _create_test_image(0), _create_test_image(1), ] win.SIG_SEND_OBJECTLIST.emit(test_objects) assert win.object_store.count == 5 assert len(win.object_store.signals) == 3 assert len(win.object_store.images) == 2 with tempfile.TemporaryDirectory() as tmpdir: path = osp.join(tmpdir, "workspace_test.h5") # Save via the overridden method (goes through save_to_h5_file flow) win.save_h5_workspace(path) assert osp.isfile(path) assert not win.is_modified() # Create a second window and load the workspace win2 = DerivedAppWindow(console=False) win2.resize(1200, 700) win2.show() assert win2.object_store.count == 0 win2.load_h5_workspace(path) assert win2.object_store.count == 5 assert len(win2.object_store.signals) == 3 assert len(win2.object_store.images) == 2 # Verify data np.testing.assert_array_almost_equal( win.object_store.signals[0].xydata, win2.object_store.signals[0].xydata, ) np.testing.assert_array_equal( win.object_store.images[0].data, win2.object_store.images[0].data, ) # Verify titles survived the round-trip for i in range(3): assert win2.object_store.signals[i].title == f"Test signal #{i}", ( f"Signal title mismatch at index {i}" ) for i in range(2): assert win2.object_store.images[i].title == f"Test image #{i}", ( f"Image title mismatch at index {i}" ) win2.set_modified(False) win2.close() win.set_modified(False) win.close() execenv.print("Derived app workspace round-trip test passed.") @pytest.mark.app def test_derived_app_import_and_save() -> None: """Test importing an HDF5 file and saving the workspace.""" fnames = helpers.get_test_fnames("*.h5") if not fnames: execenv.print("No test HDF5 files found, skipping test.") return fname = fnames[-1] with qth.sigimax_app_context(exec_loop=False): win = DerivedAppWindow(console=False) win.resize(1200, 700) win.show() # Import objects from a real HDF5 file execenv.print(f"Importing HDF5 file: {fname}") win.import_all_from_h5_file(fname) initial_count = win.object_store.count execenv.print(f"Imported {initial_count} object(s)") if initial_count > 0: with tempfile.TemporaryDirectory() as tmpdir: path = osp.join(tmpdir, "import_save_test.h5") win.save_h5_workspace(path) assert osp.isfile(path) # Reload and verify count matches win2 = DerivedAppWindow(console=False) win2.show() win2.load_h5_workspace(path) assert win2.object_store.count == initial_count win2.set_modified(False) win2.close() win.set_modified(False) win.close() execenv.print("Import-and-save test passed.") @pytest.mark.app def test_import_specific_dataset_and_save() -> None: """Test importing a specific dataset by name and save/load round-trip. Exercises :meth:`DerivedAppWindow.import_dataset_from_file` via the ``open_h5_files`` comma-separated syntax (``"file.h5,dataset_name"``). """ with tempfile.TemporaryDirectory() as tmpdir: # -- Create an HDF5 file with raw named datasets -- src_path = osp.join(tmpdir, "raw_datasets.h5") _create_h5_with_datasets(src_path) with qth.sigimax_app_context(exec_loop=False): win = DerivedAppWindow(console=False) win.resize(1200, 700) win.show() # -- 1. Import a single dataset by name -- win.open_h5_files( h5files=[f"{src_path},sine"], import_all=False, reset_all=False, ) assert win.object_store.count == 1, ( f"Expected 1 object, got {win.object_store.count}" ) assert len(win.object_store.signals) == 1 assert win.object_store.signals[0].title == "sine" # -- 2. Import another dataset (no reset) -- win.open_h5_files( h5files=[f"{src_path},checkerboard"], import_all=False, reset_all=False, ) assert win.object_store.count == 2 assert len(win.object_store.images) == 1 assert win.object_store.images[0].title == "checkerboard" # The historical ``filename,dataset`` contract rejects any extra # comma rather than silently changing how the selector is parsed. with pytest.raises(ValueError): win.open_h5_files( h5files=[f"{src_path},sine,extra"], import_all=False, reset_all=False, ) # -- 3. Import all datasets at once (with reset) -- win.open_h5_files( h5files=[src_path], import_all=True, reset_all=True, ) # import_all=True triggers import_dataset_from_file with dsetname=None assert win.object_store.count == 3, ( f"Expected 3 objects, got {win.object_store.count}" ) assert len(win.object_store.signals) == 2 # sine + cosine assert len(win.object_store.images) == 1 # checkerboard # -- 4. Save workspace and reload -- ws_path = osp.join(tmpdir, "workspace_specific.h5") win.save_h5_workspace(ws_path) assert osp.isfile(ws_path) win2 = DerivedAppWindow(console=False) win2.resize(1200, 700) win2.show() win2.load_h5_workspace(ws_path) assert win2.object_store.count == 3 assert len(win2.object_store.signals) == 2 assert len(win2.object_store.images) == 1 # Verify data integrity for the sine signal np.testing.assert_array_almost_equal( win.object_store.signals[0].y, win2.object_store.signals[0].y, ) # Verify image data integrity np.testing.assert_array_equal( win.object_store.images[0].data, win2.object_store.images[0].data, ) win2.set_modified(False) win2.close() win.set_modified(False) win.close() execenv.print("Import-specific-dataset and save/load test passed.") def show_derivated_app() -> None: """Show the derived application window.""" with qth.sigimax_app_context(exec_loop=True): win = DerivedAppWindow(console=False) win.show() if __name__ == "__main__": test_object_store_serialize_roundtrip() test_derived_app_h5_workspace() test_derived_app_import_and_save() test_import_specific_dataset_and_save() # show_derivated_app() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/test_h5_utils.py000066400000000000000000000263401524425144300252620ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for HDF5 utility modules ------------------------------- Covers: - h5/common.py: data_to_xy with various shaped arrays - h5/generic.py: safe_decode_bytes, format_text_data - h5/utils.py: fix_ldata, fix_ndata, is_single_str_array, is_supported_num_dtype, is_supported_str_dtype, process_scalar_value, process_label, process_xy_values """ from __future__ import annotations import h5py import numpy as np import pytest from sigimax.h5.common import data_to_xy from sigimax.h5.generic import format_text_data, safe_decode_bytes from sigimax.h5.utils import ( fix_ldata, fix_ndata, is_single_str_array, is_supported_num_dtype, is_supported_str_dtype, process_label, process_scalar_value, process_xy_values, ) pytestmark = pytest.mark.unit # ======================== data_to_xy ========================================= class TestDataToXy: """Tests for data_to_xy conversion.""" def test_1d_array(self): """1D array should be treated as y values with x as indices.""" data = np.array([10, 20, 30]) x, y, dx, dy = data_to_xy(data) np.testing.assert_array_equal(x, np.arange(3)) np.testing.assert_array_equal(y, data) assert dx is None assert dy is None def test_2col_array(self): """2D array with 2 columns should be treated as x and y.""" data = np.array([[1, 4], [2, 5], [3, 6]]) x, y, dx, dy = data_to_xy(data) np.testing.assert_array_equal(x, [1, 2, 3]) np.testing.assert_array_equal(y, [4, 5, 6]) assert dx is None assert dy is None def test_3col_array(self): """3D array with 3 columns should be treated as x, y, and dy.""" # rows > cols triggers transpose: 5×3 → 3×5 data = np.array( [[1, 4, 0.1], [2, 5, 0.2], [3, 6, 0.3], [4, 7, 0.4], [5, 8, 0.5]] ) x, y, dx, dy = data_to_xy(data) np.testing.assert_array_equal(x, [1, 2, 3, 4, 5]) np.testing.assert_array_equal(y, [4, 5, 6, 7, 8]) assert dx is None np.testing.assert_array_almost_equal(dy, [0.1, 0.2, 0.3, 0.4, 0.5]) def test_4col_array(self): """4D array with 4 columns should be treated as x, y, dx, and dy.""" # rows > cols triggers transpose: 5×4 → 4×5 data = np.array( [ [1, 4, 0.1, 0.4], [2, 5, 0.2, 0.5], [3, 6, 0.3, 0.6], [4, 7, 0.4, 0.7], [5, 8, 0.5, 0.8], ] ) x, y, dx, dy = data_to_xy(data) np.testing.assert_array_equal(x, [1, 2, 3, 4, 5]) np.testing.assert_array_equal(y, [4, 5, 6, 7, 8]) np.testing.assert_array_almost_equal(dx, [0.1, 0.2, 0.3, 0.4, 0.5]) np.testing.assert_array_almost_equal(dy, [0.4, 0.5, 0.6, 0.7, 0.8]) def test_2row_array_transposed(self): """2 rows × many cols should be transposed.""" data = np.array([[1, 2, 3, 4, 5], [10, 20, 30, 40, 50]]) x, y, _dx, _dy = data_to_xy(data) np.testing.assert_array_equal(x, [1, 2, 3, 4, 5]) np.testing.assert_array_equal(y, [10, 20, 30, 40, 50]) def test_invalid_shape_raises(self): """Arrays with unsupported shapes should raise an error.""" data = np.ones((5, 5, 5)) with pytest.raises((ValueError, IndexError)): data_to_xy(data) # ======================== safe_decode_bytes ================================== class TestSafeDecodeBytes: """Tests for safe_decode_bytes.""" def test_str_passthrough(self): """String input should be returned unchanged.""" assert safe_decode_bytes("hello") == "hello" def test_bytes_utf8(self): """UTF-8 encoded bytes should be decoded to a string.""" assert safe_decode_bytes(b"hello") == "hello" def test_bytes_latin1(self): """Bytes that are not valid UTF-8 should be decoded with latin1 fallback.""" result = safe_decode_bytes("café".encode("latin1")) assert "caf" in result def test_none_returns_str(self): """None input should be converted to an empty string.""" result = safe_decode_bytes(None) assert isinstance(result, str) def test_int_returns_str(self): """Non-string, non-bytes input should be converted to string.""" result = safe_decode_bytes(42) assert result == "42" # ======================== format_text_data =================================== class TestFormatTextData: """Tests for format_text_data.""" def test_none_returns_unreadable(self): """None input should return a string indicating the data is unreadable.""" result = format_text_data(None) assert "unreadable" in result.lower() def test_string_passthrough(self): """String input should be returned unchanged.""" result = format_text_data("some text") assert "some text" in result def test_numeric(self): """Numeric input should be converted to string.""" result = format_text_data(42) assert "42" in result # ======================== fix_ldata / fix_ndata ============================== class TestFixFunctions: """Tests for fix_ldata and fix_ndata.""" def test_fix_ldata_string(self): """String input should be returned unchanged.""" assert fix_ldata("hello") == "hello" def test_fix_ldata_bytes(self): """Bytes input should be decoded to a string.""" result = fix_ldata(np.bytes_(b"test")) assert result == "test" def test_fix_ldata_none(self): """None input should be converted to an empty string.""" assert fix_ldata(None) == "" def test_fix_ndata_int(self): """Integer input should be returned unchanged.""" assert fix_ndata(5) == 5 def test_fix_ndata_float(self): """Float input should be returned unchanged.""" assert fix_ndata(3.14) == 3.14 def test_fix_ndata_none(self): """None input should be returned unchanged.""" assert fix_ndata(None) is None def test_fix_ndata_string(self): """String input should be converted to None.""" assert fix_ndata("not a number") is None # ======================== dtype checks ======================================= class TestDtypeChecks: """Tests for is_supported_num_dtype and is_supported_str_dtype.""" def test_int_dtype(self): """Integer dtype should be supported.""" data = np.array([1, 2, 3], dtype=np.int32) assert is_supported_num_dtype(data) is True def test_float_dtype(self): """Float dtype should be supported.""" data = np.array([1.0, 2.0], dtype=np.float64) assert is_supported_num_dtype(data) is True def test_complex_dtype(self): """Complex dtype should be supported.""" data = np.array([1 + 2j], dtype=np.complex128) assert is_supported_num_dtype(data) is True def test_bool_dtype_not_num(self): """Boolean dtype should not be considered a supported numeric dtype.""" data = np.array([True, False]) assert is_supported_num_dtype(data) is False def test_uint_dtype(self): """Unsigned integer dtype should be supported.""" data = np.array([1, 2], dtype=np.uint16) assert is_supported_num_dtype(data) is True def test_is_single_str_array_false_for_generic_scalar(self): """An ``ndarray`` (not a numpy generic) is rejected.""" scalar = np.array(["x"], dtype=str)[0:1] # ndarray, not generic assert is_single_str_array(scalar) is False def test_is_single_str_array_false_for_ndarray(self): """A multi-element ndarray of strings is not a single string array.""" arr = np.array(["a", "b"]) assert is_single_str_array(arr) is False def test_supported_str_dtype_false_for_bytes_array(self): """NumPy bytes-dtype arrays are not classified as string-supported.""" arr = np.array([b"x", b"y"], dtype="S2") # numpy bytes dtype name starts with "bytes" not "string" -> expected False assert is_supported_str_dtype(arr) is False def test_supported_str_dtype_false_for_int(self): """Numeric arrays are not string-supported.""" assert is_supported_str_dtype(np.zeros(3, dtype=np.int32)) is False # ======================== process_scalar_value / process_label / process_xy == @pytest.fixture(name="h5_with_datasets") def _h5_with_datasets(tmp_path): """Build a small in-memory HDF5 file containing typical layouts.""" path = tmp_path / "fixture.h5" with h5py.File(path, "w") as f: # Scalar value as a 1-element array (the common LMJ layout) f.create_dataset("scalar", data=np.array([42.5])) # Label as a 2-element string list f.create_dataset("label2", data=np.array([b"X-Axis", b"Y-Axis"], dtype="S20")) # Label as a 3-element string list f.create_dataset("label3", data=np.array([b"X", b"Y", b"Z"], dtype="S20")) # x/y pair f.create_dataset("xy", data=np.array([1.5, 2.5])) yield path class TestProcessScalarValue: """Tests for process_scalar_value.""" def test_returns_callback_result(self, h5_with_datasets): """The callback is applied to the dataset's first element.""" with h5py.File(h5_with_datasets, "r") as f: result = process_scalar_value(f, "scalar", float) assert result == pytest.approx(42.5) def test_missing_dataset_returns_none(self, h5_with_datasets): """A missing dataset path yields None.""" with h5py.File(h5_with_datasets, "r") as f: result = process_scalar_value(f, "missing", float) assert result is None class TestProcessLabel: """Tests for process_label.""" def test_two_element_label(self, h5_with_datasets): """A two-element label dataset fills (x, y, "").""" with h5py.File(h5_with_datasets, "r") as f: xl, yl, zl = process_label(f, "label2") assert xl == "X-Axis" assert yl == "Y-Axis" assert zl == "" def test_three_element_label(self, h5_with_datasets): """A three-element label dataset fills (x, y, z).""" with h5py.File(h5_with_datasets, "r") as f: xl, yl, zl = process_label(f, "label3") assert (xl, yl, zl) == ("X", "Y", "Z") def test_missing_returns_empty_strings(self, h5_with_datasets): """A missing label dataset returns three empty strings.""" with h5py.File(h5_with_datasets, "r") as f: result = process_label(f, "missing") assert result == ("", "", "") class TestProcessXyValues: """Tests for process_xy_values.""" def test_returns_pair(self, h5_with_datasets): """A two-element dataset is returned as a (x, y) pair.""" with h5py.File(h5_with_datasets, "r") as f: x, y = process_xy_values(f, "xy") assert x == pytest.approx(1.5) assert y == pytest.approx(2.5) def test_missing_returns_none_pair(self, h5_with_datasets): """A missing dataset returns (None, None).""" with h5py.File(h5_with_datasets, "r") as f: x, y = process_xy_values(f, "missing") assert x is None assert y is None if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/test_h5browser_all_files.py000066400000000000000000000017021524425144300274530ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ HDF5 browser unit tests 1 ------------------------- Try and open all HDF5 test data available. """ # guitest: show from __future__ import annotations import pytest from guidata.qthelpers import exec_dialog, qt_app_context from sigimax.tests import helpers from sigimax.tests.hdf5.test_h5browser_app import create_h5browser_dialog pytestmark = pytest.mark.gui def test_h5browser_all_files(pattern=None): """HDF5 browser unit test for all available .h5 test files""" with qt_app_context(): fnames = helpers.get_test_fnames("*.h5" if pattern is None else pattern) for index, fname in enumerate(fnames): dlg = create_h5browser_dialog([fname], toggle_all=True, select_all=True) dlg.setObjectName(dlg.objectName() + f"_{index:02d}") exec_dialog(dlg) if __name__ == "__main__": test_h5browser_all_files() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/test_h5browser_app.py000066400000000000000000000042031524425144300263000ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ HDF5 Browser Application test ----------------------------- """ # guitest: show from __future__ import annotations import pytest from guidata.qthelpers import exec_dialog, qt_app_context from qtpy import QtWidgets as QW from sigimax.env import execenv from sigimax.tests import helpers from sigimax.widgets.h5browser import H5BrowserDialog pytestmark = pytest.mark.gui def create_h5browser_dialog( fnames: list[str], toggle_all: bool = False, select_all: bool = False ) -> H5BrowserDialog: """Create HDF5 browser dialog with all nodes expanded and selected Args: fnames: HDF5 file names Returns: H5BrowserDialog instance """ execenv.print(f"Opening: {fnames}") dlg = H5BrowserDialog(None) dlg.open_files(fnames) dlg.browser.tree.toggle_all(toggle_all) dlg.browser.tree.select_all(select_all) return dlg def test_h5browser() -> None: """Test HDF5 browser""" fnames = helpers.get_test_fnames("*.h5")[-2:] with qt_app_context(): dlg = create_h5browser_dialog(fnames) if execenv.unattended: # Test all buttons: dlg.show() for index in range(dlg.button_layout.count()): widget = dlg.button_layout.itemAt(index).widget() if isinstance(widget, QW.QCheckBox): widget.setChecked(True) widget.setChecked(False) elif isinstance(widget, QW.QPushButton): widget.click() # Test various features: tree = dlg.browser.tree tree.update_menu() tree.expandAll() tree.collapseAll() tree.restore() # Removing file, adding file from browser: dlg.browser.close_file(fnames[0]) dlg.browser.open_file(fnames[0]) # Removing file, adding file from file selector: dlg.browser.selector.remove_file(fnames[0]) dlg.browser.selector.add_file(fnames[0]) exec_dialog(dlg) if __name__ == "__main__": test_h5browser() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/hdf5/test_h5import.py000066400000000000000000000011321524425144300252650ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ HDF5 import application test """ # guitest: show import pytest from sigimax.env import execenv from sigimax.tests import helpers, sigimax_test_app_context pytestmark = pytest.mark.app def test_hdf5_import(): """Testing SigimaX app launcher""" with sigimax_test_app_context(console=False) as win: fname = helpers.get_test_fnames("*.h5")[-1] execenv.print(f"Importing HDF5 file: {fname}") win.import_all_from_h5_file(fname) if __name__ == "__main__": test_hdf5_import() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/000077500000000000000000000000001524425144300234365ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/__init__.py000066400000000000000000000000021524425144300255370ustar00rootroot00000000000000# DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_app_create.py000066400000000000000000000044411524425144300271550ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for the application launcher (:mod:`sigimax.app`) ------------------------------------------------------- Covers: - create() with default args → returns SGMXMainWindow - create() with console=True → window has console - create() with custom size → window geometry matches - create() with custom window_class → returns correct subclass """ from __future__ import annotations import pytest from sigimax.app import create as sigimax_create from sigimax.mainwindow import SGMXMainWindow from sigimax.utils.qthelpers import sigimax_app_context pytestmark = pytest.mark.app class _TestSubWindow(SGMXMainWindow): """Minimal subclass for testing window_class parameter.""" CUSTOM_MARKER = True def __init__(self, console=None, hide_on_close=False): super().__init__(console=console, hide_on_close=hide_on_close) def test_create_default(): """create() with default args returns a SGMXMainWindow instance.""" with sigimax_app_context(exec_loop=False): win = sigimax_create(splash=False) assert isinstance(win, SGMXMainWindow) win.close() def test_create_with_console(): """create() with console=True → window has an embedded console.""" with sigimax_app_context(exec_loop=False): win = sigimax_create(splash=False, console=True) assert isinstance(win, SGMXMainWindow) # Console dock should exist assert win.docks is not None win.close() def test_create_custom_size(): """create() with custom size → window should be resized.""" width, height = 800, 500 with sigimax_app_context(exec_loop=False): win = sigimax_create(splash=False, size=(width, height)) assert win.width() == width assert win.height() == height win.close() def test_create_custom_window_class(): """create() with a custom window_class returns an instance of that class.""" with sigimax_app_context(exec_loop=False): win = sigimax_create(window_class=_TestSubWindow, splash=False) assert isinstance(win, _TestSubWindow) assert hasattr(win, "CUSTOM_MARKER") assert win.CUSTOM_MARKER is True win.close() if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_console_hooks.py000066400000000000000000000131051524425144300277140ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Console hooks functional test ------------------------------- Verify that: 1. The base :class:`SGMXMainWindow` creates a working console with a generic namespace (``win``, ``np``, etc.) and a generic welcome message. 2. A derived application can override :meth:`_get_console_namespace` and :meth:`_get_console_message` to inject custom variables and a custom welcome message. """ # guitest: show from __future__ import annotations import numpy as np import pytest from sigimax.config import CONF as Conf from sigimax.mainwindow import SGMXMainWindow from sigimax.utils import qthelpers as qth pytestmark = pytest.mark.app # ============================================================================= # Derived window with custom console namespace # ============================================================================= class CustomConsoleWindow(SGMXMainWindow): """Derived window that adds domain-specific variables to the console.""" CUSTOM_DATA = np.array([1, 2, 3, 4, 5]) def __init__(self) -> None: Conf.app_name.set("ConsoleHookTest") Conf.app_version.set("0.0.1") super().__init__(console=True) def _get_console_namespace(self) -> dict[str, object]: """Add custom variables alongside the defaults.""" ns = super()._get_console_namespace() ns["app"] = self # alias ns["data"] = self.CUSTOM_DATA ns["magic"] = 42 return ns def _get_console_message(self) -> str: """Provide a domain-specific welcome message.""" return ( "Welcome to ConsoleHookTest console!\n" "Custom variables: app, data, magic\n" "Example:\n" " data # sample array\n" " magic # the answer\n" " app == win # True — both reference the main window" ) # ============================================================================= # Tests # ============================================================================= def test_console_base(): """Verify that the base SGMXMainWindow console has the expected namespace and welcome message.""" with qth.sigimax_app_context(exec_loop=False): Conf.app_name.set("BaseConsoleTest") Conf.app_version.set("0.0.1") win = SGMXMainWindow(console=True) win.resize(800, 500) win.show() # Console must have been created assert win.console is not None, "Console was not created" # -- Check namespace contents ---------------------------------------- ns = win.console.interpreter.locals assert "win" in ns, f"'win' missing from namespace: {list(ns)}" assert ns["win"] is win, "'win' should reference the main window" assert "np" in ns, f"'np' missing from namespace: {list(ns)}" assert ns["np"] is np, "'np' should be numpy" expected_keys = {"win", "np", "sps", "spi", "os", "sys", "osp", "time"} assert expected_keys.issubset(ns.keys()), ( f"Missing keys: {expected_keys - ns.keys()}" ) # DataLab-specific names must NOT be present assert "dl" not in ns, "'dl' should not be in base namespace" # -- Check welcome message ------------------------------------------- msg = win._get_console_message() # pylint: disable=protected-access assert "win" in msg, "Welcome message should mention 'win'" assert Conf.app_name.get() in msg, "Welcome message should contain the app name" # -- Clean close ----------------------------------------------------- win.set_modified(False) win.close() print("Base console test passed.") def test_console_derived(): """Verify that a derived window can inject custom variables and message into the console.""" with qth.sigimax_app_context(exec_loop=False): win = CustomConsoleWindow() win.resize(800, 500) win.show() assert win.console is not None, "Console was not created" # -- Check that custom variables are present in namespace ------------- ns = win.console.interpreter.locals assert "app" in ns, f"'app' missing from namespace: {list(ns)}" assert ns["app"] is win, "'app' should reference the main window" assert "data" in ns, f"'data' missing from namespace: {list(ns)}" assert (ns["data"] == CustomConsoleWindow.CUSTOM_DATA).all(), ( "'data' should be the custom array" ) assert "magic" in ns, f"'magic' missing from namespace: {list(ns)}" assert ns["magic"] == 42, "'magic' should be 42" # -- Default variables should still be present (via super()) ---------- assert "win" in ns, "'win' should still be present" assert ns["win"] is win, "'win' should reference the main window" assert "np" in ns, "'np' should still be present" # -- Check custom welcome message ------------------------------------- msg = win._get_console_message() # pylint: disable=protected-access assert "ConsoleHookTest" in msg, ( "Custom welcome message should mention the app name" ) assert "magic" in msg, "Custom welcome message should mention 'magic'" assert "data" in msg, "Custom welcome message should mention 'data'" # -- Clean close ----------------------------------------------------- win.set_modified(False) win.close() print("Derived console test passed.") if __name__ == "__main__": test_console_base() test_console_derived() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_derived_app.py000066400000000000000000000332671524425144300273440ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Derived application example test --------------------------------- This test demonstrates how to build a custom application on top of SigimaX by: 1. Subclassing :class:`SigimaXOptions` to add application-specific options. 2. Subclassing :class:`SGMXMainWindow` to customize the main window (menus, toolbars, dockable widgets, etc.). The resulting "MyApp" application showcases the full derivation pattern that downstream projects (like DataLab) can follow. """ # guitest: show from __future__ import annotations import numpy as np import pytest from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action from plotpy.constants import PlotType from qtpy import QtCore as QC from qtpy import QtWidgets as QW from sigimax.app import create as sigimax_create from sigimax.config import CONF as Conf from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField, _ from sigimax.mainwindow import SGMXMainWindow from sigimax.utils import qthelpers as qth from sigimax.widgets.plotdock import DockablePlotWidget from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig # ============================================================================= # 1. Derived configuration: MyAppOptions # ============================================================================= class MyAppOptions(SigimaXOptions): """Custom options for the MyApp application. Extends :class:`SigimaXOptions` with application-specific settings such as a greeting message, max recent files, and a default unit system. """ APP_NAME = "MyApp" CONF_VERSION = "1.0.0" def __init__(self) -> None: super().__init__() # Override default application metadata self.app_name.set("MyApp") self.app_version.set("0.1.0") self.app_desc.set("A demo application built on SigimaX") # --- Application-specific options --- self.greeting_message = TypedOptionField( self, "greeting_message", default="Welcome to MyApp!", expected_type=str, description="Message displayed in the status bar on startup.", ) self.max_recent_files = TypedOptionField( self, "max_recent_files", default=10, expected_type=int, description="Maximum number of recent files to remember.", ) self.default_unit_system = EnumOptionField( self, "default_unit_system", default="metric", choices=["metric", "imperial"], description="Default unit system for display.", ) self.auto_compute_on_load = TypedOptionField( self, "auto_compute_on_load", default=False, expected_type=bool, description=( "If True, automatically run default computations " "when loading a dataset." ), ) # Recapture defaults after adding custom options self._defaults.update( { name: getattr(self, name).get() for name in ( "greeting_message", "max_recent_files", "default_unit_system", "auto_compute_on_load", ) } ) # ============================================================================= # 2. Derived main window: MyAppMainWindow # ============================================================================= class MyAppMainWindow(SGMXMainWindow): """Custom main window for the MyApp application. Extends :class:`SGMXMainWindow` with: - A custom "Tools" menu with domain-specific actions. - A dockable curve plot widget. - A demo action that generates a sine wave and displays it. The pattern for derived windows is: 1. Configure the global ``Conf`` options (app_name, app_version, etc.) **before** calling ``super().__init__()``, because :class:`SGMXMainWindow` reads from the module-level ``Conf`` reference. 2. Add custom UI elements by overriding the ``setup()`` hooks, so that docks exist before the persisted window state is restored. """ def __init__( self, console: bool | None = None, hide_on_close: bool = False, ) -> None: # Configure global Conf BEFORE calling super().__init__() so that # SGMXMainWindow reads the correct app_name, app_version, etc. Conf.app_name.set("MyApp") Conf.app_version.set("0.1.0") Conf.app_desc.set("A demo application built on SigimaX") # --- Custom widgets --- self.curve_dock: DockablePlotWidget | None = None super().__init__(console=console, hide_on_close=hide_on_close) # ------------------------------------------------------------------ # Custom UI setup # ------------------------------------------------------------------ def _setup_docks(self) -> None: """Add MyApp-specific dock widgets.""" self._add_curve_dock() def _post_setup(self, console: bool) -> None: """Add MyApp-specific menus and toolbars.""" self._add_tools_menu() self._add_custom_toolbar() def _add_curve_dock(self) -> None: """Add a dockable curve plot widget to the main window.""" self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) self._add_dockwidget(self.curve_dock, _("Curve Viewer"), name="curve_viewer") def _add_tools_menu(self) -> None: """Add a custom 'Tools' menu to the menu bar.""" tools_menu = self.menuBar().addMenu(_("&Tools")) generate_action = create_action( self, _("Generate sine wave"), icon=get_icon("new_signal.svg"), tip=_("Generate a sample sine wave and display it"), triggered=self._generate_sine_wave, ) clear_action = create_action( self, _("Clear plot"), icon=get_icon("libre-gui-close.svg"), tip=_("Remove all curves from the plot"), triggered=self._clear_plot, ) show_options_action = create_action( self, _("Show configuration"), tip=_("Print all current configuration options to the console"), triggered=self._show_configuration, ) add_actions( tools_menu, [generate_action, clear_action, None, show_options_action] ) def _add_custom_toolbar(self) -> None: """Add a custom toolbar with quick-access actions.""" toolbar = QW.QToolBar(_("MyApp Tools"), self) toolbar.setObjectName("myapp_tools_toolbar") self.addToolBar(QC.Qt.TopToolBarArea, toolbar) generate_action = create_action( self, _("Sine"), icon=get_icon("new_signal.svg"), tip=_("Generate a sine wave"), triggered=self._generate_sine_wave, ) toolbar.addAction(generate_action) # ------------------------------------------------------------------ # Custom actions # ------------------------------------------------------------------ def _generate_sine_wave(self) -> None: """Generate a sine wave and display it in the curve dock.""" if self.curve_dock is None: return x = np.linspace(0, 4 * np.pi, 500) y = np.sin(x) + 0.1 * np.random.randn(len(x)) plot = self.curve_dock.get_plot() from plotpy.builder import make # pylint: disable=import-outside-toplevel curve = make.curve(x, y, title="sin(x) + noise", color="blue") plot.add_item(curve) plot.do_autoscale() self.statusBar().showMessage( _("Generated sine wave with %d points") % len(x), 3000 ) def _clear_plot(self) -> None: """Remove all items from the curve dock plot.""" if self.curve_dock is None: return plot = self.curve_dock.get_plot() plot.del_all_items() plot.replot() self.statusBar().showMessage(_("Plot cleared"), 2000) def _show_configuration(self) -> None: """Print all configuration options to stdout (and console if available).""" print("\n--- MyApp Configuration ---") Conf.describe_all() print("---\n") # ============================================================================= # 3. Test function # ============================================================================= @pytest.mark.unit def test_derived_app(): """Test that a derived application can be built on top of SigimaX.""" # -- Verify custom options work -- conf = MyAppOptions() assert conf.app_name.get() == "MyApp" assert conf.app_version.get() == "0.1.0" assert conf.greeting_message.get() == "Welcome to MyApp!" assert conf.max_recent_files.get() == 10 assert conf.default_unit_system.get() == "metric" assert conf.auto_compute_on_load.get() is False # Test option modification conf.greeting_message.set("Hello, World!") assert conf.greeting_message.get() == "Hello, World!" # Test context manager override with conf.max_recent_files.context(5): assert conf.max_recent_files.get() == 5 assert conf.max_recent_files.get() == 10 # Test reset to defaults conf.greeting_message.set("Changed") conf.reset_to_defaults() assert conf.greeting_message.get() == "Welcome to MyApp!" # Test serialization round-trip d = conf.to_dict() assert "greeting_message" in d assert "max_recent_files" in d assert d["default_unit_system"] == "metric" conf2 = MyAppOptions() conf2.from_dict(d) assert conf2.greeting_message.get() == conf.greeting_message.get() assert conf2.max_recent_files.get() == conf.max_recent_files.get() # Test enum validation try: conf.default_unit_system.set("invalid_unit") assert False, "Should have raised ValueError" except ValueError: pass # Expected # Test list_options includes custom options option_names = conf.list_options() assert "greeting_message" in option_names assert "max_recent_files" in option_names assert "default_unit_system" in option_names assert "auto_compute_on_load" in option_names # Also includes inherited SigimaX options assert "color_mode" in option_names assert "console_enabled" in option_names print("All custom option tests passed.") @pytest.mark.app def test_splash_screen(): """Test that the splash screen can be created and shown.""" with qth.sigimax_app_context(exec_loop=False): # Test 1: Splash screen from explicit config (fallback pixmap, no image) config = SplashScreenConfig( app_name="MyApp", app_version="0.1.0", tagline="A demo application", show_progress=True, ) assert not config.is_enabled # No image_path => disabled # Test 2: Splash screen with a non-existent image (fallback) config_with_path = SplashScreenConfig( image_path="nonexistent.png", app_name="MyApp", app_version="0.1.0", ) assert config_with_path.is_enabled splash = SigimaXSplashScreen(config_with_path) splash.show() splash.show_message("Loading test...") splash.close() # Test 3: from_conf returns None when no splash image is configured splash_from_conf = SigimaXSplashScreen.from_conf() assert splash_from_conf is None # Default config has no splash image # Test 4: create() launcher works without splash win = sigimax_create( window_class=MyAppMainWindow, splash=False, console=False, size=(800, 600), ) assert win is not None win.set_modified(False) win.close() print("Splash screen tests passed.") @pytest.mark.app def test_derived_app_window(): # pylint: disable=protected-access # pylint: disable=redefined-outer-name """Test that the derived main window creates and runs properly.""" with qth.sigimax_app_context(exec_loop=False): win = MyAppMainWindow(console=False) win.resize(1200, 700) win.show() # Verify window title contains our app name assert "MyApp" in win.windowTitle() # Verify the curve dock was created assert win.curve_dock is not None # Test generating a sine wave win._generate_sine_wave() plot = win.curve_dock.get_plot() items_before_generate = len(plot.get_items()) assert items_before_generate > 0 # Test clearing the plot — note that the plot may keep internal # items (e.g., tool markers), so we just check the count decreased initial_count = len(plot.get_items()) win._generate_sine_wave() # add another curve assert len(plot.get_items()) > initial_count win._clear_plot() # Test show configuration (just ensure it doesn't crash) win._show_configuration() # Clean close win.set_modified(False) win.close() print("Derived main window test passed.") if __name__ == "__main__": from sigimax.app import run as sigimax_run # Launch with splash screen (fallback pixmap since no image is provided) splash_config = SplashScreenConfig( image_path="nonexistent_demo.png", # Will use fallback pixmap app_name="MyApp", app_version="0.1.0", tagline="A demo application built on SigimaX", ) sigimax_run( window_class=MyAppMainWindow, splash_config=splash_config, console=True, size=(1200, 700), ) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_lifecycle_hooks.py000066400000000000000000000117031524425144300302130ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Main-window lifecycle hook tests.""" from __future__ import annotations import pytest from qtpy import QtWidgets as QW from sigimax.config import CONF as Conf from sigimax.env import execenv from sigimax.mainwindow import SGMXMainWindow from sigimax.utils import qthelpers as qth class HookWindow(SGMXMainWindow): """Window recording the protected setup and persistence hooks.""" def __init__(self) -> None: self.hook_calls: list[str] = [] super().__init__(console=False) def _update_color_mode(self, startup: bool = False) -> None: self.hook_calls.append("color") super()._update_color_mode(startup=startup) def _before_setup(self, console: bool) -> None: self.hook_calls.append("before") super()._before_setup(console) def _configure_statusbar(self, console: bool) -> None: self.hook_calls.append("statusbar") super()._configure_statusbar(console) def _setup_global_actions(self) -> None: self.hook_calls.append("actions") super()._setup_global_actions() def _setup_central_widget(self) -> None: self.hook_calls.append("central") super()._setup_central_widget() def _add_menus(self) -> None: self.hook_calls.append("menus") super()._add_menus() def _restore_state(self) -> None: self.hook_calls.append("state") super()._restore_state() def _restore_pos_and_size(self) -> None: self.hook_calls.append("geometry") super()._restore_pos_and_size() def _after_setup(self, console: bool) -> None: self.hook_calls.append("after") super()._after_setup(console) def _save_pos_size_and_state(self) -> None: self.hook_calls.append("save") super()._save_pos_size_and_state() def _close_managed_widgets(self) -> None: self.hook_calls.append("close_widgets") super()._close_managed_widgets() def _cleanup_before_reset(self) -> None: self.hook_calls.append("before_reset") super()._cleanup_before_reset() def _cleanup_after_state_save(self) -> None: self.hook_calls.append("after_save") super()._cleanup_after_state_save() class PreparedColorWindow(SGMXMainWindow): """Window whose color hook requires the documented setup preparation.""" def __init__(self) -> None: self.color_ready = False super().__init__(console=False) def _before_setup(self, console: bool) -> None: self.color_ready = True super()._before_setup(console) def _update_color_mode(self, startup: bool = False) -> None: assert self.color_ready super()._update_color_mode(startup=startup) class DerivedInstanceWindow(SGMXMainWindow): """Derived class used to verify class-specific singleton construction.""" def test_lifecycle_hooks() -> None: """Protected lifecycle hooks are overridable and keep a stable call order.""" Conf.app_name.set("LifecycleHookTest") with qth.sigimax_app_context(exec_loop=False): window = HookWindow() assert window.hook_calls == [ "before", "color", "statusbar", "actions", "central", "menus", "state", "after", "geometry", ] window._save_pos_size_and_state() # pylint: disable=protected-access assert window.hook_calls[-1] == "save" assert window.close_properly() assert window.hook_calls[-4:] == [ "close_widgets", "before_reset", "save", "after_save", ] def test_before_setup_precedes_color_hook() -> None: """Test that derived setup runs before the color-mode hook.""" with qth.sigimax_app_context(exec_loop=False): window = PreparedColorWindow() window.close() def test_get_instance_preserves_derived_window_class() -> None: """Test that get_instance creates the class on which it is called.""" with qth.sigimax_app_context(exec_loop=False): base_window = SGMXMainWindow(console=False) derived_window = DerivedInstanceWindow.get_instance(console=False) assert isinstance(derived_window, DerivedInstanceWindow) base_window.close() derived_window.close() def test_failed_save_cancels_close(monkeypatch: pytest.MonkeyPatch) -> None: """Test that a failed Save choice preserves the modified workspace.""" with qth.sigimax_app_context(exec_loop=False): window = SGMXMainWindow(console=False) window.set_modified(True) monkeypatch.setattr(execenv, "unattended", False) monkeypatch.setattr(QW.QMessageBox, "warning", lambda *args: QW.QMessageBox.Yes) monkeypatch.setattr(window, "save_to_h5_file", lambda: None) assert not window.close_properly() assert window.is_modified() window.set_modified(False) window.close() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_local_doc_path.py000066400000000000000000000067711524425144300300150ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Local PDF doc path unit tests ------------------------------ Tests for the ``__get_local_doc_path`` static method on ``SGMXMainWindow``, which resolves a configurable path pattern to a locale-aware PDF file. """ from __future__ import annotations from unittest.mock import patch import pytest from qtpy import QtCore as QC from sigimax.config import CONF as Conf from sigimax.mainwindow import SGMXMainWindow pytestmark = pytest.mark.unit # Access the name-mangled static method without instantiating a window _get_local_doc_path = ( SGMXMainWindow._SGMXMainWindow__get_local_doc_path # pylint: disable=protected-access ) @pytest.fixture(autouse=True) def _reset_doc_path(): """Reset app_local_doc_path to empty after each test.""" yield Conf.app_local_doc_path.set("") @pytest.fixture() def pdf_files(tmp_path): # pylint: disable=redefined-outer-name """Create fake locale-aware PDF files and return the directory.""" (tmp_path / "MyApp_fr.pdf").write_text("fake", encoding="utf-8") (tmp_path / "MyApp_en.pdf").write_text("fake", encoding="utf-8") return tmp_path class TestLocalDocPath: """Tests for SGMXMainWindow.__get_local_doc_path.""" def test_empty_config_returns_none(self): """No path configured → None.""" Conf.app_local_doc_path.set("") assert _get_local_doc_path() is None def test_lang_placeholder_resolves_locale( self, pdf_files, # pylint: disable=redefined-outer-name ): """Pattern with {lang} resolves to the system locale file.""" pattern = str(pdf_files / "MyApp_{lang}.pdf") Conf.app_local_doc_path.set(pattern) with patch.object( QC.QLocale, "system", return_value=QC.QLocale(QC.QLocale.French) ): result = _get_local_doc_path() assert result is not None assert result.endswith("MyApp_fr.pdf") def test_lang_placeholder_falls_back_to_en( self, pdf_files, # pylint: disable=redefined-outer-name ): """Pattern with {lang} falls back to 'en' when locale file is missing.""" pattern = str(pdf_files / "MyApp_{lang}.pdf") Conf.app_local_doc_path.set(pattern) # Japanese locale → no MyApp_ja.pdf → should fall back to MyApp_en.pdf with patch.object( QC.QLocale, "system", return_value=QC.QLocale(QC.QLocale.Japanese) ): result = _get_local_doc_path() assert result is not None assert result.endswith("MyApp_en.pdf") def test_lang_placeholder_no_file_returns_none(self, tmp_path): """Pattern with {lang} but no matching file at all → None.""" pattern = str(tmp_path / "Missing_{lang}.pdf") Conf.app_local_doc_path.set(pattern) assert _get_local_doc_path() is None def test_direct_path_existing_file( self, pdf_files, # pylint: disable=redefined-outer-name ): """Pattern without {lang} pointing to an existing file → that path.""" path = str(pdf_files / "MyApp_en.pdf") Conf.app_local_doc_path.set(path) assert _get_local_doc_path() == path def test_direct_path_missing_file(self, tmp_path): """Pattern without {lang} pointing to a missing file → None.""" Conf.app_local_doc_path.set(str(tmp_path / "nonexistent.pdf")) assert _get_local_doc_path() is None if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_main_window.py000066400000000000000000000026721524425144300273710ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Application test for main window -------------------------------- Testing the features of the main window of the application that are not covered by other tests. """ # guitest: show import pytest from plotpy.constants import PlotType from sigimax.tests import sigimax_test_app_context from sigimax.widgets.h5browser import H5Browser from sigimax.widgets.plotdock import DockablePlotWidget @pytest.mark.app def test_main_app(): """Main window test""" with sigimax_test_app_context(console=True) as win: print("Main window test") win.activateWindow() # Add two DockablePlotWidget docks for title, plot_type in ( ("Curve Plot", PlotType.CURVE), ("Image Plot", PlotType.IMAGE), ): dock_widget = DockablePlotWidget(win, plot_type) dockwidget, location = dock_widget.create_dockwidget(title) win.addDockWidget(location, dockwidget) win.docks[dock_widget] = dockwidget # central_widget = SigimaXPlotWidget(plot_type=PlotType.CURVE) central_widget = H5Browser() win.setCentralWidget(central_widget) # win.removeToolBar(win.main_toolbar) # Remove the default toolbar # win.statusBar().hide() # Hide the status bar # win.menuBar().hide() # Hide the menu bar if __name__ == "__main__": test_main_app() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_menu_hooks.py000066400000000000000000000216701524425144300272240ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Menu hooks functional test -------------------------- Verify that a derived application can fully customize all menus (file, view, help) by overriding the ``_get_*_menu_actions()`` and ``_update_*_menu()`` hooks provided by :class:`SGMXMainWindow`. The test builds a minimal derived window that: - Prepends a "New project" action to the file menu. - Inserts a custom action between the H5 group and settings in the file menu. - Overrides ``_is_save_enabled`` to always return ``False``. - Appends a "Preferences" action to the view menu. - Inserts a "Release notes" action before "About..." in the help menu. - Adds a "Web API" separator + action after the default file menu via ``_update_file_menu`` override. """ # guitest: show from __future__ import annotations import pytest from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action from qtpy import QtWidgets as QW from sigimax.config import CONF as Conf from sigimax.config import _ from sigimax.mainwindow import SGMXMainWindow from sigimax.utils import qthelpers as qth pytestmark = pytest.mark.app # ============================================================================= # Derived window with custom menu hooks # ============================================================================= class CustomMenuWindow(SGMXMainWindow): """Derived window exercising every menu hook.""" def __init__(self) -> None: Conf.app_name.set("MenuHookTest") Conf.app_version.set("0.0.1") # Custom actions must be created BEFORE super().__init__() because # __add_menus() → _get_help_menu_actions() is called during setup(). # We can use QWidget.__init__ indirectly — create_action only needs a # QObject parent, and ``self`` is already a valid QObject at this point # thanks to Python's MRO (QMainWindow.__init__ hasn't run yet, but # the C++ QObject exists after type.__call__ allocates the instance). # However, since create_action may depend on the widget being fully # initialized, we initialize the attributes to None first and create # the actions in a dedicated method called before super().__init__(). self.new_project_action: QW.QAction | None = None self.import_csv_action: QW.QAction | None = None self.webapi_action: QW.QAction | None = None self.preferences_action: QW.QAction | None = None self.release_notes_action: QW.QAction | None = None super().__init__(console=False) # Now create actions (parent widget is fully initialized) self._create_custom_actions() # Rebuild help menu with our custom actions (it was built during # __add_menus with None placeholders) self.help_menu.clear() add_actions(self.help_menu, self._get_help_menu_actions()) def _create_custom_actions(self) -> None: """Create custom actions after the widget is fully initialized.""" self.new_project_action = create_action( self, _("New project"), icon=get_icon("libre-gui-add.svg"), tip=_("Create a new empty project"), ) self.import_csv_action = create_action( self, _("Import CSV..."), icon=get_icon("fileopen_signal.svg"), tip=_("Import data from a CSV file"), ) self.webapi_action = create_action( self, _("Web API status"), tip=_("Show Web API connection status"), ) self.preferences_action = create_action( self, _("Preferences..."), tip=_("Edit application preferences"), ) self.release_notes_action = create_action( self, _("Release notes"), tip=_("Show release notes"), ) # -- File menu hooks ------------------------------------------------------- def _is_save_enabled(self) -> bool: """Save is disabled when the workspace has no objects.""" return False # For testing: always disabled def _get_file_menu_actions(self) -> list[QW.QAction | None]: """Prepend 'New project' and insert 'Import CSV' after browse.""" return [ self.new_project_action, None, self.openh5_action, self.saveh5_action, self.browseh5_action, None, self.import_csv_action, ] def _update_file_menu(self) -> None: """Append Web API action after default population.""" super()._update_file_menu() self.file_menu.addSeparator() self.file_menu.addAction(self.webapi_action) # -- View menu hooks ------------------------------------------------------- def _get_view_menu_actions(self) -> list[QW.QAction | None]: """Append 'Preferences' at the end of the view menu.""" return super()._get_view_menu_actions() + [None, self.preferences_action] # -- Help menu hooks ------------------------------------------------------- def _get_help_menu_actions(self) -> list[QW.QAction | None]: """Insert 'Release notes' just before 'About...'.""" actions = super()._get_help_menu_actions() # During super().__init__(), custom actions are still None — skip if self.release_notes_action is None: return actions # Find the "About..." action (last one) and insert before it actions.insert(-1, None) actions.insert(-1, self.release_notes_action) return actions # ============================================================================= # Helpers # ============================================================================= def _get_action_texts(menu: QW.QMenu) -> list[str | None]: """Return action texts from a menu (None for separators).""" result: list[str | None] = [] for action in menu.actions(): if action.isSeparator(): result.append(None) else: result.append(action.text()) return result # ============================================================================= # Test # ============================================================================= def test_menu_hooks(): """Verify that menu hook overrides produce the expected menu layout.""" with qth.sigimax_app_context(exec_loop=False): win = CustomMenuWindow() win.resize(1000, 600) win.show() # -- Trigger file menu rebuild (simulates aboutToShow) ---------------- win._update_file_menu() # pylint: disable=protected-access file_texts = _get_action_texts(win.file_menu) # "New project" must be first real action (after leading separator) assert _("New project") in file_texts, f"Missing 'New project': {file_texts}" # "Import CSV..." must be present assert _("Import CSV...") in file_texts, f"Missing 'Import CSV': {file_texts}" # "Web API status" must be near the end (added by _update_file_menu) assert _("Web API status") in file_texts, ( f"Missing 'Web API status': {file_texts}" ) # "New project" before HDF5 actions idx_new = file_texts.index(_("New project")) idx_open = file_texts.index(_("Open HDF5 files...")) assert idx_new < idx_open, "New project should appear before Open HDF5" # "Import CSV..." between browse and settings idx_csv = file_texts.index(_("Import CSV...")) idx_browse = file_texts.index(_("Browse HDF5 file...")) assert idx_csv > idx_browse, "Import CSV should appear after Browse HDF5" # Save should be disabled assert not win.saveh5_action.isEnabled(), "Save should be disabled" # -- Trigger view menu rebuild ---------------------------------------- win._update_view_menu() # pylint: disable=protected-access view_texts = _get_action_texts(win.view_menu) assert _("Preferences...") in view_texts, f"Missing 'Preferences': {view_texts}" # Preferences should be last real action real_actions = [t for t in view_texts if t is not None] assert real_actions[-1] == _("Preferences...") # -- Verify help menu (built once at construction) -------------------- help_texts = _get_action_texts(win.help_menu) assert _("Release notes") in help_texts, ( f"Missing 'Release notes': {help_texts}" ) assert _("About...") in help_texts, f"Missing 'About': {help_texts}" # "Release notes" must appear before "About..." idx_rn = help_texts.index(_("Release notes")) idx_about = help_texts.index(_("About...")) assert idx_rn < idx_about, "Release notes should appear before About" # -- Clean close ------------------------------------------------------ win.set_modified(False) win.close() print("Menu hooks test passed.") if __name__ == "__main__": test_menu_hooks() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/mainwindow/test_toolbar_hooks.py000066400000000000000000000173331524425144300277230ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Toolbar hooks functional test ------------------------------ Verify that a derived application can fully customize the main toolbar by overriding ``_create_global_actions()`` and ``_get_main_toolbar_actions()`` hooks provided by :class:`SGMXMainWindow`. The test builds a minimal derived window that: - Adds a custom "Settings" action via ``_create_global_actions``. - Reorders toolbar actions and inserts a separator via ``_get_main_toolbar_actions``. - Verifies toolbar content matches the expected layout. - Verifies that default H5 actions are still present and functional. """ # guitest: show from __future__ import annotations import pytest from guidata.configtools import get_icon from guidata.qthelpers import create_action from qtpy import QtWidgets as QW from sigimax.config import CONF as Conf from sigimax.config import _ from sigimax.mainwindow import SGMXMainWindow from sigimax.utils import qthelpers as qth pytestmark = pytest.mark.app # ============================================================================= # Derived window with custom toolbar hooks # ============================================================================= class CustomToolbarWindow(SGMXMainWindow): """Derived window exercising toolbar action hooks.""" def __init__(self) -> None: Conf.app_name.set("ToolbarHookTest") Conf.app_version.set("0.0.1") self.settings_action: QW.QAction | None = None self.import_csv_action: QW.QAction | None = None super().__init__(console=False) # -- Global action hooks --------------------------------------------------- def _create_global_actions(self) -> None: """Create default actions, then add custom ones.""" super()._create_global_actions() self.settings_action = create_action( self, _("Settings..."), icon=get_icon("libre-gui-settings.svg"), tip=_("Open settings dialog"), ) self.import_csv_action = create_action( self, _("Import CSV..."), icon=get_icon("fileopen_signal.svg"), tip=_("Import data from a CSV file"), ) def _get_main_toolbar_actions(self) -> list[QW.QAction | None]: """Custom toolbar: open, save, browse, separator, import CSV, settings.""" return [ self.openh5_action, self.saveh5_action, self.browseh5_action, None, # separator self.import_csv_action, None, # separator self.settings_action, ] # ============================================================================= # Derived window that removes H5 actions from toolbar # ============================================================================= class MinimalToolbarWindow(SGMXMainWindow): """Derived window with a minimal toolbar (no H5 actions).""" def __init__(self) -> None: Conf.app_name.set("MinimalToolbarTest") Conf.app_version.set("0.0.1") self.custom_action: QW.QAction | None = None super().__init__(console=False) def _create_global_actions(self) -> None: """Create default actions plus a single custom action.""" super()._create_global_actions() self.custom_action = create_action( self, _("My Action"), tip=_("A custom action"), ) def _get_main_toolbar_actions(self) -> list[QW.QAction | None]: """Only show the custom action in the toolbar.""" return [self.custom_action] # ============================================================================= # Helpers # ============================================================================= def _get_toolbar_action_texts(toolbar: QW.QToolBar) -> list[str | None]: """Return action texts from a toolbar (None for separators).""" result: list[str | None] = [] for action in toolbar.actions(): if action.isSeparator(): result.append(None) else: result.append(action.text()) return result # ============================================================================= # Test # ============================================================================= def test_toolbar_hooks_custom(): """Verify that toolbar hook overrides produce the expected toolbar layout.""" with qth.sigimax_app_context(exec_loop=False): win = CustomToolbarWindow() win.resize(1000, 600) win.show() texts = _get_toolbar_action_texts(win.main_toolbar) # -- Default H5 actions must still be present ------------------------- assert _("Open HDF5 files...") in texts, f"Missing Open HDF5: {texts}" assert _("Save to HDF5 file...") in texts, f"Missing Save HDF5: {texts}" assert _("Browse HDF5 file...") in texts, f"Missing Browse HDF5: {texts}" # -- Custom actions must be present ----------------------------------- assert _("Import CSV...") in texts, f"Missing Import CSV: {texts}" assert _("Settings...") in texts, f"Missing Settings: {texts}" # -- Separators must be present (at least 2) -------------------------- sep_count = texts.count(None) assert sep_count >= 2, f"Expected at least 2 separators, got {sep_count}" # -- Order: Open < Save < Browse < separator < Import CSV < separator < Settings idx_open = texts.index(_("Open HDF5 files...")) idx_save = texts.index(_("Save to HDF5 file...")) idx_browse = texts.index(_("Browse HDF5 file...")) idx_csv = texts.index(_("Import CSV...")) idx_settings = texts.index(_("Settings...")) assert idx_open < idx_save < idx_browse, ( f"H5 actions out of order: {idx_open}, {idx_save}, {idx_browse}" ) assert idx_browse < idx_csv < idx_settings, ( f"Custom actions out of order: {idx_browse}, {idx_csv}, {idx_settings}" ) # -- H5 actions are still usable (not None) -------------------------- assert win.openh5_action is not None assert win.saveh5_action is not None assert win.browseh5_action is not None # -- Clean close ------------------------------------------------------ win.set_modified(False) win.close() print("Toolbar hooks (custom) test passed.") def test_toolbar_hooks_minimal(): """Verify that a derived app can replace the toolbar entirely.""" with qth.sigimax_app_context(exec_loop=False): win = MinimalToolbarWindow() win.resize(800, 500) win.show() texts = _get_toolbar_action_texts(win.main_toolbar) # -- Only the custom action should be in the toolbar ------------------ real_actions = [t for t in texts if t is not None] assert real_actions == [_("My Action")], ( f"Expected only 'My Action', got: {real_actions}" ) # -- H5 actions should still exist (just not in toolbar) -------------- assert win.openh5_action is not None, "openh5_action should still be created" assert win.saveh5_action is not None, "saveh5_action should still be created" assert win.browseh5_action is not None, ( "browseh5_action should still be created" ) # -- H5 actions are NOT in the toolbar -------------------------------- assert _("Open HDF5 files...") not in texts, ( "Open HDF5 should NOT be in minimal toolbar" ) # -- Clean close ------------------------------------------------------ win.set_modified(False) win.close() print("Toolbar hooks (minimal) test passed.") if __name__ == "__main__": test_toolbar_hooks_custom() test_toolbar_hooks_minimal() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/test_env.py000066400000000000000000000106361524425144300234710ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for env.py execution environment --------------------------------------- Covers: - VerbosityLevels enum values - SGMXExecEnv.print with different verbosity levels - SGMXExecEnv.pprint output - SGMXExecEnv.to_dict - SGMXExecEnv.context manager """ from __future__ import annotations import io import pytest from sigimax.env import VerbosityLevels, execenv pytestmark = pytest.mark.unit class TestVerbosityLevels: """Tests for VerbosityLevels enum.""" def test_quiet_value(self): """The QUIET level should have the value 'quiet'.""" assert VerbosityLevels.QUIET.value == "quiet" def test_normal_value(self): """The NORMAL level should have the value 'normal'.""" assert VerbosityLevels.NORMAL.value == "normal" def test_debug_value(self): """The DEBUG level should have the value 'debug'.""" assert VerbosityLevels.DEBUG.value == "debug" def test_all_values(self): """All enum values should be present and correct.""" values = {v.value for v in VerbosityLevels} assert values == {"quiet", "normal", "debug"} class TestSGMXExecEnv: """Tests for the SGMXExecEnv singleton behavior.""" def test_to_dict_returns_dict(self): """to_dict should return a dictionary with key properties.""" d = execenv.to_dict() assert isinstance(d, dict) # Should contain at least the key properties assert "unattended" in d assert "verbose" in d def test_print_normal_verbosity(self): """In normal verbosity, print() should output.""" old_verbose = execenv.verbose try: execenv.verbose = VerbosityLevels.NORMAL.value buf = io.StringIO() execenv.print("test output", file=buf) assert "test output" in buf.getvalue() finally: execenv.verbose = old_verbose def test_print_quiet_suppresses(self): """In quiet verbosity, print() should suppress output.""" old_verbose = execenv.verbose try: execenv.verbose = VerbosityLevels.QUIET.value buf = io.StringIO() execenv.print("should not appear", file=buf) assert buf.getvalue() == "" finally: execenv.verbose = old_verbose def test_pprint_normal_verbosity(self): """In normal verbosity, pprint() should produce output.""" old_verbose = execenv.verbose try: execenv.verbose = VerbosityLevels.NORMAL.value buf = io.StringIO() execenv.pprint({"key": "value"}, stream=buf) assert "key" in buf.getvalue() finally: execenv.verbose = old_verbose def test_pprint_quiet_suppresses(self): """In quiet verbosity, pprint() should suppress output.""" old_verbose = execenv.verbose try: execenv.verbose = VerbosityLevels.QUIET.value buf = io.StringIO() execenv.pprint({"key": "value"}, stream=buf) assert buf.getvalue() == "" finally: execenv.verbose = old_verbose def test_context_manager_restores(self): """Context manager should restore previous state on exit.""" old_unattended = execenv.unattended old_verbose = execenv.verbose with execenv.context(unattended=True, verbose="debug"): assert execenv.unattended is True assert execenv.verbose == "debug" assert execenv.unattended == old_unattended assert execenv.verbose == old_verbose def test_str_representation(self): """__str__ should return a non-empty string.""" s = str(execenv) assert len(s) > 0 def test_demo_mode(self): """enable_demo_mode / disable_demo_mode toggle.""" old_unattended = execenv.unattended old_delay = execenv.delay try: execenv.enable_demo_mode(delay=500) assert execenv.demo_mode is True assert execenv.unattended is True assert execenv.delay == 500 execenv.disable_demo_mode() assert execenv.demo_mode is False assert execenv.unattended is False assert execenv.delay == 0 finally: execenv.unattended = old_unattended execenv.delay = old_delay if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/utils/000077500000000000000000000000001524425144300224225ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/tests/utils/__init__.py000066400000000000000000000001251524425144300245310ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. DataLab-Platform-SigimaX-0182bf4/sigimax/tests/utils/test_qthelpers.py000066400000000000000000000133401524425144300260430ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for utils/qthelpers.py ----------------------------- Covers: - get_log_contents, initialize_log_file, remove_empty_log_file (unit) - is_running_tests (unit) - save_restore_stds (unit) - block_signals (gui) """ from __future__ import annotations import os import sys import tempfile import pytest from guidata.qthelpers import qt_app_context from qtpy import QtWidgets as QW from sigimax.utils.qthelpers import ( block_signals, get_log_contents, initialize_log_file, is_running_tests, remove_empty_log_file, save_restore_stds, ) # ======================== Unit tests ========================================= pytestmark = pytest.mark.unit class TestGetLogContents: """Tests for get_log_contents.""" def test_nonexistent_file_returns_none(self): """Should return None for a nonexistent file.""" assert get_log_contents("/nonexistent/path/file.log") is None def test_empty_file_returns_empty(self): """Should return empty string for an empty file.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f: path = f.name try: result = get_log_contents(path) # Empty file → empty string (stripped) assert result == "" finally: os.unlink(path) def test_file_with_content(self): """Should return the file contents as a string.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".log", delete=False, encoding="utf-8" ) as f: f.write("error occurred at line 42\n") path = f.name try: result = get_log_contents(path) assert "error occurred" in result finally: os.unlink(path) class TestInitializeLogFile: """Tests for initialize_log_file.""" def test_no_previous_log(self): """Should initialize log file when no previous log exists (empty file).""" with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f: path = f.name try: result = initialize_log_file(path) assert result is False # Empty file → no previous log finally: if os.path.exists(path): os.unlink(path) def test_with_previous_log(self): """Should initialize and rename previous log file.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".log", delete=False, encoding="utf-8" ) as f: f.write("some log content\n") path = f.name old_path = os.path.splitext(path)[0] + ".1.log" try: result = initialize_log_file(path) assert result is True assert os.path.exists(old_path) finally: for p in (path, old_path): if os.path.exists(p): os.unlink(p) class TestRemoveEmptyLogFile: """Tests for remove_empty_log_file.""" def test_removes_empty_file(self): """Should remove an empty log file.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f: path = f.name remove_empty_log_file(path) assert not os.path.exists(path) def test_keeps_nonempty_file(self): """Should not remove a file that has content.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".log", delete=False, encoding="utf-8" ) as f: f.write("content\n") path = f.name try: remove_empty_log_file(path) assert os.path.exists(path) finally: os.unlink(path) class TestIsRunningTests: """Tests for is_running_tests.""" def test_returns_true_during_pytest(self): """Should return True when running under pytest.""" assert is_running_tests() is True def test_pytest_in_modules(self): """Should have pytest in sys.modules during tests.""" assert "pytest" in sys.modules class TestSaveRestoreStds: """Tests for save_restore_stds context manager.""" def test_restores_stdout(self): """Should restore original stdout after context.""" original_stdout = sys.stdout with save_restore_stds(): assert sys.stdout is None assert sys.stdout is original_stdout def test_restores_stderr(self): """Should restore original stderr after context.""" original_stderr = sys.stderr with save_restore_stds(): pass # stdout is None inside assert sys.stderr is original_stderr def test_restores_on_exception(self): """Should restore even if an exception is raised inside the context.""" original_stdout = sys.stdout try: with save_restore_stds(): raise RuntimeError("test") except RuntimeError: pass assert sys.stdout is original_stdout # ======================== GUI tests ========================================== @pytest.mark.gui def test_block_signals(): """block_signals context manager blocks and unblocks signals.""" with qt_app_context(): widget = QW.QLineEdit() assert not widget.signalsBlocked() with block_signals(widget): assert widget.signalsBlocked() assert not widget.signalsBlocked() @pytest.mark.gui def test_block_signals_disabled(): """block_signals with enable=False should not block.""" with qt_app_context(): widget = QW.QLineEdit() with block_signals(widget, enable=False): assert not widget.signalsBlocked() assert not widget.signalsBlocked() if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/000077500000000000000000000000001524425144300227305ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/__init__.py000066400000000000000000000000021524425144300250310ustar00rootroot00000000000000# DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/_logview_error.py000066400000000000000000000011121524425144300263210ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Log viewer test: raise an exception and create a seg fault in DataLab """ # guitest: skip from guidata.qthelpers import qt_app_context from sigimax.env import execenv from sigimax.mainwindow import SGMXMainWindow def error(): """Raise an exception and create a seg fault in DataLab""" with execenv.context(unattended=True): with qt_app_context(exec_loop=True): win = SGMXMainWindow() win.test_segfault_error() if __name__ == "__main__": error() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_background_dialog.py000066400000000000000000000053031524425144300300000ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Image background dialog unit test. """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # pylint: disable=duplicate-code # guitest: show from __future__ import annotations import numpy as np import pytest import sigima.objects import sigima.params import sigima.proc.image as sipi from guidata.qthelpers import exec_dialog, qt_app_context from sigima import viz from sigima.tests.data import create_noisy_gaussian_image from sigimax.env import execenv from sigimax.widgets.imagebackground import ImageBackgroundDialog pytestmark = pytest.mark.gui def test_image_background_selection() -> None: """Image background selection test.""" with qt_app_context(): img = create_noisy_gaussian_image() # Switch to non-uniform coordinates to test the background dialog handling: xcoords = np.linspace(0, 10, img.data.shape[1]) img.set_coords(xcoords, 0.02 * xcoords**3) dlg = ImageBackgroundDialog(img) dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix exec_dialog(dlg) if execenv.unattended: dlg.test_compute_background() execenv.print(f"background: {dlg.get_background()}") execenv.print(f"rect coords: {dlg.get_rect_coords()}") # Check background value: x0, y0, x1, y1 = dlg.get_rect_coords() ix0, iy0, ix1, iy1 = dlg.imageitem.get_closest_index_rect(x0, y0, x1, y1) assert np.isclose(img.data[iy0:iy1, ix0:ix1].mean(), dlg.get_background()) def test_image_offset_correction_with_background_dialog() -> None: """Image offset correction interactive test using the background dialog.""" with qt_app_context(): i1 = create_noisy_gaussian_image() dlg = ImageBackgroundDialog(i1) ok = exec_dialog(dlg) if ok: if execenv.unattended: dlg.test_compute_background() param = sigima.objects.ROI2DParam() # pylint: disable=unbalanced-tuple-unpacking ix0, iy0, ix1, iy1 = i1.physical_to_indices(dlg.get_rect_coords()) param.x0, param.y0, param.dx, param.dy = ix0, iy0, ix1 - ix0, iy1 - iy0 i2 = sipi.offset_correction(i1, param) i3 = sipi.clip(i2, sigima.params.ClipParam.create(lower=0)) viz.view_images_side_by_side( [i1, i3], titles=["Original image", "Corrected image"], title="Image offset correction and thresholding", ) if __name__ == "__main__": test_image_background_selection() test_image_offset_correction_with_background_dialog() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_baseline_dialog.py000066400000000000000000000033701524425144300274450ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Baseline dialog test """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # pylint: disable=duplicate-code # guitest: show from __future__ import annotations import numpy as np import pytest import sigima.objects import sigima.proc.signal as sips from guidata.qthelpers import exec_dialog, qt_app_context from sigima.tests.data import create_paracetamol_signal from sigima.viz import view_curves from sigimax.env import execenv from sigimax.widgets.signalbaseline import SignalBaselineDialog pytestmark = pytest.mark.gui def test_signal_baseline_selection(): """Signal baseline selection dialog test""" sig = create_paracetamol_signal() with qt_app_context(): dlg = SignalBaselineDialog(sig) dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix exec_dialog(dlg) execenv.print(f"baseline: {dlg.get_baseline()}") execenv.print(f"X range: {dlg.get_x_range()}") # Check baseline value: i0, i1 = np.searchsorted(sig.x, dlg.get_x_range()) assert dlg.get_baseline() == sig.data[i0:i1].mean() def test_signal_baseline_dialog() -> None: """Test the signal baseline dialog for offset correction.""" with qt_app_context(): s1 = create_paracetamol_signal() dlg = SignalBaselineDialog(s1) if exec_dialog(dlg): param = sigima.objects.ROI1DParam() param.xmin, param.xmax = dlg.get_x_range() s2 = sips.offset_correction(s1, param) view_curves([s1, s2], title="Signal offset correction") if __name__ == "__main__": test_signal_baseline_selection() test_signal_baseline_dialog() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_connection_dialog.py000066400000000000000000000052551524425144300300260ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for the ConnectionDialog widget (:mod:`sigimax.widgets.connection`) -------------------------------------------------------------------------- Covers: - ConnectionThread success/failure signal emission - ConnectionDialog construction (icon/banner, default error message) Note: ``ConnectionDialog.exec()`` itself is not exercised here. Its modal loop combines a background QThread with guidata's unattended auto-close timer, which always force-accepts the dialog (see ``guidata.qthelpers.close_dialog_and_quit``) - this is why DataLab itself never runs it through the automated test suite either (only a manual, ``guitest: show,skip`` script). """ from __future__ import annotations import pytest from guidata.qthelpers import qt_app_context, qt_wait from qtpy import QtGui as QG from sigimax.widgets.connection import ConnectionDialog, ConnectionThread pytestmark = pytest.mark.gui def test_connection_thread_success(): """The thread emits SIG_CONNECTION_OK when the callback succeeds.""" with qt_app_context(): calls: list[int] = [] results: list[bool] = [] thread = ConnectionThread(lambda: calls.append(1)) thread.SIG_CONNECTION_OK.connect(lambda: results.append(True)) thread.start() thread.wait(2000) qt_wait(0.2) assert calls == [1] assert results == [True] def test_connection_thread_failure(): """The thread emits SIG_CONNECTION_KO with the error message on failure.""" def failing_connect() -> None: raise ConnectionRefusedError("no server listening") with qt_app_context(): errors: list[str] = [] thread = ConnectionThread(failing_connect) thread.SIG_CONNECTION_KO.connect(errors.append) thread.start() thread.wait(2000) qt_wait(0.2) assert errors == ["no server listening"] def test_connection_dialog_construction_defaults(): """A dialog builds with no icon/banner and an empty error message.""" with qt_app_context(): dlg = ConnectionDialog(lambda: None) assert dlg.get_error_message() == "" def test_connection_dialog_construction_with_icon_and_banner(): """A dialog accepts an optional icon and banner pixmap.""" with qt_app_context(): icon = QG.QIcon() banner = QG.QPixmap(10, 10) dlg = ConnectionDialog(lambda: None, icon=icon, banner=banner) assert dlg.get_error_message() == "" if __name__ == "__main__": test_connection_thread_success() test_connection_thread_failure() test_connection_dialog_construction_defaults() test_connection_dialog_construction_with_icon_and_banner() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_deltax_dialog.py000066400000000000000000000024621524425144300271450ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Signal delta x dialog unit test. """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # guitest: show import pytest from guidata.qthelpers import exec_dialog, qt_app_context from sigima.tests.data import create_paracetamol_signal from sigima.tools.signal.pulse import full_width_at_y from sigimax.widgets.signaldeltax import SignalDeltaXDialog pytestmark = pytest.mark.gui def test_signal_delta_x_dialog(): """Test the SignalDeltaXDialog widget.""" sig = create_paracetamol_signal() with qt_app_context(): dlg = SignalDeltaXDialog(signal=sig) dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix exec_dialog(dlg) y = dlg.get_y_value() x0, y0, x1, y1 = dlg.get_coords() # The paracetamol signal has multiple crossing points at this Y level with pytest.warns(UserWarning, match="Multiple crossing points found"): exp_x0, exp_y0, exp_x1, exp_y1 = full_width_at_y(sig.x, sig.y, y) assert (x0, y0, x1, y1) == (exp_x0, exp_y0, exp_x1, exp_y1), ( f"Expected: {(exp_x0, exp_y0, exp_x1, exp_y1)} but got: {(x0, y0, x1, y1)}" ) if __name__ == "__main__": test_signal_delta_x_dialog() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_display_all.py000066400000000000000000000171541524425144300266460ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Display all SigimaX widgets ---------------------------- This script displays all widgets from :mod:`sigimax.widgets` using data implementations found in the test modules :mod:`sigimax.tests.widgets` and :mod:`sigimax.tests.hdf5`. """ # guitest: show # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations import numpy as np from guidata.qthelpers import exec_dialog, qt_app_context from sigima.objects import NormalDistribution1DParam from sigima.tests.data import ( create_noisy_gaussian_image, create_noisy_signal, create_paracetamol_signal, get_test_signal, ) from sigima.tools.signal.peakdetection import peak_indices from sigimax.env import execenv from sigimax.tests import helpers, sigimax_test_app_context from sigimax.tests.hdf5.test_h5browser_app import create_h5browser_dialog from sigimax.widgets import fitdialog as fdlg from sigimax.widgets.imagebackground import ImageBackgroundDialog from sigimax.widgets.logviewer import exec_sigimax_logviewer_dialog from sigimax.widgets.signalbaseline import SignalBaselineDialog from sigimax.widgets.signalcursor import SignalCursorDialog from sigimax.widgets.signaldeltax import SignalDeltaXDialog from sigimax.widgets.signalpeak import SignalPeakDetectionDialog def display_signal_baseline_dialog() -> None: """Display the signal baseline selection dialog.""" execenv.print("--- SignalBaselineDialog ---") sig = create_paracetamol_signal() dlg = SignalBaselineDialog(sig) dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") exec_dialog(dlg) execenv.print(f" baseline: {dlg.get_baseline()}") execenv.print(f" X range: {dlg.get_x_range()}") def display_signal_cursor_dialog_horizontal() -> None: """Display the signal cursor dialog in horizontal mode.""" execenv.print("--- SignalCursorDialog (horizontal) ---") sig = create_paracetamol_signal() dlg = SignalCursorDialog(signal=sig, cursor_orientation="horizontal") dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") exec_dialog(dlg) x, y = dlg.get_cursor_position() execenv.print(f" cursor position: x={x}, y={y}") def display_signal_cursor_dialog_vertical() -> None: """Display the signal cursor dialog in vertical mode.""" execenv.print("--- SignalCursorDialog (vertical) ---") sig = create_paracetamol_signal() dlg = SignalCursorDialog(signal=sig, cursor_orientation="vertical") dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") exec_dialog(dlg) x, y = dlg.get_cursor_position() execenv.print(f" cursor position: x={x}, y={y}") def display_signal_deltax_dialog() -> None: """Display the signal delta X dialog.""" execenv.print("--- SignalDeltaXDialog ---") sig = create_paracetamol_signal() dlg = SignalDeltaXDialog(signal=sig) dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") exec_dialog(dlg) y = dlg.get_y_value() x0, y0, x1, y1 = dlg.get_coords() execenv.print(f" y={y}, coords=({x0}, {y0}, {x1}, {y1})") def display_signal_peak_detection_dialog() -> None: """Display the signal peak detection dialog.""" execenv.print("--- SignalPeakDetectionDialog ---") s = get_test_signal("paracetamol.txt") dlg = SignalPeakDetectionDialog(s) dlg.resize(640, 300) plot = dlg.get_plot() plot.set_axis_limits(plot.xBottom, 16, 30) dlg.setObjectName(dlg.objectName() + "_00") exec_dialog(dlg) execenv.print(" peaks:") execenv.pprint(dlg.get_peaks()) execenv.print(f" min_dist: {dlg.get_min_dist()}") def display_image_background_dialog() -> None: """Display the image background dialog.""" execenv.print("--- ImageBackgroundDialog ---") img = create_noisy_gaussian_image() xcoords = np.linspace(0, 10, img.data.shape[1]) img.set_coords(xcoords, 0.02 * xcoords**3) dlg = ImageBackgroundDialog(img) dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") exec_dialog(dlg) if execenv.unattended: dlg.test_compute_background() execenv.print(f" background: {dlg.get_background()}") execenv.print(f" rect coords: {dlg.get_rect_coords()}") def display_fit_dialogs() -> None: """Display all curve fitting dialogs.""" execenv.print("--- Fit Dialogs ---") s1 = get_test_signal("paracetamol.txt") peakidx = peak_indices(s1.y) s2 = create_noisy_signal(NormalDistribution1DParam.create(sigma=5.0)) s3 = get_test_signal("gaussian_fit.txt") s4 = get_test_signal("piecewiseexponential_fit.txt") ep = execenv.print tn = helpers.get_default_test_name ep(" Polynomial fit:") ep(fdlg.polynomial_fit(s2.x, s2.y, 4, name=tn("00"))) ep(" Linear fit:") ep(fdlg.linear_fit(s2.x, s2.y, name=tn("01"))) ep(" Gaussian fit:") ep(fdlg.gaussian_fit(s3.x, s3.y, name=tn("02"))) ep(" Lorentzian fit:") ep(fdlg.lorentzian_fit(s3.x, s3.y, name=tn("03"))) ep(" Multi-Gaussian fit:") ep(fdlg.multigaussian_fit(s1.x, s1.y, peakidx, name=tn("04"))) ep(" Multi-Lorentzian fit:") ep(fdlg.multilorentzian_fit(s1.x, s1.y, peakidx, name=tn("05"))) ep(" Voigt fit:") ep(fdlg.voigt_fit(s3.x, s3.y, name=tn("06"))) ep(" Exponential fit:") ep(fdlg.exponential_fit(s2.x, s2.y, name=tn("07"))) ep(" Sinusoidal fit:") ep(fdlg.sinusoidal_fit(s2.x, s2.y, name=tn("08"))) ep(" CDF fit:") ep(fdlg.cdf_fit(s2.x, s2.y, name=tn("09"))) ep(" Planckian fit:") ep(fdlg.planckian_fit(s3.x, s3.y, name=tn("10"))) ep(" Two-half Gaussian fit:") ep(fdlg.twohalfgaussian_fit(s3.x, s3.y, name=tn("11"))) ep(" Piecewise exponential fit:") ep(fdlg.piecewiseexponential_fit(s4.x, s4.y, name=tn("12"))) def display_logviewer_dialog() -> None: """Display the log viewer dialog.""" execenv.print("--- LogViewer Dialog ---") exec_sigimax_logviewer_dialog() def display_h5browser_dialog() -> None: """Display the HDF5 browser dialog.""" execenv.print("--- H5BrowserDialog ---") fnames = helpers.get_test_fnames("*.h5")[-2:] dlg = create_h5browser_dialog(fnames, toggle_all=True, select_all=True) dlg.setObjectName(dlg.objectName() + "_00") exec_dialog(dlg) def display_memory_status() -> None: """Display the memory status widget in the main window.""" execenv.print("--- Memory Status Widget ---") with sigimax_test_app_context(console=False) as win: win.memorystatus.update_status() def display_h5import() -> None: """Display HDF5 import in the main window.""" execenv.print("--- HDF5 Import ---") with sigimax_test_app_context(console=False) as win: fnames = helpers.get_test_fnames("*.h5") if fnames: fname = fnames[-1] execenv.print(f" Importing HDF5 file: {fname}") win.import_all_from_h5_file(fname) def display_all_widgets() -> None: """Display all SigimaX widgets with test data.""" with qt_app_context(): # Signal widgets display_signal_baseline_dialog() display_signal_cursor_dialog_horizontal() display_signal_cursor_dialog_vertical() display_signal_deltax_dialog() display_signal_peak_detection_dialog() # Image widgets display_image_background_dialog() # Fit dialogs display_fit_dialogs() # Log viewer display_logviewer_dialog() # HDF5 browser display_h5browser_dialog() # Main window widgets (need their own app context) display_memory_status() display_h5import() if __name__ == "__main__": display_all_widgets() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_fileviewer.py000066400000000000000000000062331524425144300265060ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for fileviewer.py utilities and widget -------------------------------------------- Covers: - read_text_file: reads UTF-8 and latin1 files - get_title_contents: returns (title, contents) tuple - FileViewerWidget: basic construction and set_data """ from __future__ import annotations import os import tempfile import pytest from guidata.qthelpers import qt_app_context from sigimax.widgets.fileviewer import ( FileViewerWidget, get_title_contents, read_text_file, ) # ======================== Unit tests ========================================= class TestReadTextFile: """Unit tests for read_text_file.""" pytestmark = pytest.mark.unit def test_read_utf8(self): """Should read UTF-8 encoded files correctly.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".txt", delete=False, encoding="utf-8" ) as f: f.write("Hello café") path = f.name try: result = read_text_file(path) assert "Hello café" in result finally: os.unlink(path) def test_read_latin1(self): """Should read Latin-1 encoded files correctly.""" with tempfile.NamedTemporaryFile(mode="wb", suffix=".txt", delete=False) as f: f.write("résumé".encode("latin1")) path = f.name try: result = read_text_file(path) assert "sum" in result # content should be readable finally: os.unlink(path) def test_read_ascii(self): """Should read ASCII encoded files correctly.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".txt", delete=False, encoding="ascii" ) as f: f.write("plain ascii") path = f.name try: result = read_text_file(path) assert result == "plain ascii" finally: os.unlink(path) class TestGetTitleContents: """Unit tests for get_title_contents.""" pytestmark = pytest.mark.unit def test_returns_tuple(self): """Should return a (title, contents) tuple.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".txt", delete=False, encoding="utf-8" ) as f: f.write("file body") path = f.name try: title, contents = get_title_contents(path) assert isinstance(title, str) assert "file body" in contents assert path in title or os.path.basename(path) in title finally: os.unlink(path) # ======================== GUI tests ========================================== @pytest.mark.gui def test_file_viewer_widget(): """FileViewerWidget: construct and set data without crashing.""" with qt_app_context(): widget = FileViewerWidget() widget.set_data("Title text", "Some file contents\nLine 2") assert widget.label.text() == "Title text" assert "Some file contents" in widget.editor.toPlainText() widget.show() widget.close() if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_fitdialog.py000066400000000000000000000225151524425144300263100ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Curve fitting dialog test Testing fit dialogs: Gaussian, Lorentzian, Voigt, etc. """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # guitest: show import numpy as np import pytest from guidata.qthelpers import qt_app_context from sigima.objects import NormalDistribution1DParam from sigima.tests.data import create_noisy_signal, get_test_signal from sigima.tools.signal import fitting, pulse from sigima.tools.signal.peakdetection import peak_indices from sigimax.env import execenv from sigimax.tests import helpers from sigimax.widgets import fitdialog as fdlg pytestmark = pytest.mark.gui def check_peak_fit_output(output): """Check versioned interactive peak-fit metadata.""" assert output is not None _y_fitted, _params, fit_params = output assert fit_params["fit_params_version"] == 2 assert fit_params["peak_parameterization"] == "height" assert fit_params["interactive"] is True def test_fit_dialog(): """Test function""" with qt_app_context(): # Multi-gaussian curve fitting test s1 = get_test_signal("paracetamol.txt") peakidx = peak_indices(s1.y) s2 = create_noisy_signal(NormalDistribution1DParam.create(sigma=5.0)) s3 = get_test_signal("gaussian_fit.txt") s4 = get_test_signal("piecewiseexponential_fit.txt") ep = execenv.print tn = helpers.get_default_test_name ep(fdlg.polynomial_fit(s2.x, s2.y, 4, name=tn("00"))) ep(fdlg.linear_fit(s2.x, s2.y, name=tn("01"))) ep(fdlg.gaussian_fit(s3.x, s3.y, name=tn("02"))) ep(fdlg.lorentzian_fit(s3.x, s3.y, name=tn("03"))) ep(fdlg.multigaussian_fit(s1.x, s1.y, peakidx, name=tn("04"))) ep(fdlg.multilorentzian_fit(s1.x, s1.y, peakidx, name=tn("05"))) ep(fdlg.voigt_fit(s3.x, s3.y, name=tn("06"))) ep(fdlg.exponential_fit(s2.x, s2.y, name=tn("07"))) ep(fdlg.sinusoidal_fit(s2.x, s2.y, name=tn("08"))) ep(fdlg.cdf_fit(s2.x, s2.y, name=tn("09"))) ep(fdlg.planckian_fit(s3.x, s3.y, name=tn("10"))) ep(fdlg.twohalfgaussian_fit(s3.x, s3.y, name=tn("11"))) ep(fdlg.piecewiseexponential_fit(s4.x, s4.y, name=tn("12"))) def test_peak_fit_metadata(monkeypatch): """Peak fit dialogs return canonical metadata when accepted.""" def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs): return [param.value for param in fitparams] monkeypatch.setattr(fdlg, "guifit", accept_initial_values) single_peak = get_test_signal("gaussian_fit.txt") multi_peak = get_test_signal("paracetamol.txt") peakidx = peak_indices(multi_peak.y) outputs = ( fdlg.gaussian_fit(single_peak.x, single_peak.y), fdlg.lorentzian_fit(single_peak.x, single_peak.y), fdlg.voigt_fit(single_peak.x, single_peak.y), fdlg.multigaussian_fit(multi_peak.x, multi_peak.y, peakidx), fdlg.multilorentzian_fit(multi_peak.x, multi_peak.y, peakidx), ) for output in outputs: check_peak_fit_output(output) NON_PEAK_FIT_CASES = ( ("linear", "noisy", fdlg.linear_fit), ("polynomial", "noisy", lambda x, y: fdlg.polynomial_fit(x, y, 4)), ("exponential", "noisy", fdlg.exponential_fit), ("sinusoidal", "noisy", fdlg.sinusoidal_fit), ("cdf", "noisy", fdlg.cdf_fit), ("planckian", "gaussian_fit.txt", fdlg.planckian_fit), ("twohalfgaussian", "gaussian_fit.txt", fdlg.twohalfgaussian_fit), ( "doubleexponential", "piecewiseexponential_fit.txt", fdlg.piecewiseexponential_fit, ), ) @pytest.mark.parametrize(("fit_type", "data", "call_dialog"), NON_PEAK_FIT_CASES) def test_non_peak_fit_metadata(monkeypatch, fit_type, data, call_dialog): """Non-peak fit dialogs return evaluable canonical metadata. The decisive check is the round-trip: re-evaluating the stored parameters with Sigima must reproduce the curve computed by the dialog. It catches any parameter name, ordering or unit mismatch between the two layers. """ def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs): return [param.value for param in fitparams] monkeypatch.setattr(fdlg, "guifit", accept_initial_values) if data == "noisy": signal = create_noisy_signal(NormalDistribution1DParam.create(sigma=5.0)) else: signal = get_test_signal(data) output = call_dialog(signal.x, signal.y) assert output is not None y_fitted, _params, fit_params = output assert fit_params["fit_type"] == fit_type assert fit_params["interactive"] is True fitting.validate_fit_params(fit_params) np.testing.assert_allclose( fitting.evaluate_fit(signal.x, **fit_params), y_fitted, rtol=1e-10, atol=1e-10 ) @pytest.mark.parametrize( ("dialog", "fit_type"), [ (fdlg.multigaussian_fit, "multigaussian"), (fdlg.multilorentzian_fit, "multilorentzian"), ], ) def test_multi_peak_fit_metadata_preserves_fixed_centers(monkeypatch, dialog, fit_type): """Multi-peak metadata preserves centers without adding fit controls.""" def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs): values = [param.value for param in fitparams] values[1] = -abs(values[1]) return values monkeypatch.setattr(fdlg, "guifit", accept_initial_values) signal = get_test_signal("paracetamol.txt") peakidx = peak_indices(signal.y) output = dialog(signal.x, signal.y, peakidx) assert output is not None y_fitted, params, fit_params = output assert len(params) == 2 * len(peakidx) + 1 assert fit_params["fit_type"] == fit_type for index, peak_index in enumerate(peakidx, start=1): assert fit_params[f"x0_{index}"] == pytest.approx(signal.x[peak_index]) assert fit_params[f"sigma_{index}"] > 0.0 np.testing.assert_allclose(fitting.evaluate_fit(signal.x, **fit_params), y_fitted) @pytest.mark.parametrize( ("dialog", "model"), [ (fdlg.gaussian_fit, pulse.GaussianModel), (fdlg.lorentzian_fit, pulse.LorentzianModel), (fdlg.voigt_fit, pulse.VoigtModel), ], ) def test_peak_fit_dialog_supports_negative_amplitude(monkeypatch, dialog, model): """Interactive peak controls expose and preserve signed amplitudes.""" captured_amplitudes = [] def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs): captured_amplitudes.append(fitparams[0]) return [param.value for param in fitparams] monkeypatch.setattr(fdlg, "guifit", accept_initial_values) x = np.linspace(-10.0, 10.0, 400) y = model.evaluate(x, -3.0, 1.5, 0.75, 2.0) output = dialog(x, y) assert output is not None _y_fitted, _params, fit_params = output assert fit_params["amplitude"] < 0.0 amplitude_param = captured_amplitudes[0] assert amplitude_param.min < 0.0 < amplitude_param.max def __capture_fit_params(monkeypatch) -> list: """Patch `guifit` so it accepts the initial values and records the controls.""" captured: list = [] def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs): captured.extend(fitparams) return [param.value for param in fitparams] monkeypatch.setattr(fdlg, "guifit", accept_initial_values) return captured @pytest.mark.parametrize( ("dialog", "make_data", "true_values"), [ # A decaying exponential: the B slider used to be restricted to # positive values, so this optimum was unreachable. # Parameter order: (a, b, y0) ( fdlg.exponential_fit, lambda x: 3.0 * np.exp(-0.8 * x) + 1.0, {1: -0.8}, ), # A descending transition: the amplitude slider used to start at 0. # Parameter order: (amplitude, mu, sigma, baseline) ( fdlg.cdf_fit, lambda x: ( -2.0 * fitting.CDFFitComputer.evaluate(x, 1.0, 5.0, 1.0, 0.0) + 4.0 ), {0: -2.0}, ), # A decay-then-rise shape: the rate sliders used to hard-code the # opposite (rise-then-decay) sign convention. # Parameter order: (x_center, a_left, b_left, a_right, b_right, y0) ( fdlg.piecewiseexponential_fit, lambda x: np.where(x < 5.0, np.exp(-(x - 5.0)), np.exp(x - 5.0)) + 0.5, {2: -1.0, 4: 1.0}, ), ], ) def test_fit_dialog_bounds_contain_the_optimum( monkeypatch, dialog, make_data, true_values ): """Interactive fit sliders must be able to reach the true parameters. Several dialogs used one-sided bounds that excluded a whole family of shapes, or bounds derived from the magnitude of the initial guess, which could invert into an empty interval. """ captured = __capture_fit_params(monkeypatch) x = np.linspace(0.0, 10.0, 400) assert dialog(x, make_data(x)) is not None for param in captured: assert param.min < param.max, f"{param.name}: inverted bounds" assert param.min <= param.value <= param.max, ( f"{param.name}: initial value outside its bounds" ) for index, true_value in true_values.items(): param = captured[index] assert param.min <= true_value <= param.max, ( f"{param.name}: true value {true_value} is outside the slider range " f"[{param.min}, {param.max}]" ) if __name__ == "__main__": test_fit_dialog() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_logviewer.py000066400000000000000000000007321524425144300263460ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Log viewer test """ # guitest: show import pytest from guidata.qthelpers import qt_app_context from sigimax.widgets.logviewer import exec_sigimax_logviewer_dialog pytestmark = pytest.mark.gui def test_logviewer_dialog(): """Test log viewer window""" with qt_app_context(): exec_sigimax_logviewer_dialog() if __name__ == "__main__": test_logviewer_dialog() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_memstatus.py000066400000000000000000000040711524425144300263650ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Memory status widget application test """ # guitest: show import psutil import pytest from sigimax import config from sigimax.env import execenv from sigimax.tests import sigimax_test_app_context pytestmark = pytest.mark.app def memory_alarm(threshold, expect_alarm): """Memory alarm test Args: threshold: available memory threshold (MB) expect_alarm: True if alarm is expected to trigger """ config.CONF.available_memory_threshold.set(threshold) with sigimax_test_app_context() as win: alarm_states = [] win.memorystatus.SIG_MEMORY_ALARM.connect(alarm_states.append) win.memorystatus.update_status() # Force memory status update assert len(alarm_states) == 1, "SIG_MEMORY_ALARM should have been emitted once" alarm_fired = alarm_states[0] assert alarm_fired == expect_alarm, ( f"Expected alarm={expect_alarm} for threshold={threshold} MB, " f"got alarm={alarm_fired}" ) # Verify visual indicators match alarm state if expect_alarm: assert "red" in win.memorystatus.label.styleSheet() else: assert "red" not in win.memorystatus.label.styleSheet() execenv.print(f" Alarm fired: {alarm_fired} (expected: {expect_alarm})") def test_mem_status(): """Memory alarm test""" mem_available = psutil.virtual_memory().available // (1024**2) execenv.print(f"Memory status widget test (memory available: {mem_available} MB):") test_cases = ( (mem_available * 2, True), # Threshold above available → alarm ON (mem_available - 100, False), # Threshold below available → alarm OFF ) for index, (threshold, expect_alarm) in enumerate(test_cases): execenv.print( f" Threshold {index}: {threshold} MB (expect alarm: {expect_alarm})" ) memory_alarm(threshold, expect_alarm) config.CONF.reset_to_defaults() if __name__ == "__main__": test_mem_status() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_object_dialog.py000066400000000000000000000065071524425144300271360ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for the GetObjectDialog widget (:mod:`sigimax.widgets.objectdialog`) ---------------------------------------------------------------------------- Covers: - SimpleObjectTree population from a remote proxy (stub server) - GetObjectDialog current object selection and OK button state """ from __future__ import annotations import numpy as np import pytest from guidata.qthelpers import qt_app_context from sigima.client import SimpleRemoteProxy from sigima.client.stub import DataLabStubServer from sigimax.widgets.objectdialog import GetObjectDialog pytestmark = pytest.mark.gui def _connected_proxy_with_signals(server: DataLabStubServer) -> SimpleRemoteProxy: """Connect a proxy to the stub server and populate its default group. ``add_signal`` stores the signal on the stub server but does not attach it to any group: the default group's object list is wired directly here via the stub server instance to get a deterministic, non-empty group. """ proxy = SimpleRemoteProxy(autoconnect=False) proxy.connect(port=str(server.port), timeout=2.0, retries=1) proxy.add_signal("Signal 1", np.arange(10.0), np.arange(10.0)) proxy.add_signal("Signal 2", np.arange(10.0), np.arange(10.0) ** 2) group_uuid = next(iter(server.signal_groups)) server.signal_groups[group_uuid].objects.extend(server.signals.keys()) return proxy def test_get_object_dialog_lists_signals(): """The dialog tree is populated with the signals added to the panel.""" server = DataLabStubServer(verbose=False) server.start() try: proxy = _connected_proxy_with_signals(server) with qt_app_context(): dlg = GetObjectDialog(None, proxy, panel="signal") group_item = dlg.tree.topLevelItem(0) titles = [ group_item.child(index).text(0) for index in range(group_item.childCount()) ] # No item selected by default -> OK button disabled assert dlg.get_current_object_uuid() is None assert not dlg.ok_btn.isEnabled() # Selecting the first object enables the OK button dlg.tree.setCurrentItem(group_item.child(0)) assert dlg.get_current_object_uuid() is not None assert dlg.ok_btn.isEnabled() assert any("Signal 1" in title for title in titles) assert any("Signal 2" in title for title in titles) finally: server.stop() def test_get_object_dialog_no_selection_disables_ok(): """With no current item, the OK button is disabled.""" server = DataLabStubServer(verbose=False) server.start() try: proxy = SimpleRemoteProxy(autoconnect=False) proxy.connect(port=str(server.port), timeout=2.0, retries=1) with qt_app_context(): dlg = GetObjectDialog(None, proxy, panel="signal") dlg.tree.clearSelection() dlg.tree.setCurrentItem(None) # pylint: disable=protected-access dlg._GetObjectDialog__current_object_changed() assert dlg.get_current_object_uuid() is None assert not dlg.ok_btn.isEnabled() finally: server.stop() if __name__ == "__main__": test_get_object_dialog_lists_signals() test_get_object_dialog_no_selection_disables_ok() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_select_xy_cursor.py000066400000000000000000000032101524425144300277310ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Signal horizontal or vertical cursor selection unit test. """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # guitest: show from typing import Literal import numpy as np import pytest from guidata.qthelpers import exec_dialog, qt_app_context from sigima.tests.data import create_paracetamol_signal from sigima.tools.signal.features import find_x_values_at_y from sigimax.env import execenv from sigimax.widgets.signalcursor import SignalCursorDialog pytestmark = pytest.mark.gui @pytest.mark.parametrize("cursor_orientation", ["horizontal", "vertical"]) def test_signal_cursor_selection( cursor_orientation: Literal["horizontal", "vertical"], ) -> None: """Parametrized signal cursor selection unit test.""" sig = create_paracetamol_signal() with qt_app_context(): dlg = SignalCursorDialog(signal=sig, cursor_orientation=cursor_orientation) dlg.resize(640, 480) dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix exec_dialog(dlg) x, y = dlg.get_cursor_position() if cursor_orientation == "horizontal": execenv.print(f"X value: {x}") x_sig = find_x_values_at_y(sig.x, sig.y, y)[0] assert x == x_sig, f"Expected {x_sig}, got {x}" else: execenv.print(f"Y value: {y}") y_sig = sig.y[np.searchsorted(sig.x, x)] assert y == y_sig, f"Expected {y_sig}, got {y}" if __name__ == "__main__": test_signal_cursor_selection(cursor_orientation="horizontal") test_signal_cursor_selection(cursor_orientation="vertical") DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_signalpeak_dialog.py000066400000000000000000000017771524425144300300120ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Signal peak detection dialog test. """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # guitest: show import pytest from guidata.qthelpers import exec_dialog, qt_app_context from sigima.tests.data import get_test_signal from sigimax.env import execenv from sigimax.widgets.signalpeak import SignalPeakDetectionDialog pytestmark = pytest.mark.gui def test_peak1d_dialog(): """Signal peak dialog test""" with qt_app_context(): s = get_test_signal("paracetamol.txt") dlg = SignalPeakDetectionDialog(s) dlg.resize(640, 300) plot = dlg.get_plot() plot.set_axis_limits(plot.xBottom, 16, 30) dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix exec_dialog(dlg) execenv.print("peaks:") execenv.pprint(dlg.get_peaks()) execenv.pprint(dlg.get_min_dist()) if __name__ == "__main__": test_peak1d_dialog() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_splashscreen_resource.py000066400000000000000000000036431524425144300307500ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Splash-screen resource resolution tests.""" from __future__ import annotations from unittest.mock import patch from qtpy import QtGui as QG from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig def test_splash_resolves_image_basename(tmp_path) -> None: """A basename may be resolved through guidata's registered image paths.""" image_path = tmp_path / "derived-splash.png" pixmap = QG.QPixmap(64, 32) pixmap.fill(QG.QColor("red")) assert pixmap.save(str(image_path)) config = SplashScreenConfig( image_path="derived-splash.png", show_progress=False, ) with patch( "sigimax.widgets.splashscreen.get_image_file_path", return_value=str(image_path), ): splash = SigimaXSplashScreen(config) assert splash.pixmap().size() == pixmap.size() splash.close() def test_missing_splash_resource_uses_fallback() -> None: """An unresolved configured image must not prevent application startup.""" config = SplashScreenConfig( image_path="missing-derived-splash.png", app_name="DerivedApp", ) with patch( "sigimax.widgets.splashscreen.get_image_file_path", side_effect=RuntimeError("not found"), ): splash = SigimaXSplashScreen(config) assert not splash.pixmap().isNull() assert splash.pixmap().size().width() == 480 assert splash.pixmap().size().height() == 280 splash.close() def test_progress_message_may_be_disabled() -> None: """Derived apps may preserve an image-only splash without messages.""" config = SplashScreenConfig(image_path=None, show_progress=False) splash = SigimaXSplashScreen(config) with patch.object(splash, "showMessage") as show_message: splash.show_message("Initializing...") show_message.assert_not_called() splash.close() DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_warningerror.py000066400000000000000000000063221524425144300270630ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Tests for warningerror.py utilities ------------------------------------ Covers: - insert_spaces: pure text utility - WarningErrorMessageBox: basic construction with sample error/warning """ from __future__ import annotations import pytest from guidata.qthelpers import exec_dialog, qt_app_context from qtpy import QtWidgets as QW from sigimax.widgets.warningerror import WarningErrorMessageBox, insert_spaces pytestmark = pytest.mark.unit class TestInsertSpaces: """Tests for the insert_spaces pure-text utility.""" def test_short_text_unchanged(self): """ Short text should be returned unchanged (except for a possible trailing space). """ result = insert_spaces("hi", 80) # Short text should pass through with at most a trailing space assert "hi" in result def test_long_text_gets_spaces(self): """Long text should have spaces inserted.""" text = "a" * 200 result = insert_spaces(text, 40) # Should contain spaces breaking up the text assert " " in result # The content characters should all still be present assert result.replace(" ", "") == text def test_special_chars_trigger_break(self): """Special chars should trigger breaks even if text is short.""" text = "hello,world-foo+bar" result = insert_spaces(text, 5) assert " " in result def test_empty_string(self): """Empty string should return empty string.""" result = insert_spaces("", 10) assert result == "" def test_exact_nbchars(self): """Text with exactly nbchars should get a space added.""" text = "abcde" result = insert_spaces(text, 5) # With exactly nbchars, one iteration adds space assert "abcde" in result def _show_message_box(category: str) -> None: """Construct and show a WarningErrorMessageBox for the given category.""" with qt_app_context(): win = QW.QMainWindow() win.setWindowTitle(f"SigimaX {category.capitalize()} Message Box test") win.show() if category == "error": try: raise ValueError("Test error message box") except ValueError: context = "Test_error_message_box." * 5 tip = "This error may occured when testing the error message box. " * 10 dlg = WarningErrorMessageBox(win, "error", context, tip=tip) exec_dialog(dlg) else: context = "Test_warning_message_box." * 5 message = "Test warning message box" * 10 dlg = WarningErrorMessageBox(win, "warning", context, message) exec_dialog(dlg) @pytest.mark.gui class TestWarningErrorMessageBox: """Tests for the WarningErrorMessageBox dialog construction.""" def test_error_message_box(self): """An error message box can be constructed and shown.""" _show_message_box("error") def test_warning_message_box(self): """A warning message box can be constructed and shown.""" _show_message_box("warning") if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/tests/widgets/test_wizard.py000066400000000000000000000117141524425144300256450ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. # pylint: disable=protected-access """ Tests for the Wizard widget (:mod:`sigimax.widgets.wizard`) ----------------------------------------------------------- Covers: - WizardPage: title, subtitle, validity flag, add_to_layout - Wizard: page navigation (next/back), button states, accept/reject """ from __future__ import annotations import pytest from guidata.qthelpers import qt_app_context from qtpy import QtWidgets as QW from sigimax.widgets.wizard import Wizard, WizardPage pytestmark = pytest.mark.gui # --------------------------------------------------------------------------- # Test pages # --------------------------------------------------------------------------- class _PageA(WizardPage): """First test page — always valid.""" def __init__(self): super().__init__() self.set_title("Page A") self.set_subtitle("First page") self._initialized = False def initialize_page(self): self._initialized = True super().initialize_page() class _PageB(WizardPage): """Second page — validity can be toggled.""" def __init__(self): super().__init__() self.set_title("Page B") self.set_subtitle("Second page") self.checkbox = QW.QCheckBox("Accept terms") self.add_to_layout(self.checkbox) self.set_valid(True) class _PageInvalid(WizardPage): """A page that starts invalid.""" def __init__(self): super().__init__() self.set_title("Invalid Page") self.set_valid(False) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_wizard_page_title_subtitle(): """WizardPage title and subtitle text.""" with qt_app_context(): page = _PageA() assert page._title_label.text() == "Page A" assert page._subtitle_label.text() == "First page" def test_wizard_page_validity(): """WizardPage validity flag and signal.""" with qt_app_context(): page = _PageA() assert page.is_valid() is True page.set_valid(False) assert page.is_valid() is False page.set_valid(True) assert page.is_valid() is True def test_wizard_page_add_widget(): """WizardPage.add_to_layout with a QWidget.""" with qt_app_context(): page = WizardPage() btn = QW.QPushButton("Test") page.add_to_layout(btn) assert page._user_layout.count() == 1 def test_wizard_navigation_buttons(): """Wizard button states after page navigation.""" with qt_app_context(): wizard = Wizard() wizard.add_page(_PageA()) wizard.add_page(_PageB(), last_page=True) # On first page: Back disabled, Next enabled, Finish disabled assert not wizard._back_btn.isEnabled() assert wizard._next_btn.isEnabled() assert not wizard._finish_btn.isEnabled() # Move to next page wizard.go_to_next_page() # On last page: Back enabled, Next disabled, Finish enabled assert wizard._back_btn.isEnabled() assert not wizard._next_btn.isEnabled() assert wizard._finish_btn.isEnabled() # Go back wizard.go_to_previous_page() assert not wizard._back_btn.isEnabled() assert wizard._next_btn.isEnabled() def test_wizard_single_page_finish(): """A single-page wizard should have Finish enabled when page is valid.""" with qt_app_context(): wizard = Wizard() wizard.add_page(_PageA(), last_page=True) # Single page, last page, valid → Finish enabled assert wizard._finish_btn.isEnabled() assert not wizard._next_btn.isEnabled() assert not wizard._back_btn.isEnabled() def test_wizard_invalid_page_blocks_next(): """When a page is invalid, Next should be disabled.""" with qt_app_context(): wizard = Wizard() wizard.add_page(_PageInvalid()) wizard.add_page(_PageB(), last_page=True) # First page is invalid → Next disabled assert not wizard._next_btn.isEnabled() assert not wizard._finish_btn.isEnabled() def test_wizard_page_initialization(): """initialize_page is called when wizard navigates to a page.""" with qt_app_context(): page_a = _PageA() page_b = _PageB() wizard = Wizard() wizard.add_page(page_a) wizard.add_page(page_b, last_page=True) # Page A is initialized when wizard is created (last_page=True triggers it # on page 0) assert page_a._initialized is True def test_wizard_set_wizard_reference(): """Each page should have a reference to its parent wizard.""" with qt_app_context(): page = _PageA() wizard = Wizard() wizard.add_page(page, last_page=True) assert page.get_wizard() is wizard if __name__ == "__main__": pytest.main([__file__, "-v"]) DataLab-Platform-SigimaX-0182bf4/sigimax/utils/000077500000000000000000000000001524425144300212605ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/utils/__init__.py000066400000000000000000000003561524425144300233750ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Utilities ========= The :mod:`sigimax.utils` package provides utility functions for SigimaX and derived applications. """ __all__: list[str] = [] DataLab-Platform-SigimaX-0182bf4/sigimax/utils/conf.py000066400000000000000000000027721524425144300225670ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX Configuration utilities """ from __future__ import annotations from guidata.userconfig import UserConfig class AppUserConfig(UserConfig): """Application user configuration""" def to_dict(self) -> dict: """Return configuration as a dictionary""" confdict = {} for section in self.sections(): secdict = {} for option, value in self.items(section, raw=self.raw): secdict[option] = value confdict[section] = secdict return confdict CONF = AppUserConfig({}) class Configuration: """Configuration file""" @classmethod def initialize(cls, name: str, version: str, load: bool) -> None: """Initialize configuration""" CONF.set_application(name, version, load=load) @classmethod def reset(cls) -> None: """Reset configuration""" global CONF # pylint: disable=global-statement CONF.cleanup() # Remove configuration file CONF = AppUserConfig({}) @classmethod def get_filename(cls) -> str: """Return configuration file name""" return CONF.filename() @classmethod def get_path(cls, basename: str) -> str: """Return filename path inside configuration directory""" return CONF.get_path(basename) @classmethod def to_dict(cls) -> dict: """Return configuration as a dictionary""" return CONF.to_dict() DataLab-Platform-SigimaX-0182bf4/sigimax/utils/qthelpers.py000066400000000000000000000506201524425144300236440ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX Qt utilities """ from __future__ import annotations import faulthandler import inspect import logging import os import os.path as osp import shutil import sys import time import traceback from collections.abc import Callable, Generator from contextlib import contextmanager from typing import Any import guidata from guidata.configtools import get_icon from guidata.qthelpers import grab_save_window as guidata_grab_save_window from guidata.utils.misc import to_string from qtpy import QtCore as QC from qtpy import QtWidgets as QW from sigimax.config import ( _, get_conf, get_old_log_fname, ) from sigimax.env import execenv # Used internally by sigimax_app_context def close_widgets_and_quit(screenshot=False) -> None: """Close Qt top level widgets and quit Qt event loop""" for widget in QW.QApplication.instance().topLevelWidgets(): try: wname = widget.objectName() except RuntimeError: # Object has been deleted continue if screenshot and wname and widget.isVisible(): # pragma: no cover grab_save_window(widget, wname.lower()) assert widget.close() QW.QApplication.instance().quit() QAPP_INSTANCE = None # Used internally by initialize_log_file and remove_empty_log_file def get_log_contents(fname: str) -> str | None: """Return True if file exists and something was logged in it""" if osp.exists(fname): with open(fname, "rb") as fdesc: return to_string(fdesc.read()).strip() return None # Used internally by sigimax_app_context def initialize_log_file(fname: str) -> bool: """Eventually keep the previous log file Returns True if there was a previous log file""" contents = get_log_contents(fname) if contents: try: shutil.move(fname, get_old_log_fname(fname)) except Exception: # pylint: disable=broad-except pass return True return False # Used internally by sigimax_app_context def remove_empty_log_file(fname: str) -> None: """Eventually remove empty log files""" if not get_log_contents(fname): try: os.remove(fname) except Exception: # pylint: disable=broad-except pass # Used in SigimaX tests and app launcher @contextmanager def sigimax_app_context( exec_loop=False, enable_logs=True ) -> Generator[QW.QApplication, None, None]: """SigimaX Qt application context manager, handling Qt application creation and persistance, faulthandler/traceback logging features, screenshot mode and unattended mode. Args: exec_loop: whether to execute Qt event loop (default: False) enable_logs: whether to enable logs (default: True) """ global QAPP_INSTANCE # pylint: disable=global-statement if QAPP_INSTANCE is None: QAPP_INSTANCE = guidata.qapplication() conf = get_conf() # === Set application name and version --------------------------------------------- QAPP_INSTANCE.setApplicationName(conf.app_name.get()) QAPP_INSTANCE.setApplicationVersion(conf.app_version.get()) QAPP_INSTANCE.setOrganizationName(conf.app_name.get() + " project") if enable_logs: # === Create a logger for standard exceptions ---------------------------------- tb_log_fname = conf.traceback_log_path.get() conf.traceback_log_available.set(initialize_log_file(tb_log_fname)) logger = logging.getLogger(__name__) fmt = "[%(asctime)s] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s" logging.basicConfig( filename=tb_log_fname, filemode="w", level=logging.ERROR, format=fmt, datefmt=conf.datetime_format.get(), ) def custom_excepthook(exc_type, exc_value, exc_traceback): "Custom exception hook" logger.critical( "Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback) ) return sys.__excepthook__(exc_type, exc_value, exc_traceback) sys.excepthook = custom_excepthook # === Use faulthandler for other exceptions ------------------------------------ fh_log_fname = conf.faulthandler_log_path.get() conf.faulthandler_log_available.set(initialize_log_file(fh_log_fname)) with open(fh_log_fname, "w", encoding="utf-8") as fh_log_fn: if enable_logs and conf.faulthandler_enabled.get(): faulthandler.enable(file=fh_log_fn) exception_occured = False try: yield QAPP_INSTANCE except Exception: # pylint: disable=broad-except exception_occured = True finally: if ( execenv.unattended or execenv.screenshot ) and not execenv.do_not_quit: # pragma: no cover if execenv.delay > 0: mode = "Screenshot" if execenv.screenshot else "Unattended" message = f"{mode} mode (delay: {execenv.delay}ms)" msec = execenv.delay - 200 for widget in QW.QApplication.instance().topLevelWidgets(): if isinstance(widget, QW.QMainWindow): widget.statusBar().showMessage(message, msec) QC.QTimer.singleShot( execenv.delay, lambda: close_widgets_and_quit(screenshot=execenv.screenshot), ) if exec_loop and not exception_occured: QAPP_INSTANCE.exec() if exception_occured: raise # pylint: disable=misplaced-bare-raise if enable_logs and conf.faulthandler_enabled.get(): faulthandler.disable() remove_empty_log_file(fh_log_fname) if enable_logs: logging.shutdown() remove_empty_log_file(tb_log_fname) # Used internally by qt_try_loadsave_file and qt_handle_error_message def is_running_tests() -> bool: """Check if code is running during test execution""" return "pytest" in sys.modules # NOT used in SigimaX — kept for derived apps (e.g. plugin error handling) @contextmanager def try_or_log_error(context: str) -> Generator[None, None, None]: """Try to execute a function and log an error message if it fails""" try: yield except Exception: # pylint: disable=broad-except if is_running_tests(): # If we are running tests, we want to raise the exception raise traceback.print_exc() logger = logging.getLogger(__name__) logger.error("Error in %s", context, exc_info=traceback.format_exc()) get_conf().traceback_log_available.set(True) finally: pass # NOT used in SigimaX — kept for derived apps (progress dialog utility) @contextmanager def create_progress_bar( parent: QW.QWidget, label: str, max_: int, show_after: int = 1000 ) -> Generator[QW.QProgressDialog, None, None]: """Create modal progress bar Args: parent: Parent widget label: Progress dialog title max_: Maximum progress value show_after: Delay before showing the progress dialog (ms, default: 1000) """ prog = QW.QProgressDialog(label, _("Cancel"), 0, max_, parent, QC.Qt.SplashScreen) prog.setWindowModality(QC.Qt.WindowModal) prog.setMinimumDuration(show_after) try: yield prog finally: prog.close() prog.deleteLater() # NOT used in SigimaX — kept for derived apps (threaded computation worker) class CallbackWorker(QC.QThread): """Worker for executing long operations in a separate thread. Implements `CallbackWorkerProtocol` from `sigima.worker`, used for computations that support cancellation and progress reporting. Args: callback: The function to be executed in a separate thread, that takes optionnally 'worker' as argument (instance of this class), and any other argument passed with **kwargs kwargs: Callback keyword arguments """ SIG_PROGRESS_UPDATE = QC.Signal(int) def __init__(self, callback: Callable, **kwargs) -> None: super().__init__() self.callback = callback if "worker" in inspect.signature(callback).parameters: kwargs["worker"] = self self.kwargs = kwargs self.result: Any | None = None self.__canceled = False self.__exc = None def run(self) -> None: """Start thread""" # Initialize progress bar: setting progress to 0.0 has the effect of # showing the progress dialog after the `minimumDuration` time has elapsed. # If we don't set the progress to 0.0, the progress dialog will be shown only # after the first call to `set_progress` method even if the `minimumDuration` # time has elapsed. self.set_progress(0.0) try: self.result = self.callback(**self.kwargs) except Exception as exc: # pylint: disable=broad-except self.__exc = exc def cancel(self) -> None: """Progress bar was canceled""" self.__canceled = True def was_canceled(self) -> bool: """Return whether the progress dialog was canceled by user""" return self.__canceled def set_progress(self, value: float) -> None: """Set progress bar value Args: value: float between 0.0 and 1.0 """ self.SIG_PROGRESS_UPDATE.emit(int(100 * value)) def get_result(self) -> Any: """Return callback result""" if self.__exc is not None: raise self.__exc return self.result # NOT used in SigimaX — kept for derived apps (long callback with progress) def qt_long_callback( parent: QW.QWidget, label: str, worker: CallbackWorker, progress: bool, show_after: int = 500, ) -> Any: """Handle long callbacks: run in a separate thread while showing a busy bar Args: parent: Parent widget label: Progress dialog title worker: Callback worker handling the function execution in a separate thread progress: Whether the progress feature is handled or not. If True, a progress bar and a 'Cancel' button are shown on the progress dialog. The progress value is updated by the `worker.set_progress` method (which takes a float between 0.0 and 1.0). Moreover, if `progress` is True, we wait for the callback function to return (it means that the callback function must implement a mechanism to return an intermediate result or `None` if the `worker.was_canceled` method returns True). show_after: Delay before showing the progress dialog (ms, default: 1000) Returns: Callback result """ if progress: prog = QW.QProgressDialog( label, _("Cancel"), 0, 100, parent, QC.Qt.SplashScreen ) prog.setMinimumDuration(show_after) worker.SIG_PROGRESS_UPDATE.connect(prog.setValue) prog.canceled.connect(worker.cancel) else: prog = QW.QProgressDialog(label, None, 0, 0, parent, QC.Qt.SplashScreen) prog.setMinimumDuration(0) prog.setCancelButton(None) prog.setRange(0, 0) prog.show() prog.setWindowModality(QC.Qt.WindowModal) worker.start() while worker.isRunning() and not worker.was_canceled(): QW.QApplication.processEvents() time.sleep(0.005) if progress: worker.SIG_PROGRESS_UPDATE.disconnect(prog.setValue) worker.wait() try: result = worker.get_result() except Exception as exc: # pylint: disable=broad-except prog.close() prog.deleteLater() raise exc prog.close() prog.deleteLater() return result # Used in SigimaX: mainwindow.py, widgets/h5browser.py def qt_handle_error_message(widget: QW.QWidget, message: str, context: str = None): """Handles application (QWidget) error message""" traceback.print_exc() txt = str(message) msglines = txt.splitlines() firstline = _("Error:") if context is None else f"%s: {context}" % _("Context") msglines.insert(0, firstline) if len(msglines) > 10: msglines = msglines[:10] + ["..."] title = widget.window().objectName() QW.QMessageBox.critical(widget, title, os.linesep.join(msglines)) # Used in SigimaX: mainwindow.py (HDF5 load/save) @contextmanager def qt_try_loadsave_file( parent: QW.QWidget, filename: str, operation: str ) -> Generator[str, None, None]: """Try and open file (operation: "load" or "save")""" if operation not in ("load", "save"): raise ValueError("operation argument must be 'load' or 'save'") try: yield filename except Exception as msg: # pylint: disable=broad-except if is_running_tests(): # If we are running tests, we want to raise the exception raise traceback.print_exc() url = osp.dirname(filename).replace("\\", "/") if operation == "load": text = _("The file %s could not be read:") else: text = _("The file %s could not be written:") in_folder = _("in this folder") message = text % ( f"{osp.basename(filename)}" f" ({in_folder})" ) QW.QMessageBox.critical( parent, get_conf().app_name.get(), f"{message}

{str(msg)}" ) finally: pass # Used in SigimaX: mainwindow.py (screenshot capture) def grab_save_window( widget: QW.QWidget, name: str | None = None, add_timestamp: bool = True ) -> None: # pragma: no cover """Grab window screenshot and save it. Delegates to guidata's ``grab_save_window``, using ``execenv.screenshot_path`` as the save directory (falls back to the current working directory if not set). The screenshot path can be configured by derived apps:: # Programmatically (e.g. in tests/__init__.py or app startup) execenv.screenshot_path = "/path/to/screenshots" # Or via environment variable os.environ["GUIDATA_SCREENSHOT_PATH"] = "/path/to/screenshots" # Or via CLI argument (parsed by SGMXExecEnv) # --screenshot_path /path/to/screenshots Args: widget: Widget to grab name: Screenshot name (if None, uses widget.objectName()) add_timestamp: Whether to add a timestamp to the screenshot name """ guidata_grab_save_window( widget=widget, name=name, save_dir=execenv.screenshot_path or None, add_timestamp=add_timestamp, ) # Used in SigimaX: mainwindow.py (file dialogs) @contextmanager def save_restore_stds() -> Generator[None, None, None]: """Save/restore standard I/O before/after doing some things (e.g. calling Qt open/save dialogs)""" saved_in, saved_out, saved_err = sys.stdin, sys.stdout, sys.stderr sys.stdout = None try: yield finally: sys.stdin, sys.stdout, sys.stderr = saved_in, saved_out, saved_err # Used in SigimaX: widgets/h5browser.py, widgets/signalcursor.py @contextmanager def block_signals( widget: QW.QWidget, enable: bool = True, children: bool = False ) -> Generator[None, None, None]: """Eventually block/unblock widget Qt signals before/after doing some things Args: widget: Widget to block/unblock signals enable: Whether to block/unblock signals (default: True). This is useful to avoid blocking signals when not needed without having to handle it by adding an `if` statement which would require to duplicate the code that is inside the `with` statement in the `else` branch. children: Whether to block/unblock signals for child widgets (default: False). Returns: Context manager """ if enable: widget.blockSignals(True) if children: for child in widget.findChildren(QW.QWidget): child.blockSignals(True) try: yield finally: if enable: widget.blockSignals(False) if children: for child in widget.findChildren(QW.QWidget): child.blockSignals(False) # Used in SigimaX: mainwindow.py (window management) def bring_to_front(window: QW.QWidget) -> None: """Bring window to front Args: window: Window to bring to front """ # Show window on top of others eflags = window.windowFlags() window.setWindowFlags(eflags | QC.Qt.WindowStaysOnTopHint) window.show() window.setWindowFlags(eflags) window.show() # If window is minimized, restore it if window.isMinimized(): window.showNormal() # Used in SigimaX: mainwindow.py (file/view menus) def configure_menu_about_to_show(menu: QW.QMenu, slot: Callable) -> None: """Configure menu about to show. This method is only used to connect the "aboutToShow" signal of menus, and more importantly to fix Issue #15 (Part 2) which is the fact that dynamic menus are not supported on MacOS unless an action is added to the menu before it is displayed. Args: menu: menu slot: slot """ # On MacOS, add an empty action to the menu before connecting the # "aboutToShow" signal to the slot. This is required to fix Issue #15 (Part 2) if sys.platform == "darwin": menu.addAction(QW.QAction(menu)) menu.aboutToShow.connect(slot) # Used in SigimaX: widgets/signalpeak, signaldeltax, signalcursor, # signalbaseline, imagebackground def resize_widget_to_parent( widget: QW.QWidget, parent: QW.QWidget | None = None, ratio: float = 0.95, aspect_ratio: float = 1.0, min_size: int = 500, ) -> None: """Resize widget based on parent widget's dimensions Args: widget: Widget to resize parent: Parent widget (if None, uses widget.parentWidget()) ratio: Ratio of parent size to use (0.0 to 1.0, default: 0.95 for 95%). This represents the percentage of the maximum dimension with respect to the widget. aspect_ratio: Width/height ratio (1.0 for square, >1.0 for landscape, <1.0 for portrait, default: 1.0) min_size: Minimum size in pixels (default: 500) """ if parent is None: parent = widget.parentWidget() if parent is not None: parent_size = parent.size() parent_width = parent_size.width() parent_height = parent_size.height() # Calculate maximum available dimensions max_width = parent_width * ratio max_height = parent_height * ratio # Determine which dimension is limiting based on aspect ratio # For aspect_ratio = w/h, we have: w = aspect_ratio * h # Check which constraint is more restrictive width_from_height = max_height * aspect_ratio height_from_width = max_width / aspect_ratio if width_from_height <= max_width: # Height is the limiting factor height = int(max_height) width = int(width_from_height) else: # Width is the limiting factor width = int(max_width) height = int(height_from_width) # Ensure minimum size while preserving aspect ratio if width < min_size or height < min_size: if aspect_ratio >= 1.0: # Landscape or square: scale up from minimum width width = max(width, min_size) height = max(height, int(min_size / aspect_ratio)) else: # Portrait: scale up from minimum height height = max(height, min_size) width = max(width, int(min_size * aspect_ratio)) # Final check: ensure we don't exceed parent dimensions width = min(width, parent_width) height = min(height, parent_height) widget.resize(width, height) else: # Fallback: use square with min_size if no parent widget.resize(min_size, min_size) # Used in SigimaX: mainwindow.py (tab widget corner menu) def add_corner_menu( tabwidget: QW.QTabWidget, corner: QC.Qt.Corner | None = None ) -> QW.QMenu: """Add menu as corner widget to tab widget Args: tabwidget: Tab widget corner: Corner Returns: Menu """ if corner is None: corner = QC.Qt.TopRightCorner menu = QW.QMenu(tabwidget) btn = QW.QToolButton(tabwidget) btn.setMenu(menu) btn.setPopupMode(QW.QToolButton.InstantPopup) btn.setIcon(get_icon("menu.svg")) btn.setToolTip(_("Open tab menu")) tabwidget.setCornerWidget(btn, corner) return menu DataLab-Platform-SigimaX-0182bf4/sigimax/utils/svg_icons.py000066400000000000000000000031741524425144300236310ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """SVG icon constants and helper for remote-control widgets.""" from __future__ import annotations from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtSvg as QS __all__ = ["GROUP", "IMAGE", "SIGNAL", "svgtext_to_icon"] SIGNAL = """ """ IMAGE = """ """ GROUP = """ """ def svgtext_to_icon(text: str) -> QG.QIcon: """Convert SVG text to a QIcon. Args: text: SVG text Returns: Icon rendered from the SVG text """ svg_bytes = QC.QByteArray(text.encode("utf-8")) renderer = QS.QSvgRenderer(svg_bytes) # pylint: disable=no-member pixmap = QG.QPixmap(64, 64) pixmap.fill(QC.Qt.transparent) painter = QG.QPainter(pixmap) renderer.render(painter) painter.end() return QG.QIcon(pixmap) DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/000077500000000000000000000000001524425144300215665ustar00rootroot00000000000000DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/__init__.py000066400000000000000000000037101524425144300237000ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX widgets =============== Reusable Qt widgets for SigimaX-derived applications. Convenience imports ------------------- The most commonly used widgets are re-exported here for easy access:: from sigimax.widgets import H5Browser, Wizard, LogViewerWindow Specialized scientific dialogs (fit, peak detection, baseline, etc.) remain accessible via their submodule:: from sigimax.widgets.fitdialog import gaussian_fit from sigimax.widgets.signalpeak import SignalPeakDetectionDialog Submodules ---------- .. autosummary:: plotdock connection filedialog fileviewer fitdialog h5browser imagebackground logviewer objectdialog signalbaseline signalcursor signaldeltax signalpeak splashscreen status warningerror wizard """ from sigimax.widgets.connection import ConnectionDialog from sigimax.widgets.h5browser import H5Browser, H5BrowserDialog from sigimax.widgets.logviewer import LogViewerWindow from sigimax.widgets.objectdialog import GetObjectDialog, SimpleObjectTree from sigimax.widgets.plotdock import ( CurveStatsToolFunctions, DockablePlotWidget, SigimaXPlotWidget, ) from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig from sigimax.widgets.status import BaseStatus, ConsoleStatus, MemoryStatus from sigimax.widgets.warningerror import WarningErrorMessageBox, show_warning_error from sigimax.widgets.wizard import Wizard, WizardPage __all__ = [ "BaseStatus", "ConnectionDialog", "ConsoleStatus", "CurveStatsToolFunctions", "DockablePlotWidget", "GetObjectDialog", "H5Browser", "H5BrowserDialog", "LogViewerWindow", "MemoryStatus", "SigimaXPlotWidget", "SigimaXSplashScreen", "SimpleObjectTree", "SplashScreenConfig", "WarningErrorMessageBox", "Wizard", "WizardPage", "show_warning_error", ] DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/connection.py000066400000000000000000000115471524425144300243070ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Connection dialog ------------------ This module provides a dialog box showing the progress of a connection to a DataLab-derived application server, to be used together with :class:`sigima.client.SimpleRemoteProxy` (or any remote proxy exposing a similarly blocking ``connect`` method). .. autoclass:: ConnectionDialog :members: """ from __future__ import annotations from collections.abc import Callable from guidata.qthelpers import get_std_icon, win32_fix_title_bar_background from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from sigimax.config import _ __all__ = ["ConnectionDialog"] class ConnectionThread(QC.QThread): """Connection thread, running the blocking connect callback in background. Args: connect_callback: Callback function to connect to the remote server parent: Parent object. Defaults to None. """ SIG_CONNECTION_OK = QC.Signal() SIG_CONNECTION_KO = QC.Signal(str) def __init__(self, connect_callback: Callable, parent: QC.QObject = None) -> None: super().__init__(parent) self.connect_callback = connect_callback def run(self) -> None: """Run thread""" try: self.connect_callback() self.SIG_CONNECTION_OK.emit() except ConnectionRefusedError as exc: self.SIG_CONNECTION_KO.emit(str(exc)) class ConnectionDialog(QW.QDialog): """Connection dialog, showing the progress of a connection attempt. Args: connect_callback: Callback function to connect to the remote server parent: Parent widget. Defaults to None. icon: Window icon. Defaults to None (no icon override). banner: Optional banner pixmap displayed above the progress bar. """ def __init__( self, connect_callback: Callable, parent: QW.QWidget = None, icon: QG.QIcon | None = None, banner: QG.QPixmap | None = None, ) -> None: super().__init__(parent) win32_fix_title_bar_background(self) self.setWindowTitle(_("Connection")) if icon is not None: self.setWindowIcon(icon) self.resize(300, 50) self.__error_message = "" layout = QW.QVBoxLayout() self.setLayout(layout) if banner is not None: banner_label = QW.QLabel() banner_label.setPixmap(banner) banner_label.setAlignment(QC.Qt.AlignCenter) layout.addWidget(banner_label) self.progress_bar = QW.QProgressBar() self.progress_bar.setRange(0, 0) status = QW.QWidget() status_layout = QW.QHBoxLayout() status.setLayout(status_layout) self.status_label = QW.QLabel(_("Waiting for connection...")) self.status_icon = QW.QLabel() status_layout.addWidget(self.status_icon) status_layout.addWidget(self.status_label) status_layout.setContentsMargins(0, 0, 0, 0) status_layout.addStretch() layout.addWidget(self.progress_bar) layout.addWidget(status) self.thread = ConnectionThread(connect_callback) self.thread.SIG_CONNECTION_OK.connect(self.__on_connection_successful) self.thread.SIG_CONNECTION_KO.connect(self.__on_connection_failed) button_box = QW.QDialogButtonBox(QW.QDialogButtonBox.Cancel) button_box.rejected.connect(self.reject) layout.addWidget(button_box) def __set_status_icon(self, name: str) -> None: """Set status icon with standard Qt icon name""" self.status_icon.setPixmap(QG.QPixmap(get_std_icon(name).pixmap(24))) def __connect_to_server(self) -> None: """Connect to server""" self.progress_bar.setRange(0, 0) self.__set_status_icon("BrowserReload") self.status_label.setText(_("Connecting to server...")) self.thread.start() def __on_connection_successful(self) -> None: """Connection successful""" self.progress_bar.setRange(0, 1) self.progress_bar.setValue(1) self.__set_status_icon("DialogApplyButton") self.status_label.setText(_("Connection successful!")) QC.QTimer.singleShot(1000, self.accept) def __on_connection_failed(self, error_message: str) -> None: """Connection failed""" self.__error_message = error_message self.progress_bar.setRange(0, 1) self.progress_bar.setValue(1) self.__set_status_icon("MessageBoxCritical") self.status_label.setText(_("Connection failed: %s") % error_message) QC.QTimer.singleShot(2000, self.reject) def get_error_message(self) -> str: """Get error message if connection failed""" return self.__error_message def exec(self) -> int: """Execute dialog""" self.__connect_to_server() return super().exec() DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/filedialog.py000066400000000000000000000061521524425144300242430ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Module providing a file dialog widget based on Qt's QFileDialog.getOpenFileNames but supporting multiple file preselection (Qt original dialog only supports single file selection). .. autofunction:: get_open_file_names """ from __future__ import annotations import os import os.path as osp from guidata.qthelpers import qt_app_context from qtpy.QtCore import QItemSelectionModel from qtpy.QtWidgets import QAbstractItemView, QFileDialog, QListView, QWidget __all__ = [ "get_open_file_names", ] def get_open_file_names( parent: QWidget | None = None, caption: str = "", basedir: str | list[str] = "", filters: str = "", selectedfilter: str = "", options: QFileDialog.Options = None, ) -> tuple[list[str], str]: """Wrapper around QtGui.QFileDialog.getOpenFileNames static method Returns a tuple (filenames, selectedfilter) -- when dialog box is canceled, returns a tuple (empty list, empty string) Args: parent: Parent widget for the dialog. caption: Dialog title. basedir: Initial directory to open the dialog in, or preselected files (single string or list of strings). filters: File filters for the dialog. selectedfilter: Default filter to be selected. options: Additional options for the dialog. Returns: A tuple containing a list of selected filenames and the selected filter. """ if isinstance(basedir, str): if osp.isfile(basedir): sel_files = [basedir] basedir = osp.dirname(basedir) else: sel_files = [] else: assert isinstance(basedir, list) sel_files = basedir basedir = osp.dirname(sel_files[0]) if sel_files else "" dlg = QFileDialog( parent, caption, basedir, filters, options=QFileDialog.DontUseNativeDialog ) if options is not None: dlg.setOptions(options | QFileDialog.DontUseNativeDialog) file_view = dlg.findChild(QListView, "listView") sel_model = file_view.selectionModel() for fname in sel_files: idx = sel_model.model().index(fname) sel_model.select(idx, QItemSelectionModel.Select | QItemSelectionModel.Rows) file_view.setSelectionMode(QAbstractItemView.ExtendedSelection) file_view.setSelectionBehavior(QAbstractItemView.SelectRows) if dlg.exec(): filenames = dlg.selectedFiles() selectedfilter = dlg.selectedNameFilter() else: filenames = [] selectedfilter = "" return filenames, selectedfilter def test_get_open_file_names(): """Test get_open_file_names function""" widgets_path = osp.dirname(__file__) sel_files = [ osp.join(widgets_path, fname) for fname in os.listdir(widgets_path)[:2] ] with qt_app_context(): filenames, selectedfilter = get_open_file_names( filters="Python files (*.py);;All files (*)", caption="Select Python files", basedir=sel_files, ) print(filenames, selectedfilter) if __name__ == "__main__": test_get_open_file_names() DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/fileviewer.py000066400000000000000000000046711524425144300243110ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Module providing a file viewer widget """ from __future__ import annotations from pathlib import Path from guidata.configtools import get_icon from guidata.widgets.codeeditor import CodeEditor from qtpy import QtWidgets as QW from sigimax.config import _, get_conf __all__ = [ "FileViewerWidget", "get_title_contents", "read_text_file", ] def read_text_file(path: str) -> str: """Read text file using multiple encodings Args: path (str): path to file Raises: UnicodeDecodeError: if unable to read file using any of the encodings Returns: str: file contents """ encodings = ["utf-8", "latin1", "cp1252", "utf-16", "utf-32", "ascii"] for encoding in encodings: try: with open(path, "r", encoding=encoding) as fdesc: return fdesc.read() except UnicodeDecodeError: pass raise UnicodeDecodeError( f"Unable to read file using the following encodings: {encodings}" ) def get_title_contents(path: str) -> tuple[str, str]: """Get title and contents for log filename Args: path (str): path to file Returns: tuple[str, str]: title and contents """ contents = read_text_file(path) pathobj = Path(path) uri_path = pathobj.absolute().as_uri() prefix = _("Contents of file") text = f'{prefix} {path}:' return text, contents class FileViewerWidget(QW.QWidget): """File viewer widget Args: parent (QW.QWidget | None): parent widget. Defaults to None. """ def __init__(self, language: str | None = None, parent: QW.QWidget = None) -> None: super().__init__(parent) if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) self.editor = CodeEditor(language=language) self.editor.setReadOnly(True) layout = QW.QVBoxLayout() self.label = QW.QLabel("") layout.addWidget(self.label) layout.addWidget(self.editor) self.setLayout(layout) def set_data(self, text: str, contents: str) -> None: """Set log data Args: text (str): text to display contents (str): contents to display """ self.label.setText(text) self.label.setOpenExternalLinks(True) self.editor.setPlainText(contents) DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/fitdialog.py000066400000000000000000000745601524425144300241160ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Curve fitting dialog widgets .. autofunction:: guifit .. autofunction:: linear_fit .. autofunction:: polynomial_fit .. autofunction:: gaussian_fit .. autofunction:: lorentzian_fit .. autofunction:: voigt_fit .. autofunction:: multigaussian_fit .. autofunction:: multilorentzian_fit .. autofunction:: exponential_fit .. autofunction:: sinusoidal_fit .. autofunction:: cdf_fit .. autofunction:: planckian_fit .. autofunction:: twohalfgaussian_fit .. autofunction:: piecewiseexponential_fit """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import numpy as np from guidata.configtools import get_icon from guidata.qthelpers import exec_dialog from plotpy.plot import PlotOptions from plotpy.widgets.fit import FitDialog, FitParam from scipy.special import erf # pylint: disable=no-name-in-module from sigima.tools.checks import check_1d_arrays from sigima.tools.signal import fitting, fourier, pulse from sigimax.config import _, get_conf __all__ = [ "cdf_fit", "exponential_fit", "gaussian_fit", "guifit", "linear_fit", "lorentzian_fit", "multigaussian_fit", "multilorentzian_fit", "piecewiseexponential_fit", "planckian_fit", "polynomial_fit", "sinusoidal_fit", "twohalfgaussian_fit", "voigt_fit", ] DEFAULT_FORMAT = "%g" def create_interactive_fit_params(fit_type, values, y, y_fitted): """Create canonical metadata for a fit committed from a dialog.""" residual_rms = np.sqrt(np.mean((y - y_fitted) ** 2)) return fitting.create_fit_params( fit_type, values, residual_rms=residual_rms, interactive=True ) def guifit( x, y, fitfunc, fitparams, fitargs=None, fitkwargs=None, wintitle=None, title=None, xlabel=None, ylabel=None, param_cols=1, auto_fit=True, winsize=None, winpos=None, parent=None, name=None, ): # pylint: disable=too-many-positional-arguments """GUI-based curve fitting tool""" win = FitDialog( edit=True, title=wintitle, icon=None, toolbar=True, options=PlotOptions( title=title, xlabel=xlabel, ylabel=ylabel, curve_antialiasing=True, show_axes_tab=False, autoscale_margin_percent=get_conf().sig_autoscale_margin_percent.get(), ), parent=parent, param_cols=param_cols, auto_fit=auto_fit, ) win.setObjectName(name) win.set_data(x, y, fitfunc, fitparams, fitargs, fitkwargs) try: win.autofit() # TODO: [P3] make this optional except ValueError: pass if parent is None: win.setWindowIcon(get_icon(get_conf().app_logo_path.get())) if winsize is not None: win.resize(*winsize) if winpos is not None: win.move(*winpos) win.get_plot().do_autoscale() if exec_dialog(win): return win.get_values() return None # --- Polynomial fitting curve ------------------------------------------------- def polynomial_fit(x, y, degree, parent=None, name=None, fit_type="polynomial"): """Compute polynomial fit Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary""" computer = fitting.PolynomialFitComputer(x, y, degree) ivals = np.polyfit(x, y, degree) params = [] for index in range(degree + 1): val = ivals[index] vmax = max(1.0, np.abs(val)) param = FitParam( f"c{(len(ivals) - index - 1):d}", val, -2 * vmax, 2 * vmax, format=DEFAULT_FORMAT, ) params.append(param) def fitfunc(x, params): return np.polyval(params, x) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Polymomial fit"), name=name ) if values: y_fitted = fitfunc(x, values) # Both `np.polyfit` and `PolynomialFitComputer` order coefficients from # the highest degree to the lowest, so a plain zip is correct here. fit_values = dict(zip(computer.get_params_names(), values)) fit_params = create_interactive_fit_params(fit_type, fit_values, y, y_fitted) return y_fitted, params, fit_params def linear_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None): """Compute linear fit using polynomialfit. Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary """ # A first-degree polynomial and a linear fit share the same `(a, b)` parameter # names, so only the stored fit type has to be overridden. return polynomial_fit(x, y, 1, parent=parent, name=name, fit_type="linear") # --- Gaussian fitting curve --------------------------------------------------- def gaussian_fit(x, y, parent=None, name=None): """Compute Gaussian fit Returns (yfit, params), where yfit is the fitted curve and params are the fitting parameters""" # Get initial parameter estimates from Sigima GaussianFitComputer computer = fitting.GaussianFitComputer(x, y) initial_params = computer.compute_initial_params() amplitude_guess = initial_params["amplitude"] sigma_guess = initial_params["sigma"] mu_guess = initial_params["x0"] b_guess = initial_params["y0"] dy = np.max(y) - np.min(y) max_amplitude = max(2.0 * dy, 2.0 * abs(amplitude_guess)) amplitude = FitParam( _("Amplitude"), amplitude_guess, -max_amplitude, max_amplitude, format=DEFAULT_FORMAT, ) b = FitParam( _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT ) sigma = FitParam( _("Std-dev") + " (σ)", sigma_guess, sigma_guess * 0.1, sigma_guess * 10, format=DEFAULT_FORMAT, ) mu = FitParam( _("Mean") + " (μ)", mu_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT ) params = [amplitude, sigma, mu, b] def fitfunc(x, params): return pulse.GaussianModel.evaluate(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Gaussian fit"), name=name ) if values: y_fitted = fitfunc(x, values) fit_params = create_interactive_fit_params( "gaussian", dict(zip(computer.get_params_names(), values)), y, y_fitted ) return y_fitted, params, fit_params # --- Lorentzian fitting curve ------------------------------------------------- def lorentzian_fit(x, y, parent=None, name=None): """Compute Lorentzian fit Returns (yfit, params), where yfit is the fitted curve and params are the fitting parameters""" # Get initial parameter estimates from Sigima LorentzianFitComputer computer = fitting.LorentzianFitComputer(x, y) initial_params = computer.compute_initial_params() amplitude_guess = initial_params["amplitude"] sigma_guess = initial_params["sigma"] mu_guess = initial_params["x0"] b_guess = initial_params["y0"] # Create parameter bounds dy = np.max(y) - np.min(y) max_amplitude = max(2.0 * dy, 2.0 * abs(amplitude_guess)) amplitude = FitParam( _("Amplitude"), amplitude_guess, -max_amplitude, max_amplitude, format=DEFAULT_FORMAT, ) b = FitParam( _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT ) sigma = FitParam( _("Std-dev") + " (σ)", sigma_guess, sigma_guess * 0.1, sigma_guess * 10, format=DEFAULT_FORMAT, ) mu = FitParam( _("Mean") + " (μ)", mu_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT ) params = [amplitude, sigma, mu, b] def fitfunc(x, params): return pulse.LorentzianModel.evaluate(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Lorentzian fit"), name=name ) if values: y_fitted = fitfunc(x, values) fit_params = create_interactive_fit_params( "lorentzian", dict(zip(computer.get_params_names(), values)), y, y_fitted ) return y_fitted, params, fit_params # --- Voigt fitting curve ------------------------------------------------------ def voigt_fit(x, y, parent=None, name=None): """Compute Voigt fit Returns (yfit, params), where yfit is the fitted curve and params are the fitting parameters""" # Get initial parameter estimates from Sigima VoigtFitComputer computer = fitting.VoigtFitComputer(x, y) initial_params = computer.compute_initial_params() amplitude_guess = initial_params["amplitude"] sigma_guess = initial_params["sigma"] mu_guess = initial_params["x0"] b_guess = initial_params["y0"] # Create parameter bounds dy = np.max(y) - np.min(y) max_amplitude = max(2.0 * dy, 2.0 * abs(amplitude_guess)) amplitude = FitParam( _("Amplitude"), amplitude_guess, -max_amplitude, max_amplitude, format=DEFAULT_FORMAT, ) b = FitParam( _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT ) sigma = FitParam( _("Std-dev") + " (σ)", sigma_guess, sigma_guess * 0.1, sigma_guess * 10, format=DEFAULT_FORMAT, ) mu = FitParam( _("Mean") + " (μ)", mu_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT ) params = [amplitude, sigma, mu, b] def fitfunc(x, params): return pulse.VoigtModel.evaluate(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Voigt fit"), name=name ) if values: y_fitted = fitfunc(x, values) fit_params = create_interactive_fit_params( "voigt", dict(zip(computer.get_params_names(), values)), y, y_fitted ) return y_fitted, params, fit_params # --- Multi-Gaussian fitting curve --------------------------------------------- def multigaussian(x, *values, **kwargs): """Return a 1-dimensional multi-Gaussian function.""" amplitudes = values[0::2] a_sigma = values[1::2] y0 = values[-1] a_x0 = kwargs["a_x0"] y = np.zeros_like(x) + y0 for amplitude, sigma, x0 in zip(amplitudes, a_sigma, a_x0): y += pulse.GaussianModel.evaluate(x, amplitude, sigma, x0, 0.0) return y def multigaussian_fit(x, y, peak_indices, parent=None, name=None): """Compute Multi-Gaussian fit Returns (yfit, params), where yfit is the fitted curve and params are the fitting parameters""" # Get initial parameter estimates from Sigima MultiGaussianFitComputer computer = fitting.MultiGaussianFitComputer(x, y, peak_indices) initial_params = computer.compute_initial_params() # Use Sigima parameters to populate SigimaX params params = [] for index, i0 in enumerate(peak_indices): stri = f"{index + 1:02d}" amplitude_key = f"amplitude_{index + 1}" sigma_key = f"sigma_{index + 1}" amplitude_value = initial_params.get(amplitude_key, y[i0] - np.min(y)) sigma_val = ( initial_params[sigma_key] if sigma_key in initial_params else (x.max() - x.min()) / 100 ) # Calculate bounds based on local data istart = 0 iend = len(x) - 1 if index > 0: istart = (peak_indices[index - 1] + i0) // 2 if index < len(peak_indices) - 1: iend = (peak_indices[index + 1] + i0) // 2 dx = 0.5 * (x[iend] - x[istart]) dy = np.max(y[istart:iend]) - np.min(y[istart:iend]) amplitude_range = max(dy * 2, abs(amplitude_value) * 2) params += [ FitParam( ("A") + stri, amplitude_value, -amplitude_range, amplitude_range, format=DEFAULT_FORMAT, ), FitParam("σ" + stri, sigma_val, dx / 100, dx, format=DEFAULT_FORMAT), ] y0_val = initial_params.get("y0", np.min(y)) params.append( FitParam( _("Y0"), y0_val, np.min(y) - 0.1 * (np.max(y) - np.min(y)), np.max(y), format=DEFAULT_FORMAT, ) ) kwargs = {"a_x0": x[peak_indices]} def fitfunc(xi, params): return multigaussian(xi, *params, **kwargs) param_cols = 1 if len(params) > 8: param_cols = 4 values = guifit( x, y, fitfunc, params, param_cols=param_cols, winsize=(900, 600), parent=parent, name=name, wintitle=_("Multi-Gaussian fit"), ) if values: y_fitted = fitfunc(x, values) fit_values = {"y0": values[-1]} for index, (amplitude, sigma, x0) in enumerate( zip(values[0::2], values[1::2], kwargs["a_x0"]), start=1 ): fit_values[f"amplitude_{index}"] = amplitude fit_values[f"sigma_{index}"] = abs(sigma) fit_values[f"x0_{index}"] = x0 fit_params = create_interactive_fit_params( "multigaussian", fit_values, y, y_fitted ) return y_fitted, params, fit_params # --- Multi-Lorentzian fitting curve ------------------------------------------- def multilorentzian(x, *values, **kwargs): """Return a 1-dimensional multi-Lorentzian function.""" amplitudes = values[0::2] a_sigma = values[1::2] y0 = values[-1] a_x0 = kwargs["a_x0"] y = np.zeros_like(x) + y0 for amplitude, sigma, x0 in zip(amplitudes, a_sigma, a_x0): y += pulse.LorentzianModel.evaluate(x, amplitude, sigma, x0, 0.0) return y def multilorentzian_fit( x: np.ndarray, y: np.ndarray, peak_indices, parent=None, name=None ): """Compute Multi-Lorentzian fit Returns (yfit, params), where yfit is the fitted curve and params are the fitting parameters""" # Get initial parameter estimates from Sigima MultiLorentzianFitComputer computer = fitting.MultiLorentzianFitComputer(x, y, peak_indices) initial_params = computer.compute_initial_params() # Use Sigima parameters to populate SigimaX params params = [] dy = np.max(y) - np.min(y) for index, i0 in enumerate(peak_indices): stri = f"{index + 1:02d}" amplitude_key = f"amplitude_{index + 1}" sigma_key = f"sigma_{index + 1}" amplitude_value = initial_params.get(amplitude_key, y[i0] - np.min(y)) sigma_val = ( initial_params[sigma_key] if sigma_key in initial_params else (x.max() - x.min()) / 100 ) params += [ FitParam( ("A") + stri, amplitude_value, -max(abs(amplitude_value) * 2, dy * 2), max(abs(amplitude_value) * 2, dy * 2), format=DEFAULT_FORMAT, ), FitParam( "σ" + stri, sigma_val, sigma_val * 0.2, sigma_val * 10, format=DEFAULT_FORMAT, ), ] y0_val = initial_params.get("y0", np.min(y)) params.append( FitParam( _("Y0"), y0_val, np.min(y) - 0.1 * (np.max(y) - np.min(y)), np.max(y), format=DEFAULT_FORMAT, ) ) kwargs = {"a_x0": x[peak_indices]} def fitfunc(xi, params): return multilorentzian(xi, *params, **kwargs) param_cols = 1 if len(params) > 8: param_cols = 4 values = guifit( x, y, fitfunc, params, param_cols=param_cols, winsize=(900, 600), parent=parent, name=name, wintitle=_("Multi-Lorentzian fit"), ) if values: y_fitted = fitfunc(x, values) fit_values = {"y0": values[-1]} for index, (amplitude, sigma, x0) in enumerate( zip(values[0::2], values[1::2], kwargs["a_x0"]), start=1 ): fit_values[f"amplitude_{index}"] = amplitude fit_values[f"sigma_{index}"] = abs(sigma) fit_values[f"x0_{index}"] = x0 fit_params = create_interactive_fit_params( "multilorentzian", fit_values, y, y_fitted ) return y_fitted, params, fit_params # --- Exponential fitting curve ------------------------------------------------ def exponential_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None): """Compute exponential fit Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary""" # Get initial parameter estimates from Sigima ExponentialFitComputer computer = fitting.ExponentialFitComputer(x, y) initial_params = computer.compute_initial_params() oa = initial_params["a"] ob = initial_params["b"] oc = initial_params["y0"] # Create parameter bounds moa, mob, moc = np.maximum(1, [abs(oa), abs(ob), abs(oc)]) a_p = FitParam( _("A coefficient"), oa, -2 * moa, 2 * moa, logscale=True, format=DEFAULT_FORMAT ) # B must be free to change sign: a positive-only range makes every decaying # exponential unreachable. Sigima uses (-10, 10) for the same parameter. mob = max(10.0, 2 * mob) b_p = FitParam(_("B coefficient"), ob, -mob, mob, format=DEFAULT_FORMAT) c_p = FitParam(_("y0 constant"), oc, -2 * moc, 2 * moc, format=DEFAULT_FORMAT) params = [a_p, b_p, c_p] def modelfunc(x, a, b, c): return a * np.exp(b * x) + c def fitfunc(x, params): return modelfunc(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Exponential fit"), name=name ) if values: y_fitted = fitfunc(x, values) fit_params = create_interactive_fit_params( "exponential", dict(zip(computer.get_params_names(), values)), y, y_fitted ) return y_fitted, params, fit_params # --- Sinusoidal fitting curve ------------------------------------------------ @check_1d_arrays(x_evenly_spaced=True) def dominant_frequency(x: np.ndarray, y: np.ndarray) -> np.floating: """Find the dominant frequency. Args: x: 1-D x values. y: 1-D y values. Returns: Dominant frequency. """ f, spectrum = fourier.magnitude_spectrum(x, y) return np.abs(f[np.argmax(spectrum)]) def sinusoidal_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None): """Compute sinusoidal fit Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary""" # Get initial parameter estimates from Sigima SinusoidalFitComputer computer = fitting.SinusoidalFitComputer(x, y) initial_params = computer.compute_initial_params() guess_a = initial_params["amplitude"] guess_f = initial_params["frequency"] guess_ph = np.rad2deg(initial_params["phase"]) # Convert to degrees guess_c = initial_params["offset"] # Create parameter bounds abs_values = [abs(guess_a), abs(guess_f), abs(guess_ph), abs(guess_c)] moa, mof, _mop, moc = np.maximum(1, abs_values) a_p = FitParam(_("Amplitude"), guess_a, -2 * moa, 2 * moa, format=DEFAULT_FORMAT) f_p = FitParam(_("Frequency"), guess_f, 0, 2 * mof, format=DEFAULT_FORMAT) p_p = FitParam(_("Phase"), guess_ph, -360, 360, format=DEFAULT_FORMAT) c_p = FitParam( _("Continuous component"), guess_c, -2 * moc, 2 * moc, format=DEFAULT_FORMAT ) params = [a_p, f_p, p_p, c_p] def modelfunc(x, a, f, p, c): return a * np.sin(2 * np.pi * f * x + np.deg2rad(p)) + c def fitfunc(x, params): return modelfunc(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Sinusoidal fit"), name=name ) if values: y_fitted = fitfunc(x, values) # The phase is edited in degrees but stored in radians, as expected by # Sigima's sinusoidal model. amplitude, frequency, phase, offset = values fit_values = dict( zip( computer.get_params_names(), (amplitude, frequency, np.deg2rad(phase), offset), ) ) fit_params = create_interactive_fit_params( "sinusoidal", fit_values, y, y_fitted ) return y_fitted, params, fit_params # --- Cumulative distribution function fitting curve ----------------------------------- def cdf_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None): """Compute Cumulative Distribution Function (CDF) fit Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary""" # Get initial parameter estimates from Sigima CDFFitComputer computer = fitting.CDFFitComputer(x, y) initial_params = computer.compute_initial_params() a_guess = initial_params["amplitude"] mu_guess = initial_params["mu"] sigma_guess = initial_params["sigma"] b_guess = initial_params["baseline"] # Create parameter bounds dy = np.max(y) - np.min(y) x_min, x_max = float(np.min(x)), float(np.max(x)) dx = x_max - x_min iamp = max(1.0, abs(a_guess)) # Amplitude must be free to change sign, otherwise a descending transition # cannot be fitted at all. a = FitParam( _("Amplitude"), a_guess, -iamp * 2.0, iamp * 2.0, format=DEFAULT_FORMAT ) b = FitParam( _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT ) # Bound sigma and mu to the abscissa range rather than to the magnitude of # the initial guess, which excluded negative and near-zero means. sigma = FitParam( _("Std-dev") + " (σ)", sigma_guess, dx * 0.001, dx, format=DEFAULT_FORMAT, ) mu = FitParam(_("Mean") + " (μ)", mu_guess, x_min, x_max, format=DEFAULT_FORMAT) params = [a, mu, sigma, b] def modelfunc(x, a, mu, sigma, b): return a * erf((x - mu) / (sigma * np.sqrt(2))) + b def fitfunc(x, params): return modelfunc(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("CDF fit"), name=name, ) if values: y_fitted = fitfunc(x, values) fit_params = create_interactive_fit_params( "cdf", dict(zip(computer.get_params_names(), values)), y, y_fitted ) return y_fitted, params, fit_params # --- Planckian fitting curve -------------------------------------------------- def planckian_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None): """Compute Planckian (blackbody radiation) fit Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary""" # Get initial parameter estimates from Sigima PlanckianFitComputer computer = fitting.PlanckianFitComputer(x, y) initial_params = computer.compute_initial_params() amp_guess = initial_params["amp"] x0_guess = initial_params["x0"] sigma_guess = initial_params["sigma"] y0_guess = initial_params["y0"] # Create parameter bounds dy = np.max(y) - np.min(y) # Parameter bounds with appropriate ranges for Planckian fitting amp = FitParam( _("Amplitude"), amp_guess, amp_guess * 0.01, amp_guess * 100, format=DEFAULT_FORMAT, ) x0 = FitParam( _("Scale factor"), x0_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT ) sigma = FitParam(_("Width factor"), sigma_guess, 0.1, 5.0, format=DEFAULT_FORMAT) y0 = FitParam( _("Base line"), y0_guess, y0_guess - 0.2 * dy, y0_guess + 0.2 * dy, format=DEFAULT_FORMAT, ) params = [amp, x0, sigma, y0] def fitfunc(x, params: list[float]) -> np.ndarray: """Evaluate Planckian function with given parameters.""" return fitting.PlanckianFitComputer.evaluate(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Planckian fit"), name=name ) if values: y_fitted = fitfunc(x, values) fit_params = create_interactive_fit_params( "planckian", dict(zip(computer.get_params_names(), values)), y, y_fitted ) return y_fitted, params, fit_params # --- Two half-Gaussian fitting curve ------------------------------------------ def twohalfgaussian_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None): """Compute two half-Gaussian fit for asymmetric peaks Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary""" # Get initial parameter estimates from Sigima TwoHalfGaussianFitComputer computer = fitting.TwoHalfGaussianFitComputer(x, y) initial_params = computer.compute_initial_params() amp_left_guess = initial_params["amp_left"] amp_right_guess = initial_params["amp_right"] sigma_left_guess = initial_params["sigma_left"] sigma_right_guess = initial_params["sigma_right"] x0_guess = initial_params["x0"] y0_left_guess = initial_params["y0_left"] y0_right_guess = initial_params["y0_right"] # Create parameter bounds dx = np.max(x) - np.min(x) dy = np.max(y) - np.min(y) # Parameter bounds with better ranges # New model signature: func(x, amp_left, amp_right, sigma_left, # sigma_right, x0, y0_left, y0_right) amp_left = FitParam( _("Left amplitude"), amp_left_guess, dy * 0.1, dy * 3, format=DEFAULT_FORMAT ) amp_right = FitParam( _("Right amplitude"), amp_right_guess, dy * 0.1, dy * 3, format=DEFAULT_FORMAT ) sigma_left = FitParam( _("Left width") + " (σL)", sigma_left_guess, dx * 0.001, # Very small minimum dx * 0.5, # Reasonable maximum format=DEFAULT_FORMAT, ) sigma_right = FitParam( _("Right width") + " (σR)", sigma_right_guess, dx * 0.001, # Very small minimum dx * 0.5, # Reasonable maximum format=DEFAULT_FORMAT, ) x0 = FitParam( _("Center") + " (x₀)", x0_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT ) y0_left = FitParam( _("Left baseline"), y0_left_guess, y0_left_guess - 0.2 * dy, y0_left_guess + 0.2 * dy, format=DEFAULT_FORMAT, ) y0_right = FitParam( _("Right baseline"), y0_right_guess, y0_right_guess - 0.2 * dy, y0_right_guess + 0.2 * dy, format=DEFAULT_FORMAT, ) params = [amp_left, amp_right, sigma_left, sigma_right, x0, y0_left, y0_right] def fitfunc(x, params): return fitting.TwoHalfGaussianFitComputer.evaluate(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Two half-Gaussian fit"), name=name, ) if values: y_fitted = fitfunc(x, values) fit_params = create_interactive_fit_params( "twohalfgaussian", dict(zip(computer.get_params_names(), values)), y, y_fitted, ) return y_fitted, params, fit_params # --- Piecewise exponential (raise-decay) fitting curve ------------------------ def piecewiseexponential_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None): """Compute piecewise exponential fit (raise-decay) Returns (yfit, params, fit_params), where yfit is the fitted curve, params are the fitting parameters and fit_params is the canonical metadata dictionary""" # Get initial parameter estimates from Sigima DoubleExponentialFitComputer computer = fitting.DoubleExponentialFitComputer(x, y) initial_params = computer.compute_initial_params() x_center_guess = initial_params["x_center"] a_left_guess = initial_params["a_left"] b_left_guess = initial_params["b_left"] a_right_guess = initial_params["a_right"] b_right_guess = initial_params["b_right"] y0_guess = initial_params["y0"] # Create parameter bounds x_min, x_max = float(x.min()), float(x.max()) y_min, y_max = float(y.min()), float(y.max()) y_range = y_max - y_min x_range = x_max - x_min # Parameter bounds with more realistic ranges # New model signature: func(x, x_center, a_left, b_left, a_right, b_right, y0) # Amplitudes are bounded symmetrically: a `(0, guess * 10)` range silently # inverts into an empty interval whenever the guess is negative. amp_bound = max(abs(a_left_guess), abs(a_right_guess), y_range) * 10.0 # Rates are bounded symmetrically too: forcing b_left > 0 and b_right < 0 # assumes a rise-then-decay shape and makes the opposite shape unreachable. rate_bound = 100.0 / x_range x_center = FitParam( _("Center position"), x_center_guess, x_min, x_max, format=DEFAULT_FORMAT ) a_left = FitParam( _("Left amplitude"), a_left_guess, -amp_bound, amp_bound, format=DEFAULT_FORMAT, ) b_left = FitParam( _("Left rate") + " (bL)", b_left_guess, # Already in coefficient form -rate_bound, rate_bound, format=DEFAULT_FORMAT, ) a_right = FitParam( _("Right amplitude"), a_right_guess, -amp_bound, amp_bound, format=DEFAULT_FORMAT, ) b_right = FitParam( _("Right rate") + " (bR)", b_right_guess, # Already in coefficient form -rate_bound, rate_bound, format=DEFAULT_FORMAT, ) y0 = FitParam( _("Base line"), y0_guess, y0_guess - 0.2 * y_range, y0_guess + 0.2 * y_range, format=DEFAULT_FORMAT, ) params = [x_center, a_left, b_left, a_right, b_right, y0] def fitfunc(x, params): return fitting.DoubleExponentialFitComputer.evaluate(x, *params) values = guifit( x, y, fitfunc, params, parent=parent, wintitle=_("Piecewise exponential (raise-decay) fit"), name=name, ) if values: y_fitted = fitfunc(x, values) # Sigima registers this model under "doubleexponential": the fit type is # not derived from the dialog function name. fit_params = create_interactive_fit_params( "doubleexponential", dict(zip(computer.get_params_names(), values)), y, y_fitted, ) return y_fitted, params, fit_params DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/h5browser.py000066400000000000000000001031211524425144300240560ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX HDF5 browser module .. autoclass:: H5Browser :members: .. autoclass:: H5BrowserDialog :members: .. autoclass:: H5TreeWidget :members: """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations import abc import os import os.path as osp from typing import TYPE_CHECKING, Any, Callable from guidata.qthelpers import ( add_actions, create_action, create_toolbutton, exec_dialog, get_icon, get_std_icon, win32_fix_title_bar_background, ) from guidata.utils.misc import to_string from guidata.widgets.arrayeditor import ArrayEditor from plotpy.builder import make from plotpy.plot import PlotOptions, PlotWidget from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from qtpy.compat import getopenfilename from sigima import ImageObj, SignalObj from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object from sigimax.config import _, get_conf from sigimax.h5 import H5Importer from sigimax.utils.qthelpers import block_signals, qt_handle_error_message __all__ = [ "AbstractTreeWidget", "H5Browser", "H5BrowserDialog", "H5FileSelector", "H5TreeWidget", ] if TYPE_CHECKING: from plotpy.plot import BasePlot from sigimax.h5.common import BaseNode class AbstractTreeWidgetMeta(type(QW.QTreeWidget), abc.ABCMeta): """Mixed metaclass to avoid conflicts""" class AbstractTreeWidget(QW.QTreeWidget, metaclass=AbstractTreeWidgetMeta): """One-column tree widget with context menu, ...""" def __init__(self, parent: QW.QWidget) -> None: super().__init__(parent) self.setItemsExpandable(True) self.itemActivated.connect(self.activated) self.itemClicked.connect(self.clicked) # Setup context menu self.menu = QW.QMenu(self) self.collapse_all_action = None self.collapse_selection_action = None self.expand_all_action = None self.expand_selection_action = None self.common_actions = self.setup_common_actions() self.itemSelectionChanged.connect(self.item_selection_changed) self.item_selection_changed() @abc.abstractmethod def activated(self, item: QW.QTreeWidgetItem) -> None: """Double-click event""" @abc.abstractmethod def clicked(self, item: QW.QTreeWidgetItem) -> None: """Item was clicked""" @abc.abstractmethod def get_actions_from_items( self, items: list[QW.QTreeWidgetItem] ) -> list[QW.QAction]: """Get actions from item""" # Right here: add other actions if necessary (reimplement this method) return [] def setup_common_actions(self) -> list[QW.QAction]: """Setup context menu common actions""" self.collapse_all_action = create_action( self, _("Collapse all"), icon=get_icon("collapse.svg"), triggered=self.collapseAll, ) self.expand_all_action = create_action( self, _("Expand all"), icon=get_icon("expand.svg"), triggered=self.expandAll ) self.restore_action = create_action( self, _("Restore"), tip=_("Restore original tree layout"), icon=get_icon("restore.svg"), triggered=self.restore, ) self.collapse_selection_action = create_action( self, _("Collapse selection"), icon=get_icon("collapse_selection.svg"), triggered=self.collapse_selection, ) self.expand_selection_action = create_action( self, _("Expand selection"), icon=get_icon("expand_selection.svg"), triggered=self.expand_selection, ) return [ self.collapse_all_action, self.expand_all_action, self.restore_action, None, self.collapse_selection_action, self.expand_selection_action, ] def update_menu(self) -> None: """Update context menu""" self.menu.clear() items = self.selectedItems() actions = self.get_actions_from_items(items) if actions: actions.append(None) actions += self.common_actions add_actions(self.menu, actions) def restore(self) -> None: """Restore tree state""" self.collapseAll() for item in self.get_top_level_items(): self.expandItem(item) def __expand_item(self, item: QW.QTreeWidgetItem) -> None: # pragma: no cover """Expand item tree branch""" self.expandItem(item) for index in range(item.childCount()): child = item.child(index) self.__expand_item(child) def expand_selection(self) -> None: # pragma: no cover """Expand selection""" items = self.selectedItems() if not items: items = self.get_top_level_items() for item in items: self.__expand_item(item) if items: self.scrollToItem(items[0]) def __collapse_item(self, item: QW.QTreeWidgetItem) -> None: # pragma: no cover """Collapse item tree branch""" self.collapseItem(item) for index in range(item.childCount()): child = item.child(index) self.__collapse_item(child) def collapse_selection(self) -> None: # pragma: no cover """Collapse selection""" items = self.selectedItems() if not items: items = self.get_top_level_items() for item in items: self.__collapse_item(item) if items: self.scrollToItem(items[0]) def item_selection_changed(self) -> None: """Item selection has changed""" is_selection = len(self.selectedItems()) > 0 self.expand_selection_action.setEnabled(is_selection) self.collapse_selection_action.setEnabled(is_selection) def get_top_level_items(self) -> list[QW.QTreeWidgetItem]: """Iterate over top level items""" return [self.topLevelItem(_i) for _i in range(self.topLevelItemCount())] def find_all_items(self): """Find all items""" return self.findItems("", QC.Qt.MatchContains | QC.Qt.MatchRecursive) def contextMenuEvent(self, event: QG.QContextMenuEvent) -> None: """Override Qt method""" self.update_menu() self.menu.popup(event.globalPos()) class H5TreeWidget(AbstractTreeWidget): """HDF5 Browser Tree Widget Args: parent: Parent widget """ SIG_SELECTED = QC.Signal(QW.QTreeWidgetItem) def __init__(self, parent: QW.QWidget) -> None: super().__init__(parent) title = _("HDF5 Browser") self.setColumnCount(4) self.setWindowTitle(title) self.setHeaderLabels([_("Name"), _("Size"), _("Type"), _("Value")]) self.header().setSectionResizeMode(0, QW.QHeaderView.Stretch) self.header().setStretchLastSection(False) self.fnames: list[str] = [] self.h5importers: list[H5Importer] = [] def add_root(self, fname: str) -> None: """Add HDF5 root (new file) Args: fname: HDF5 file name """ self.fnames.append(osp.abspath(fname)) importer = H5Importer(fname) self.h5importers.append(importer) self.add_root_to_tree(importer) # Temporarily expand all items to calculate proper column widths rootitem = self.topLevelItem(self.topLevelItemCount() - 1) self.expand_all_children(rootitem) for col in range(4): self.resizeColumnToContents(col) # Restore to default state (only root and its immediate children expanded) for index in range(rootitem.childCount()): child = rootitem.child(index) self.collapseItem(child) def remove_root(self, fname: str) -> None: """Remove HDF5 root Args: fname: HDF5 file name """ index = self.fnames.index(osp.abspath(fname)) self.fnames.pop(index) importer = self.h5importers.pop(index) importer.close() # Remove root item associated with file item = self.topLevelItem(index) self.takeTopLevelItem(index) del item def cleanup(self) -> None: """Clean up widget""" for importer in self.h5importers: importer.close() self.fnames: list[str] = [] self.h5importers: list[H5Importer] = [] self.clear() def __get_top_level_item(self, item: QW.QTreeWidgetItem) -> QW.QTreeWidgetItem: """Get top level item associated to item Args: item: Tree item Returns: Top level item """ while item.parent(): item = item.parent() return item def get_node(self, item: QW.QTreeWidgetItem) -> BaseNode: """Get HDF5 dataset associated to item Args: item: Tree item Returns: HDF5 node """ toplevel_item = self.__get_top_level_item(item) toplevel_index = self.indexOfTopLevelItem(toplevel_item) node_id = item.data(0, QC.Qt.UserRole) if node_id: importer = self.h5importers[toplevel_index] return importer.get(node_id) return None def get_nodes(self, only_checked_items: bool = True) -> list[BaseNode]: """Get all nodes associated to checked items Args: only_checked_items: If True, only checked items are returned Returns: List of HDF5 nodes """ datasets = [] for item in self.find_all_items(): if item.flags() & QC.Qt.ItemIsUserCheckable: if only_checked_items and item.checkState(0) == 0: continue if item is not self.topLevelItem(0): node = self.get_node(item) datasets.append(node) return datasets def activated(self, item: QW.QTreeWidgetItem) -> None: """Double-click event""" if item is not self.topLevelItem(0): self.SIG_SELECTED.emit(item) def clicked(self, item: QW.QTreeWidgetItem) -> None: """Click event""" self.activated(item) def get_actions_from_items(self, items): # pylint: disable=W0613 """Get actions from item""" return [] def is_empty(self) -> bool: """Return True if tree is empty""" return len(self.find_all_items()) == 1 def is_any_item_checked(self) -> bool: """Return True if any item is checked""" for item in self.find_all_items(): if item.checkState(0) > 0: return True return False def select_all(self, state: bool) -> None: """Select all items Args: state: If True, all items are selected """ for item in self.find_all_items(): if item.flags() & QC.Qt.ItemIsUserCheckable: item.setSelected(state) if state: self.clicked(item) def toggle_all(self, state: bool) -> None: """Toggle all item state from 'unchecked' to 'checked' (or vice-versa) Args: state: If True, all items are checked """ for item in self.find_all_items(): if item.flags() & QC.Qt.ItemIsUserCheckable: item.setCheckState(0, QC.Qt.Checked if state else QC.Qt.Unchecked) @staticmethod def __create_node(node: BaseNode) -> QW.QTreeWidgetItem: """Create tree node from HDF5 node Args: node: HDF5 node Returns: Tree widget node """ text = to_string(node.text) if len(text) > 30: text = text[:30] + "..." treeitem = QW.QTreeWidgetItem([node.name, node.shape_str, node.dtype_str, text]) treeitem.setData(0, QC.Qt.UserRole, node.id) if node.description: for col in range(treeitem.columnCount()): treeitem.setToolTip(col, node.description) return treeitem @staticmethod def __recursive_popfunc(parent_item: QW.QTreeWidgetItem, node: BaseNode) -> None: """Recursive HDF5 analysis Args: parent_item: Parent tree item node: HDF5 node """ tree_item = H5TreeWidget.__create_node(node) if node.is_supported(): tree_item.setCheckState(0, QC.Qt.Unchecked) else: tree_item.setFlags(QC.Qt.ItemIsEnabled) tree_item.setIcon(0, get_icon(node.icon_name)) parent_item.addChild(tree_item) for child in node.children: H5TreeWidget.__recursive_popfunc(tree_item, child) def expand_all_children(self, item: QW.QTreeWidgetItem) -> None: """Expand all children (recursively) Args: item: Tree item """ self.expandItem(item) for index in range(item.childCount()): child = item.child(index) self.expand_all_children(child) def add_root_to_tree(self, importer: H5Importer) -> None: """Add root to tree Args: importer: HDF5 importer """ root = importer.root rootitem = QW.QTreeWidgetItem([root.name]) rootitem.setToolTip(0, root.description) rootitem.setData(0, QC.Qt.UserRole, root.id) rootitem.setFlags(QC.Qt.ItemIsEnabled) rootitem.setIcon(0, get_icon(root.icon_name)) self.addTopLevelItem(rootitem) for node in root.children: self.__recursive_popfunc(rootitem, node) self.expandItem(rootitem) def toggle_show_only_checkable_items(self, state: bool) -> None: """Show only checkable items Args: state: If True, only checkable items are shown """ for item in self.find_all_items(): item.setHidden(state) if state: # Iterate over checkable items and show them (and their parents) for item in self.find_all_items(): if item.flags() & QC.Qt.ItemIsUserCheckable: item.setHidden(False) parent = item.parent() while parent: parent.setHidden(False) parent = parent.parent() def toggle_show_values(self, state: bool) -> None: """Show values Args: state: If True, values are shown """ # Hide or show the "Value" column self.setColumnHidden(3, not state) def set_current_file(self, fname: str) -> None: """Set current file Args: fname: HDF5 file name """ index = self.fnames.index(osp.abspath(fname)) item = self.topLevelItem(index) self.setCurrentItem(item) self.scrollToItem(item, QW.QAbstractItemView.PositionAtTop) class PlotPreview(QW.QStackedWidget): """Plot preview""" def __init__(self, parent: QW.QWidget) -> None: super().__init__(parent) self.curvewidget = PlotWidget( self, options=PlotOptions( type="curve", curve_antialiasing=True, show_axes_tab=False, autoscale_margin_percent=get_conf().sig_autoscale_margin_percent.get(), ), ) self.addWidget(self.curvewidget) self.imagewidget = PlotWidget( self, options=PlotOptions( type="image", show_contrast=True, show_axes_tab=False, autoscale_margin_percent=get_conf().ima_autoscale_margin_percent.get(), ), ) self.addWidget(self.imagewidget) def cleanup(self) -> None: """Clean up widget""" for widget in (self.imagewidget, self.curvewidget): widget.get_plot().del_all_items() def update_plot_preview(self, node: BaseNode) -> None: """Update plot preview widget""" try: obj = node.get_native_object() except Exception as msg: # pylint: disable=broad-except qt_handle_error_message(self, msg) return if obj is None: # An error occurred while creating the object (invalid data, ...) label = make.label(_("Unsupported data"), "C", (0, 0), "C") plot: BasePlot = self.currentWidget().get_plot() plot.del_all_items() plot.add_item(label) plot.replot() return if isinstance(obj, SignalObj): obj: SignalObj widget = self.curvewidget else: obj: ImageObj widget = self.imagewidget with CURVESTYLES.suspend(): item = create_adapter_from_object(obj).make_item() plot = widget.get_plot() plot.del_all_items() plot.add_item(item) plot.set_active_item(item) item.unselect() plot.do_autoscale() self.setCurrentWidget(widget) class TablePreview(QW.QWidget): """Table preview Args: title: Group title parent: Parent widget """ def __init__(self, parent: QW.QWidget) -> None: super().__init__(parent) self.setLayout(QW.QVBoxLayout()) self.table = QW.QTableWidget(self) self.table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) self.table.horizontalHeader().setStretchLastSection(True) self.layout().addWidget(self.table) def clear(self) -> None: """Clear table""" self.table.clear() def update_table_preview(self, data: dict[str, Any]) -> None: """Update table preview widget Args: node: HDF5 node """ self.clear() self.table.setRowCount(len(data)) self.table.setColumnCount(1) self.table.setHorizontalHeaderLabels([_("Value")]) self.table.setVerticalHeaderLabels(list(data.keys())) for row, value in enumerate(data.values()): self.table.setItem(row, 0, QW.QTableWidgetItem(str(value))) self.table.resizeRowsToContents() class GroupAndAttributes(QW.QTabWidget): """Group and attributes Args: parent: Parent widget show_array_callback: Callback to show array """ def __init__(self, parent: QW.QWidget, show_array_callback: Callable) -> None: super().__init__(parent) self.group = TablePreview(self) self.addTab(self.group, get_icon("h5group.svg"), _("Group")) self.attrs = TablePreview(self) self.addTab(self.attrs, get_icon("h5attrs.svg"), _("Attributes")) # Add a button as corner widget to show the array (if any): self.__show_array_btn = create_toolbutton( self, icon=get_icon("show_results.svg"), text=_("Show array"), autoraise=False, triggered=show_array_callback, ) self.__show_array_btn.setEnabled(False) self.setCornerWidget(self.__show_array_btn, QC.Qt.TopRightCorner) def cleanup(self) -> None: """Clean up widget""" self.group.clear() self.attrs.clear() def update_from_node(self, node: BaseNode) -> None: """Update widget from node Args: node: HDF5 node """ # Update group ================================================================= text = to_string(node.text) if text: lines = text.splitlines()[:5] if len(lines) == 5: lines += ["[...]"] text = os.linesep.join(lines) data = { _("Path"): node.id, _("Name"): node.name, _("Description"): node.description, _("Textual preview"): text, # "Raw": repr(node.data), } self.group.update_table_preview(data) # Update attributes ============================================================ self.attrs.update_table_preview(node.metadata) # Update show array button ===================================================== self.__show_array_btn.setEnabled(node.IS_ARRAY) class H5FileSelector(QW.QWidget): """HDF5 file selector Args: parent: Parent widget """ SIG_ADD_FILENAME = QC.Signal(str) SIG_REMOVE_FILENAME = QC.Signal(str) SIG_CURRENT_CHANGED = QC.Signal(str) def __init__(self, parent: QW.QWidget) -> None: super().__init__(parent) self.setLayout(QW.QHBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) self.combo = QW.QComboBox(self) self.combo.currentTextChanged.connect(self.current_file_changed) self.layout().addWidget(self.combo) self.btn_add = create_toolbutton( self, icon=get_std_icon("DirOpenIcon"), text=_("Open") + " ...", autoraise=False, triggered=lambda _checked=False: self.add_file(), ) self.btn_add.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed) self.layout().addWidget(self.btn_add) self.btn_rmv = create_toolbutton( self, icon=get_std_icon("DialogCloseButton"), text=_("Close"), autoraise=False, triggered=self.remove_file, ) self.btn_rmv.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed) self.layout().addWidget(self.btn_rmv) self.btn_rmv.setEnabled(False) def set_current_fname(self, fname: str) -> None: """Set current file name Args: fname: HDF5 file name """ index = self.combo.findText(fname) if index >= 0: self.combo.setCurrentIndex(index) def get_current_fname(self) -> str: """Return current file name Returns: HDF5 file name """ return self.combo.currentText() def current_file_changed(self, fname: str) -> None: """Current file changed Args: fname: HDF5 file name """ self.SIG_CURRENT_CHANGED.emit(fname) def add_fname(self, fname: str) -> None: """Add file name Args: fname: HDF5 file name """ self.combo.addItem(get_icon("h5file.svg"), fname) self.btn_rmv.setEnabled(True) def remove_fname(self, fname: str) -> None: """Remove file name Args: fname: HDF5 file name """ index = self.combo.findText(fname) if index >= 0: self.combo.removeItem(index) if self.combo.count() == 0: self.btn_rmv.setEnabled(False) def add_file(self, fname: str | None = None) -> None: """Browse file Args: fname: HDF5 file name. Default is None. (this is used for testing only) """ if fname is None: fname = getopenfilename( self, _("Select HDF5 file"), "", _("HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"), )[0] if fname: self.SIG_ADD_FILENAME.emit(osp.abspath(fname)) def remove_file(self, fname: str | None = None) -> None: """Remove file name Args: fname: HDF5 file name """ if fname is None: fname = self.combo.currentText() self.SIG_REMOVE_FILENAME.emit(fname) class H5Browser(QW.QSplitter): """HDF5 Browser Widget Args: parent: Parent widget """ SIG_SELECT_NEW_FILE = QC.Signal(str) SIG_REMOVE_FILE = QC.Signal(str) def __init__(self, parent: QW.QWidget | None = None) -> None: super().__init__(parent) if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) self.selector = H5FileSelector(self) self.selector.SIG_ADD_FILENAME.connect(self.__add_new_file) self.selector.SIG_REMOVE_FILENAME.connect(self.__remove_file) self.selector.SIG_CURRENT_CHANGED.connect(self.__selector_current_file_changed) self.tree = H5TreeWidget(self) self.tree.SIG_SELECTED.connect(self.__item_selected_on_tree) selectorandtree = QW.QFrame(self) selectorandtree.setLayout(QW.QVBoxLayout()) selectorandtree.layout().addWidget(self.selector) # Add toolbar with tree actions toolbar = self.__create_toolbar() selectorandtree.layout().addWidget(toolbar) selectorandtree.layout().addWidget(self.tree) selectorandtree.layout().setContentsMargins(0, 0, 0, 0) self.addWidget(selectorandtree) preview = QW.QSplitter(self) preview.setOrientation(QC.Qt.Vertical) self.addWidget(preview) self.plotpreview = PlotPreview(self) preview.addWidget(self.plotpreview) self.groupandattrs = GroupAndAttributes(self, self.show_array) preview.addWidget(self.groupandattrs) preview.setSizes([int(self.size().height() / 2)] * 2) def __create_toolbar(self) -> QW.QToolBar: """Create toolbar with tree actions Returns: Toolbar widget """ toolbar = QW.QToolBar(self) toolbar.setToolButtonStyle(QC.Qt.ToolButtonTextBesideIcon) toolbar.setIconSize(QC.QSize(16, 16)) toolbar.setStyleSheet("QToolBar { padding: 2px; spacing: 2px; }") toolbar.addAction(self.tree.expand_all_action) toolbar.addAction(self.tree.collapse_all_action) toolbar.addAction(self.tree.restore_action) toolbar.addSeparator() toolbar.addAction(self.tree.expand_selection_action) toolbar.addAction(self.tree.collapse_selection_action) return toolbar def open_file(self, fname: str) -> None: """Open HDF5 file Args: fname: HDF5 file name """ self.tree.add_root(fname) self.selector.add_fname(fname) def close_file(self, fname: str) -> None: """Close HDF5 file Args: fname: HDF5 file name """ self.tree.remove_root(fname) self.selector.remove_fname(fname) def __add_new_file(self, fname: str) -> None: """Add new file Args: fname: HDF5 file name """ self.open_file(fname) self.selector.set_current_fname(fname) self.SIG_SELECT_NEW_FILE.emit(fname) def __remove_file(self, fname: str) -> None: """Remove file Args: fname: HDF5 file name """ self.close_file(fname) self.SIG_REMOVE_FILE.emit(fname) def cleanup(self) -> None: """Clean up widget""" self.tree.cleanup() self.plotpreview.cleanup() def get_node(self, item: QW.QTreeWidgetItem | None = None) -> BaseNode: """Return (selected) dataset Args: item: Tree item Returns: HDF5 node """ if item is None: item = self.tree.currentItem() return self.tree.get_node(item) def __item_selected_on_tree(self, item: QW.QTreeWidgetItem) -> None: """Item selected on tree Args: item: Tree item """ # View the selected item node = self.get_node(item) if node.is_supported(): self.plotpreview.update_plot_preview(node) self.groupandattrs.update_from_node(node) # Update the file selector combo box with block_signals(self.selector.combo): # Avoid triggering current file changed signal, which would result in # loosing the current selection on the tree (side effect: "Show array" # button would still be enabled if the previous node was an array, except # that now the current node is not an array, thus causing an error if # the user clicks on the button). self.selector.set_current_fname(node.h5file.filename) def __selector_current_file_changed(self, fname: str) -> None: """Selector current file changed Args: fname: HDF5 file name """ if fname: self.tree.set_current_file(fname) def show_array(self) -> None: """Show array""" node = self.get_node() assert node.IS_ARRAY arrayeditor = ArrayEditor(self) arrayeditor.setup_and_check( node.data, title=node.name, readonly=True, add_title_suffix=False ) exec_dialog(arrayeditor) class H5BrowserDialog(QW.QDialog): """HDF5 Browser Dialog Args: parent: Parent widget size: Dialog size """ def __init__( self, parent: QW.QWidget | None = None, size: tuple[int, int] = (1150, 700) ) -> None: super().__init__(parent) self.setWindowFlags(QC.Qt.Window) self.setObjectName("h5browser") self.setWindowTitle(_("HDF5 Browser")) self.setWindowIcon(get_icon("h5browser.svg")) win32_fix_title_bar_background(self) vlayout = QW.QVBoxLayout() self.setLayout(vlayout) self.button_layout: QW.QHBoxLayout | None = None self.bbox: QW.QDialogButtonBox | None = None self.nodes: list[BaseNode] = [] self.checkbox_show_only: QW.QCheckBox | None = None self.checkbox_show_values: QW.QCheckBox | None = None self.browser = H5Browser(self) self.browser.SIG_SELECT_NEW_FILE.connect(self.select_new_file) self.browser.SIG_REMOVE_FILE.connect(self.remove_file) vlayout.addWidget(self.browser) self.browser.tree.itemChanged.connect(lambda item: self.refresh_buttons()) self.install_button_layout() self.setMinimumSize(QC.QSize(900, 500)) self.resize(QC.QSize(*size)) self.browser.setSizes([int(self.size().height() / 2)] * 2) self.refresh_buttons() def accept(self) -> None: """Accept changes""" self.nodes = self.browser.tree.get_nodes() QW.QDialog.accept(self) def is_empty(self) -> bool: """Return True if tree is empty""" return self.browser.tree.is_empty() def cleanup(self) -> None: """Cleanup dialog""" self.browser.cleanup() def refresh_buttons(self) -> None: """Refresh buttons""" state = self.browser.tree.is_any_item_checked() self.bbox.button(QW.QDialogButtonBox.Ok).setEnabled(state) def show_only_checkable_items(self, state: int) -> None: """Show only checkable items Args: state: If True, only checkable items are shown """ self.browser.tree.toggle_show_only_checkable_items(state) fname = self.browser.selector.get_current_fname() if fname: self.browser.tree.set_current_file(fname) def __finalize_setup(self) -> None: """Finalize setup""" tree = self.browser.tree tree.toggle_show_only_checkable_items(self.checkbox_show_only.isChecked()) tree.toggle_show_values(self.checkbox_show_values.isChecked()) def open_file(self, fname: str) -> None: """Open file Args: fname: HDF5 file name """ self.browser.open_file(fname) self.__finalize_setup() def open_files(self, fnames: list[str]) -> None: """Open files Args: fnames: HDF5 file names """ for fname in fnames: self.browser.open_file(fname) self.__finalize_setup() def select_new_file(self, fname: str) -> None: # pylint:disable=unused-argument """Select new file Args: fname: HDF5 file name """ self.__finalize_setup() self.refresh_buttons() def remove_file(self, fname: str) -> None: # pylint:disable=unused-argument """Remove file Args: fname: HDF5 file name """ self.refresh_buttons() def get_all_nodes(self) -> list[BaseNode]: """Return all supported datasets Returns: List of HDF5 nodes """ return self.browser.tree.get_nodes(only_checked_items=False) def get_nodes(self) -> list[BaseNode]: """Return datasets Returns: List of HDF5 nodes """ return self.nodes def install_button_layout(self) -> None: """Install button layout""" bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel) bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) btn_check_all = create_toolbutton( self, icon=get_icon("check_all.svg"), text=_("Check all"), autoraise=False, shortcut=QG.QKeySequence.SelectAll, triggered=lambda checked=True: self.browser.tree.toggle_all(checked), ) btn_uncheck_all = create_toolbutton( self, icon=get_icon("uncheck_all.svg"), text=_("Uncheck all"), autoraise=False, triggered=lambda checked=False: self.browser.tree.toggle_all(checked), ) self.checkbox_show_only = QW.QCheckBox(_("Show only supported data")) self.checkbox_show_only.stateChanged.connect(self.show_only_checkable_items) self.checkbox_show_values = QW.QCheckBox(_("Show values")) self.checkbox_show_values.stateChanged.connect( self.browser.tree.toggle_show_values ) self.button_layout = QW.QHBoxLayout() self.button_layout.addWidget(self.checkbox_show_only) self.button_layout.addWidget(self.checkbox_show_values) self.button_layout.addSpacing(10) self.button_layout.addWidget(btn_check_all) self.button_layout.addWidget(btn_uncheck_all) self.button_layout.addStretch() self.button_layout.addWidget(bbox) self.bbox = bbox vlayout: QW.QVBoxLayout = self.layout() vlayout.addSpacing(10) vlayout.addLayout(self.button_layout) DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/imagebackground.py000066400000000000000000000102411524425144300252600ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Image background selection dialog. .. autoclass:: ImageBackgroundDialog :members: """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations from typing import TYPE_CHECKING, Any import numpy as np from guidata.configtools import get_icon from plotpy.builder import make from plotpy.plot import PlotDialog, PlotOptions from sigimax.adapters_plotpy import create_adapter_from_object from sigimax.config import _, get_conf from sigimax.utils.qthelpers import resize_widget_to_parent __all__ = [ "ImageBackgroundDialog", ] if TYPE_CHECKING: from plotpy.items import MaskedXYImageItem, RangeComputation2d, RectangleShape from qtpy.QtWidgets import QWidget from sigima.objects import ImageObj class ImageBackgroundDialog(PlotDialog): """Image background selection dialog. Args: image: image object parent: parent widget. Defaults to None. options: plot options. Defaults to None. """ def __init__( self, image: ImageObj, parent: QWidget | None = None, options: PlotOptions | dict[str, Any] | None = None, ) -> None: self.__background: float | None = None self.__rect_coords: tuple[float, float, float, float] | None = None self.imageitem: MaskedXYImageItem | None = None self.rectarea: RectangleShape | None = None self.comput2d: RangeComputation2d | None = None super().__init__( title=_("Image background selection"), edit=True, parent=parent, options=options, ) self.setObjectName("backgroundselection") if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) else: resize_widget_to_parent(self, aspect_ratio=1.0) self.__image = image.copy() self.__setup_dialog() def test_compute_background(self) -> None: """Method to test background computation.""" # Instead of waiting for Qt events, directly test the computation method # by simulating what RangeComputation2d would do x0, y0, x1, y1 = self.rectarea.get_rect() x, y, z = self.imageitem.get_data(x0, y0, x1, y1) self.__compute_background(x, y, z) def __compute_background( self, x: np.ndarray, # pylint: disable=unused-argument y: np.ndarray, # pylint: disable=unused-argument z: np.ndarray, ) -> float: """Compute background value""" self.__rect_coords = self.rectarea.get_rect() self.__background = z.mean() return self.__background def __setup_dialog(self) -> None: """Setup dialog box""" obj = self.__image self.imageitem = create_adapter_from_object(obj).make_item() plot = self.get_plot() if obj.is_uniform_coords: x0, y0 = obj.x0, obj.y0 x1, y1 = obj.xc + obj.dx, obj.yc + obj.dy else: x0, y0 = obj.xcoords[0], obj.ycoords[0] xc = (obj.xcoords[0] + obj.xcoords[-1]) / 2 yc = (obj.ycoords[0] + obj.ycoords[-1]) / 2 x1, y1 = xc, yc self.rectarea = make.rectangle(x0, y0, x1, y1, _("Background area")) self.comput2d = make.computation2d( self.rectarea, "TL", _("Background value:") + " %g", self.imageitem, self.__compute_background, ) for item in (self.imageitem, self.rectarea, self.comput2d): plot.add_item(item) plot.replot() plot.set_active_item(self.rectarea) def get_background(self) -> float: """Get background value""" return self.__background def get_rect_coords(self) -> tuple[float, float, float, float]: """Get rectangle coordinates Returns: tuple: rectangle coordinates (x0, y0, x1, y1) Raises: ValueError: if rectangle coordinates are not set """ if self.__rect_coords is None: raise ValueError("Rectangle coordinates not set") return self.__rect_coords DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/logviewer.py000066400000000000000000000061501524425144300241450ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Module providing a log viewer widget, a log viewer window and SigimaX's log viewer .. autoclass:: LogViewerWindow :members: .. autofunction:: get_log_filenames .. autofunction:: get_log_prompt_message .. autofunction:: exec_sigimax_logviewer_dialog """ from __future__ import annotations import os.path as osp from guidata.configtools import get_icon from guidata.qthelpers import exec_dialog from qtpy import QtWidgets as QW from sigimax.config import _, get_conf, get_old_log_fname from sigimax.env import execenv from sigimax.widgets.fileviewer import FileViewerWidget, get_title_contents __all__ = [ "LogViewerWindow", "exec_sigimax_logviewer_dialog", "get_log_filenames", "get_log_prompt_message", ] class LogViewerWindow(QW.QDialog): """Log viewer window""" def __init__(self, fnames: list[str], parent: QW.QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("logviewer") self.setWindowTitle(get_conf().app_name.get() + " - " + _("Log files")) self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) self.tabs = QW.QTabWidget(self) for fname in fnames: if osp.isfile(fname): title, contents = get_title_contents(fname) if not contents.strip(): continue viewer = FileViewerWidget(language="Python") viewer.set_data(title, contents) self.tabs.addTab(viewer, get_icon("logs.svg"), osp.basename(fname)) layout = QW.QVBoxLayout() layout.addWidget(self.tabs) self.setLayout(layout) self.resize(900, 400) @property def is_empty(self) -> bool: """Return True if there is no log available""" return self.tabs.count() == 0 def get_log_filenames() -> list[str]: """Return log filenames""" conf = get_conf() return [ conf.traceback_log_path.get(), conf.faulthandler_log_path.get(), get_old_log_fname(conf.traceback_log_path.get()), get_old_log_fname(conf.faulthandler_log_path.get()), ] def get_log_prompt_message() -> str | None: """Return prompt message for log files, i.e. a message informing the user whether log files were generated during last session or current session.""" avail = [osp.isfile(fname) for fname in get_log_filenames()] if avail[0] or avail[1]: return _("Log files were generated during current session.") if avail[2] or avail[3]: return _("Log files were generated during last session.") return None def exec_sigimax_logviewer_dialog(parent: QW.QWidget | None = None) -> None: """View SigimaX logs""" fnames = [osp.normpath(fname) for fname in get_log_filenames() if osp.isfile(fname)] dlg = LogViewerWindow(fnames, parent=parent) if dlg.is_empty: if not execenv.unattended: QW.QMessageBox.information( dlg, get_conf().app_name.get(), _("Log files are currently empty.") ) dlg.close() else: exec_dialog(dlg) DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/objectdialog.py000066400000000000000000000213561524425144300245750ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Object dialog ------------- This module provides a dialog box to select an object (signal or image) from a remote DataLab-derived application, using :class:`sigima.client.SimpleRemoteProxy` (or any proxy exposing the same ``get_group_titles_with_object_info``/``get_current_panel``/ ``set_current_panel`` methods). .. autoclass:: GetObjectDialog :members: """ from __future__ import annotations import os from collections.abc import Iterator from typing import TYPE_CHECKING from guidata.qthelpers import block_signals from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from sigimax.config import _ from sigimax.utils.svg_icons import GROUP, IMAGE, SIGNAL, svgtext_to_icon if TYPE_CHECKING: # pragma: no cover from sigima.client import SimpleRemoteProxy __all__ = ["GetObjectDialog", "SimpleObjectTree"] class SimpleObjectTree(QW.QTreeWidget): """Object list tree widget, populated from a remote proxy's groups/objects. Args: parent: Parent widget """ SIG_ITEM_DOUBLECLICKED = QC.Signal(str) SIG_CONTEXT_MENU = QC.Signal(QC.QPoint) def __init__(self, parent: QW.QWidget) -> None: self.__obj_uuids: list[list[str]] = [] self.__obj_titles: list[list[str]] = [] self.__grp_titles: list[str] = [] self.__panel_name: str = "" super().__init__(parent) self.setHeaderHidden(True) self.setColumnCount(1) self.setAlternatingRowColors(True) self.itemDoubleClicked.connect(self.item_double_clicked) def __str__(self) -> str: """Return string representation""" textlist = [] for tl_index in range(self.topLevelItemCount()): tl_item = self.topLevelItem(tl_index) textlist.append(tl_item.text(0)) for index in range(tl_item.childCount()): textlist.append(" " + tl_item.child(index).text(0)) return os.linesep.join(textlist) def initialize(self, proxy: SimpleRemoteProxy) -> None: """Initialize tree with objects, using proxy""" grp_titles, obj_uuids, obj_titles = proxy.get_group_titles_with_object_info() self.__grp_titles = grp_titles self.__obj_uuids = obj_uuids self.__obj_titles = obj_titles self.__panel_name = proxy.get_current_panel() self.populate_tree() def iter_items( self, item: QW.QTreeWidgetItem | None = None ) -> Iterator[QW.QTreeWidgetItem]: """Recursively iterate over all items""" if item is None: for index in range(self.topLevelItemCount()): yield from self.iter_items(self.topLevelItem(index)) else: yield item for index in range(item.childCount()): yield from self.iter_items(item.child(index)) def get_item_from_id(self, item_id) -> QW.QTreeWidgetItem: """Return QTreeWidgetItem from id (stored in item's data)""" for item in self.iter_items(): if item.data(0, QC.Qt.UserRole) == item_id: return item return None def get_current_item_id(self, object_only: bool = False) -> str | None: """Return current item id""" item = self.currentItem() if item is not None and (not object_only or item.parent() is not None): return item.data(0, QC.Qt.UserRole) return None def set_current_item_id(self, uuid: str, extend: bool = False) -> None: """Set current item by id""" item = self.get_item_from_id(uuid) if extend: self.setCurrentItem(item, 0, QC.QItemSelectionModel.Select) else: self.setCurrentItem(item) @staticmethod def __update_item( item: QW.QTreeWidgetItem, number: int, prefix: str, title: str, uuid: str ) -> None: """Update item""" item.setText(0, f"{prefix}{number:03d}: {title}") item.setData(0, QC.Qt.UserRole, uuid) def populate_tree(self) -> None: """Populate tree with objects""" uuid = self.get_current_item_id() with block_signals(widget=self, enable=True): self.clear() for grp_idx, (grp_title, obj_uuids, obj_titles) in enumerate( zip(self.__grp_titles, self.__obj_uuids, self.__obj_titles) ): self.add_group_item(grp_idx + 1, grp_title, obj_uuids, obj_titles) if uuid is not None: self.set_current_item_id(uuid) def __add_to_group_item( self, number: int, obj_uuid: str, obj_title: str, group_item: QW.QTreeWidgetItem ) -> None: """Add object to group item""" item = QW.QTreeWidgetItem() prefix = self.__panel_name[0] svgtext = SIGNAL if prefix == "s" else IMAGE item.setIcon(0, svgtext_to_icon(svgtext)) self.__update_item(item, number, prefix, obj_title, obj_uuid) group_item.addChild(item) def add_group_item( self, number: int, title: str, obj_uuids: list[str], obj_titles: list[str] ) -> None: """Add group item""" group_item = QW.QTreeWidgetItem() group_item.setIcon(0, svgtext_to_icon(GROUP)) self.__update_item(group_item, number, "g", title, "") self.addTopLevelItem(group_item) group_item.setExpanded(True) for obj_idx, (obj_uuid, obj_title) in enumerate(zip(obj_uuids, obj_titles)): self.__add_to_group_item(obj_idx + 1, obj_uuid, obj_title, group_item) def item_double_clicked(self, item: QW.QTreeWidgetItem) -> None: """Item was double-clicked: emit signal for parent dialog to handle""" if item.parent() is not None: oid = item.data(0, QC.Qt.UserRole) self.SIG_ITEM_DOUBLECLICKED.emit(oid) def contextMenuEvent(self, event: QG.QContextMenuEvent) -> None: # pylint: disable=invalid-name """Override Qt method""" self.SIG_CONTEXT_MENU.emit(event.globalPos()) class GetObjectDialog(QW.QDialog): """Get object dialog box Args: parent: Parent widget proxy: Remote proxy panel: Panel to retrieve objects from ('signal', 'image' or None for both) title: Dialog title """ def __init__( self, parent: QW.QWidget, proxy: SimpleRemoteProxy, panel: str | None = None, title: str | None = None, ) -> None: super().__init__(parent) assert panel in (None, "signal", "image") self.__proxy = proxy self.__current_object_uuid: str | None = None self.setWindowTitle(_("Select object") if title is None else title) vlayout = QW.QVBoxLayout() self.setLayout(vlayout) panelgroup = None if panel is None: panelgroup = QW.QWidget() panellayout = QW.QHBoxLayout() panellayout.setContentsMargins(0, 0, 0, 0) panelgroup.setLayout(panellayout) panelcombo = QW.QComboBox() panelcombo.addItem(svgtext_to_icon(SIGNAL), _("Signals")) panelcombo.addItem(svgtext_to_icon(IMAGE), _("Images")) if proxy.get_current_panel() == "image": panelcombo.setCurrentIndex(1) panelcombo.currentIndexChanged.connect(self.__change_panel) panellabel = QW.QLabel(_("Active panel:")) panellayout.addWidget(panellabel) panellayout.addWidget(panelcombo) panellayout.setStretch(1, 1) else: self.__proxy.set_current_panel(panel) self.tree = SimpleObjectTree(parent) self.tree.initialize(proxy) self.tree.SIG_ITEM_DOUBLECLICKED.connect(lambda oid: self.accept()) self.tree.itemSelectionChanged.connect(self.__current_object_changed) if panelgroup is not None: vlayout.addWidget(panelgroup) vlayout.addWidget(self.tree) bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel) bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) self.ok_btn = bbox.button(QW.QDialogButtonBox.Ok) vlayout.addSpacing(10) vlayout.addWidget(bbox) # Update OK button state: self.__current_object_changed() def __change_panel(self, index: int) -> None: """Change panel""" self.__proxy.set_current_panel("signal" if index == 0 else "image") self.tree.initialize(self.__proxy) self.__current_object_changed() def __current_object_changed(self) -> None: """Item selection has changed""" self.__current_object_uuid = self.tree.get_current_item_id() self.ok_btn.setEnabled(bool(self.__current_object_uuid)) def get_current_object_uuid(self) -> str: """Return current object uuid""" return self.__current_object_uuid DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/plotdock.py000066400000000000000000000336711524425144300237710ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Docks ===== The :mod:`sigimax.widgets.plotdock` module provides the dockable plot widgets for the SigimaX main window. Plot widget ----------- .. autoclass:: SigimaXPlotWidget Dockable plot widget -------------------- .. autoclass:: DockablePlotWidget """ from __future__ import annotations __all__ = [ "CurveStatsToolFunctions", "DockablePlotWidget", "SigimaXPlotWidget", ] import warnings from typing import TYPE_CHECKING import numpy as np from guidata.qthelpers import is_dark_theme from guidata.widgets.dockable import DockableWidget from plotpy.constants import PlotType from plotpy.plot import PlotOptions, PlotWidget from plotpy.tools import ( BasePlotMenuTool, CurveStatsTool, DeleteItemTool, DisplayCoordsTool, DoAutoscaleTool, EditItemDataTool, ExportItemDataTool, ImageStatsTool, ItemCenterTool, RectangularSelectionTool, RectZoomTool, SelectTool, YRangeCursorTool, ) from plotpy.tools.image import get_stats as get_image_stats from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from qtpy.QtWidgets import QApplication from sigima.tools.signal import pulse from skimage import measure from sigimax.config import get_conf if TYPE_CHECKING: from plotpy.items.image.base import BaseImageItem from plotpy.plot import BasePlot from plotpy.styles import BaseImageParam class CurveStatsToolFunctions: """Statistical functions for `CurveStatsTool` and `YRangeCursorTool`""" @classmethod def set_labelfuncs(cls, statstool: CurveStatsTool | YRangeCursorTool) -> None: """Set label functions for the statistics tool""" if isinstance(statstool, CurveStatsTool): labelfuncs = list(CurveStatsTool.LABELFUNCS) labelfuncs[-1] = (labelfuncs[-1][0] + "
", labelfuncs[-1][1]) labelfuncs.extend( [ ("FWHM=%s", cls.fwhm_info), ("∆xRISE 10-90=%s", cls.rise_time_info), ( "∆xRISE 20-80=%s", lambda x, y: cls.rise_time_info(x, y, 0.2, 0.8), ), ("∆xFALL 90-10=%s", cls.fall_time_info), ( "∆xFALL 80-20=%s", lambda x, y: cls.fall_time_info(x, y, 0.8, 0.2), ), ] ) statstool.set_labelfuncs(tuple(labelfuncs)) else: # YRangeCursorTool - use PlotPy's defaults as-is statstool.set_labelfuncs(YRangeCursorTool.LABELFUNCS) @staticmethod def fwhm_info(x, y): """Return FWHM information string""" try: with warnings.catch_warnings(record=True) as w: x0, _y0, x1, _y1 = pulse.fwhm(x, y, "zero-crossing") wstr = " ⚠�" if w else "" except (ValueError, ZeroDivisionError, pulse.InvalidSignalError): return "🛑" return f"{x1 - x0:g}{wstr}" @staticmethod def rise_time_info(x, y, start_ratio=0.1, end_ratio=0.9): """Return rise time information string""" try: with warnings.catch_warnings(record=True) as w: dt = pulse.get_rise_time(x, y, start_ratio, end_ratio) wstr = " ⚠�" if w else "" if dt is None: return "🛑" except (ValueError, ZeroDivisionError, pulse.InvalidSignalError): return "🛑" return f"{dt:g}{wstr}" @staticmethod def fall_time_info(x, y, start_ratio=0.9, end_ratio=0.1): """Return fall time information string""" try: with warnings.catch_warnings(record=True) as w: dt = pulse.get_fall_time(x, y, start_ratio, end_ratio) wstr = " ⚠�" if w else "" if dt is None: return "🛑" except (ValueError, ZeroDivisionError, pulse.InvalidSignalError): return "🛑" return f"{dt:g}{wstr}" def get_more_image_stats( item: BaseImageItem, x0: float, y0: float, x1: float, y1: float, ) -> str: """Return formatted string with stats on image rectangular area (output should be compatible with AnnotatedShape.get_info) Args: item: image item x0: X0 y0: Y0 x1: X1 y1: Y1 """ with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) info = get_image_stats(item, x0, y0, x1, y1) ix0, iy0, ix1, iy1 = item.get_closest_index_rect(x0, y0, x1, y1) data = item.data[iy0:iy1, ix0:ix1] p: BaseImageParam = item.param xunit, yunit, zunit = p.get_units() integral = np.nansum(data) integral_fmt = r"%.3e " + zunit info += f"
∑ = {integral_fmt % integral}" if xunit == yunit: surfacefmt = p.xformat.split()[0] + " " + xunit if xunit != "": surfacefmt = surfacefmt + "²" surface = abs((x1 - x0) * (y1 - y0)) info += f"
A = {surfacefmt % surface}" if xunit is not None and zunit is not None: if surface != 0: density = integral / surface densityfmt = r"%.3e" if xunit and zunit: densityfmt += " " + zunit + "/" + xunit + "²" info = info + f"
� = {densityfmt % density}" # Convert data (ndarray) to a simple array to compute centroid with the new # einsum optimisation introduce in numpy 2.4.0 and scikit-image 0.26.0 c_i, c_j = measure.centroid(np.array(data)) c_x, c_y = item.get_plot_coordinates(c_j + ix0, c_i + iy0) info += "
" + "
".join( [ "C|x = " + p.xformat % c_x, "C|y = " + p.yformat % c_y, ] ) return info class SigimaXPlotWidget(PlotWidget): """SigimaX PlotWidget This class is a subclass of `plotpy.plot.PlotWidget` that provides a customized widget for SigimaX, with a specific set of tools and a customized appearance. Args: plot_type: Plot type """ def __init__(self, plot_type: PlotType) -> None: # Get autoscale margin from configuration based on plot type conf = get_conf() if plot_type == PlotType.CURVE: autoscale_margin = conf.sig_autoscale_margin_percent.get() elif plot_type == PlotType.IMAGE: autoscale_margin = conf.ima_autoscale_margin_percent.get() else: # For AUTO or MANUAL types, use signal margin as default autoscale_margin = conf.sig_autoscale_margin_percent.get() super().__init__( options=PlotOptions( type=plot_type, show_axes_tab=False, autoscale_margin_percent=autoscale_margin, ), toolbar=True, ) def __register_standard_tools(self) -> None: """Register standard tools The only differences with the `manager.register_standard_tools` method are the following: 1. We don't register the `BasePlotMenuTool, "axes"` tool, because it is not compatible with SigimaX's apps approach to axes management. 2. We don't register the `ItemListPanelTool` tool (this intends to prevent the user from accessing the item list panel, and thus, the parameters of all the items - some of them are read-only and should not be modified, like the annotations for example). """ mgr = self.manager select_tool = mgr.add_tool(SelectTool) mgr.set_default_tool(select_tool) mgr.add_tool(RectangularSelectionTool, intersect=False) mgr.add_tool(RectZoomTool) mgr.add_tool(DoAutoscaleTool) mgr.add_tool(BasePlotMenuTool, "item") mgr.add_tool(ExportItemDataTool) mgr.add_tool(EditItemDataTool) mgr.add_tool(ItemCenterTool) mgr.add_tool(DeleteItemTool) mgr.add_separator_tool() mgr.add_tool(BasePlotMenuTool, "grid") mgr.add_tool(DisplayCoordsTool) def __register_other_tools(self) -> None: """Register other tools""" mgr = self.manager mgr.add_separator_tool() if self.options.type == PlotType.CURVE: mgr.register_curve_tools() xstatstool = mgr.get_tool(CurveStatsTool) CurveStatsToolFunctions.set_labelfuncs(xstatstool) ystatstool = mgr.get_tool(YRangeCursorTool) CurveStatsToolFunctions.set_labelfuncs(ystatstool) else: mgr.register_image_tools() # Customizing the ImageStatsTool statstool = mgr.get_tool(ImageStatsTool) statstool.set_stats_func(get_more_image_stats, replace=True) self._customize_image_panels() mgr.add_separator_tool() mgr.register_other_tools() mgr.add_separator_tool() mgr.update_tools_status() mgr.get_default_tool().activate() def _customize_image_panels(self) -> None: """Customize the X and Y cross section panels. Called once the image tools are registered, so that the panels and their toolbars exist. The base implementation is a no-op. """ def register_tools(self) -> None: """Register the plotting tools according to the plot type""" self.__register_standard_tools() self.__register_other_tools() # Mapping from config string to Qt dock area constant _DOCK_LOCATION_MAP: dict[str, QC.Qt.DockWidgetArea] = { "top": QC.Qt.TopDockWidgetArea, "bottom": QC.Qt.BottomDockWidgetArea, "left": QC.Qt.LeftDockWidgetArea, "right": QC.Qt.RightDockWidgetArea, } class DockablePlotWidget(DockableWidget): """Docked plotting widget Args: parent: Parent widget plot_type: Plot type """ LOCATION = QC.Qt.RightDockWidgetArea #: Plot widget class instantiated by this dock: override in subclasses to #: provide an application-specific one. PLOTWIDGET_CLASS: type[SigimaXPlotWidget] = SigimaXPlotWidget def __init__( self, parent: QW.QWidget, plot_type: PlotType, ) -> None: super().__init__(parent) self._apply_dock_location() self.plotwidget = self.PLOTWIDGET_CLASS(plot_type) self.toolbar = self.plotwidget.get_toolbar() self.watermark: QW.QLabel | None = None self._setup_watermark() self.setup_layout() self.setup_plotwidget() def _apply_dock_location(self) -> None: """Set dock location from config.""" location_str = get_conf().plot_dock_location.get() location = _DOCK_LOCATION_MAP.get(location_str, QC.Qt.RightDockWidgetArea) self.setup_dockwidget(location=location) def _setup_watermark(self) -> None: """Create the watermark label from the configured image path. If ``Conf.watermark_image_path`` is empty, no watermark is created. """ path = get_conf().watermark_image_path.get() if path: self.watermark = QW.QLabel() pixmap = QG.QPixmap(path) self.watermark.setPixmap(pixmap) else: self.watermark = None def __get_toolbar_row_col(self) -> tuple[int, int]: """Return toolbar row and column""" tb_pos = get_conf().plot_toolbar_position.get() tb_col, tb_row = 1, 1 if tb_pos in ("left", "right"): self.toolbar.setOrientation(QC.Qt.Vertical) tb_col = 0 if tb_pos == "left" else 2 else: self.toolbar.setOrientation(QC.Qt.Horizontal) tb_row = 0 if tb_pos == "top" else 2 return tb_row, tb_col def setup_layout(self) -> None: """Setup layout""" tb_row, tb_col = self.__get_toolbar_row_col() layout = QW.QGridLayout() layout.addWidget(self.toolbar, tb_row, tb_col) layout.addWidget(self.plotwidget, 1, 1) if self.watermark is not None: layout.addWidget(self.watermark, 1, 1, QC.Qt.AlignCenter) self.setLayout(layout) def update_toolbar_position(self) -> None: """Update toolbar position""" tb_row, tb_col = self.__get_toolbar_row_col() layout = self.layout() layout.removeWidget(self.toolbar) layout.addWidget(self.toolbar, tb_row, tb_col) def setup_plotwidget(self) -> None: """Setup plotting widget""" title = self.toolbar.windowTitle() self.plotwidget.get_manager().add_toolbar(self.toolbar, title) # Customizing widget appearances self.update_color_mode() plot = self.plotwidget.get_plot() canvas = plot.canvas() canvas.setFrameStyle(canvas.Plain | canvas.NoFrame) if self.watermark is not None: plot.SIG_ITEMS_CHANGED.connect(self.update_watermark) def update_color_mode(self) -> None: """Update plot widget styles according to application color mode""" if is_dark_theme(): palette = QApplication.instance().palette() else: palette = QG.QPalette(QC.Qt.white) for widget in (self.plotwidget, self.plotwidget.get_plot(), self): widget.setBackgroundRole(QG.QPalette.Window) widget.setAutoFillBackground(True) widget.setPalette(palette) def get_plot(self) -> BasePlot: """Return plot instance""" return self.plotwidget.get_plot() def update_watermark(self, plot: BasePlot) -> None: """Update watermark visibility""" if self.watermark is None: return items = plot.get_items() if self.plotwidget.options.type == PlotType.IMAGE: enabled = len(items) <= 1 else: enabled = len(items) <= 2 self.watermark.setVisible(enabled) # ------DockableWidget API def visibility_changed(self, enable: bool) -> None: """DockWidget visibility has changed""" DockableWidget.visibility_changed(self, enable) self.toolbar.setVisible(enable) DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/signalbaseline.py000066400000000000000000000065431524425144300251300ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Signal base line selection dialog. .. autoclass:: SignalBaselineDialog :members: """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations from typing import TYPE_CHECKING import numpy as np from guidata.configtools import get_icon from plotpy.builder import make from plotpy.plot import PlotDialog from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object from sigimax.config import _, get_conf from sigimax.utils.qthelpers import resize_widget_to_parent __all__ = [ "SignalBaselineDialog", ] if TYPE_CHECKING: from plotpy.items import CurveItem, Marker, XRangeSelection from qtpy.QtWidgets import QWidget from sigima.objects import SignalObj class SignalBaselineDialog(PlotDialog): """Signal baseline selection dialog. Args: signal: signal object parent: parent widget. Defaults to None. """ def __init__(self, signal: SignalObj, parent: QWidget | None = None) -> None: self.__curve_styles = CURVESTYLES.style_generator() self.__baseline: float | None = None self.__x_range: tuple[float, float] = [np.nan, np.nan] self.curve: CurveItem | None = None self.cursor: Marker | None = None self.xrange: XRangeSelection | None = None super().__init__(title=_("Signal baseline selection"), edit=True, parent=parent) self.setObjectName("baselineselection") if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) legend = make.legend("TR") self.get_plot().add_item(legend) self.__signal = signal.copy() self.__setup_dialog() resize_widget_to_parent(self, aspect_ratio=1.0) def __setup_dialog(self) -> None: """Setup dialog box""" obj = self.__signal with CURVESTYLES.alternative(self.__curve_styles): self.curve = create_adapter_from_object(obj).make_item() plot = self.get_plot() plot.set_antialiasing(True) plot.SIG_RANGE_CHANGED.connect(self.xrange_changed) plot.SIG_MARKER_CHANGED.connect(self.cursor_changed) self.cursor = make.hcursor(0.0, _("Base line") + " = %g") self.cursor.set_movable(False) self.xrange = make.xrange(obj.x[0], obj.x[int(0.2 * len(obj.x))]) for item in (self.curve, self.cursor, self.xrange): plot.add_item(item) plot.replot() plot.set_active_item(self.xrange) self.xrange_changed(self.xrange, *self.xrange.get_range()) # pylint: disable=unused-argument def xrange_changed(self, item: XRangeSelection, xmin: float, xmax: float) -> None: """X range changed""" self.__x_range = sorted([xmin, xmax]) imin, imax = np.searchsorted(self.__signal.x, self.__x_range) if imin == imax: return self.cursor.set_pos(0, np.mean(self.__signal.y[imin:imax])) plot = self.get_plot() plot.replot() def cursor_changed(self, item: Marker) -> None: """Cursor changed""" _x, self.__baseline = item.get_pos() def get_baseline(self) -> float: """Get baseline""" return self.__baseline def get_x_range(self) -> tuple[float, float]: """Get x range""" return self.__x_range DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/signalcursor.py000066400000000000000000000204601524425144300246550ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Signal horizontal or vertical cursor selection dialog. .. autoclass:: SignalCursorDialog :members: """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations from typing import TYPE_CHECKING, Literal import numpy as np from guidata.configtools import get_icon from plotpy.builder import make from plotpy.plot import PlotDialog from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from sigima.tools.signal.features import find_x_values_at_y from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object from sigimax.config import _, get_conf from sigimax.utils.qthelpers import block_signals, resize_widget_to_parent __all__ = [ "SignalCursorDialog", ] if TYPE_CHECKING: from plotpy.items import CurveItem, Marker from qtpy.QtWidgets import QWidget from sigima.objects import SignalObj class SignalCursorDialog(PlotDialog): """Signal horizontal or vertical cursor selection dialog. Args: signal: signal object parent: parent widget. Defaults to None. """ def __init__( self, signal: SignalObj, cursor_orientation: Literal["horizontal", "vertical"], parent: QWidget | None = None, ) -> None: assert cursor_orientation in ( "horizontal", "vertical", ), "cursor_orientation must be 'horizontal' or 'vertical'" self.__curve_styles = CURVESTYLES.style_generator() self.__cursor_orientation = cursor_orientation self.__signal = signal self.__x_value: float | None = None self.__y_value: float | None = None self.curve: CurveItem | None = None self.hcursor: Marker | None = None self.vcursor: Marker | None = None self.xlineedit: QW.QLineEdit | None = None self.ylineedit: QW.QLineEdit | None = None if cursor_orientation == "horizontal": title = _("Select X value with cursor") else: title = _("Select Y value with cursor") super().__init__(title=title, edit=True, parent=parent) self.setObjectName("SignalCursorDialog") if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) legend = make.legend("TR") self.get_plot().add_item(legend) self.__setup_dialog() resize_widget_to_parent(self, aspect_ratio=1.0) def __setup_dialog(self) -> None: """Setup dialog box""" apply_button = QW.QPushButton(_("Apply")) apply_button.setIcon(get_icon("apply.svg")) apply_button.setToolTip(_("Apply cursor position")) xlabel = QW.QLabel("X=") ylabel = QW.QLabel("Y=") self.xlineedit = QW.QLineEdit() self.xlineedit.editingFinished.connect(self.xlineedit_editing_finished) x_validator = QG.QDoubleValidator() x_validator.setLocale(QC.QLocale("C")) self.xlineedit.setValidator(x_validator) self.ylineedit = QW.QLineEdit() self.ylineedit.editingFinished.connect(self.ylineedit_editing_finished) y_validator = QG.QDoubleValidator() y_validator.setLocale(QC.QLocale("C")) self.ylineedit.setValidator(y_validator) self.xlineedit.setReadOnly(self.__cursor_orientation == "horizontal") self.xlineedit.setDisabled(self.__cursor_orientation == "horizontal") self.ylineedit.setReadOnly(self.__cursor_orientation == "vertical") self.ylineedit.setDisabled(self.__cursor_orientation == "vertical") xygroup = QW.QGroupBox(_("Cursor position")) xylayout = QW.QHBoxLayout() xylayout.addWidget(xlabel) xylayout.addWidget(self.xlineedit) if self.__cursor_orientation == "vertical": xylayout.addWidget(apply_button) apply_button.clicked.connect(self.xlineedit_editing_finished) xylayout.addStretch() xylayout.addSpacing(10) xylayout.addWidget(ylabel) xylayout.addWidget(self.ylineedit) if self.__cursor_orientation == "horizontal": xylayout.addWidget(apply_button) apply_button.clicked.connect(self.ylineedit_editing_finished) xygroup.setLayout(xylayout) self.button_layout.insertWidget(0, xygroup) obj = self.__signal with CURVESTYLES.alternative(self.__curve_styles): self.curve = create_adapter_from_object(obj).make_item() plot = self.get_plot() plot.set_antialiasing(True) xcursor = make.xcursor(np.mean(obj.x), np.mean(obj.y), "X = %g, Y = %g") xcursor.set_selectable(False) param = xcursor.markerparam param.symbol.facecolor = "blue" param.symbol.edgecolor = "cyan" param.symbol.size = 9 param.line.style = "DotLine" param.line.color = "blue" param.line.width = 2.0 param.update_item(xcursor) plot.SIG_MARKER_CHANGED.connect(self.cursor_changed) if self.__cursor_orientation == "horizontal": self.hcursor = make.hcursor(np.mean(obj.y), "Y = %g") self.vcursor = xcursor self.vcursor.setVisible(False) else: self.vcursor = make.vcursor(np.mean(obj.x), "X = %g") self.hcursor = xcursor self.hcursor.setVisible(False) for item in (self.curve, self.vcursor, self.hcursor): plot.add_item(item) plot.replot() if self.__cursor_orientation == "horizontal": plot.set_active_item(self.hcursor) self.cursor_changed(self.hcursor) else: plot.set_active_item(self.vcursor) self.cursor_changed(self.vcursor) def cursor_changed(self, item: Marker) -> None: """Cursor changed""" sig = self.__signal plot = self.get_plot() if self.__cursor_orientation == "horizontal" and item is self.hcursor: _x, y = item.get_pos() x = None x_values = find_x_values_at_y(sig.x, sig.y, y) if len(x_values) > 0: x = x_values[0] with block_signals(plot): self.vcursor.set_pos(x, y) self.vcursor.setVisible(x is not None) self.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(x is not None) elif self.__cursor_orientation == "vertical" and item is self.vcursor: x, _y = item.get_pos() y_index = np.searchsorted(self.__signal.x, x) if x < self.__signal.x[0] or y_index >= len(self.__signal.y): y = None else: y = self.__signal.y[y_index] with block_signals(plot): self.hcursor.set_pos(x, y) self.hcursor.setVisible(True) self.hcursor.setVisible(y is not None) self.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(y is not None) self.xlineedit.setText(f"{x:g}" if x is not None else "") self.ylineedit.setText(f"{y:g}" if y is not None else "") self.__x_value, self.__y_value = x, y def xlineedit_editing_finished(self) -> None: """X line edit editing finished""" try: x = float(self.xlineedit.text()) _x, y = self.vcursor.get_pos() if self.__cursor_orientation == "horizontal": self.hcursor.set_pos(x, y) else: self.vcursor.set_pos(x, y) except ValueError: pass plot = self.get_plot() plot.replot() def ylineedit_editing_finished(self) -> None: """Y line edit editing finished""" try: y = float(self.ylineedit.text()) x, _y = self.hcursor.get_pos() if self.__cursor_orientation == "horizontal": self.hcursor.set_pos(x, y) else: self.vcursor.set_pos(x, y) except ValueError: pass plot = self.get_plot() plot.replot() def get_cursor_position(self) -> tuple[float, float]: """Get cursor position""" return self.__x_value, self.__y_value def get_x_value(self) -> float: """Get cursor x value""" return self.__x_value def get_y_value(self) -> float: """Get cursor y value""" return self.__y_value DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/signaldeltax.py000066400000000000000000000134131524425144300246210ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ GUI dialog for analyzing signals and calculating full width at Y. .. autoclass:: SignalDeltaXDialog :members: """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations import warnings from typing import TYPE_CHECKING import numpy as np from guidata.configtools import get_icon from plotpy.builder import make from plotpy.plot import PlotDialog from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from sigima.tools.signal.pulse import full_width_at_y from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object from sigimax.config import _, get_conf from sigimax.utils.qthelpers import resize_widget_to_parent __all__ = [ "SignalDeltaXDialog", ] if TYPE_CHECKING: from plotpy.items import CurveItem, Marker, XRangeSelection from qtpy.QtWidgets import QWidget from sigima.objects import SignalObj class SignalDeltaXDialog(PlotDialog): """Signal Delta X dialog. Args: signal: signal object parent: parent widget. Defaults to None. """ def __init__(self, signal: SignalObj, parent: QWidget | None = None) -> None: self.__curve_styles = CURVESTYLES.style_generator() self.__signal = signal self.__coords: list[float, float, float, float] | None = None self.curve: CurveItem | None = None self.hcursor: Marker | None = None self.delta_xrange: XRangeSelection | None = None self.deltaxlineedit: QW.QLineEdit | None = None self.ylineedit: QW.QLineEdit | None = None title = _("Select Y value with cursor") super().__init__(title=title, edit=True, parent=parent) self.setObjectName("SignalCursorDialog") if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) legend = make.legend("TR") self.get_plot().add_item(legend) self.__setup_dialog() resize_widget_to_parent(self, aspect_ratio=1.0) def __setup_dialog(self) -> None: """Setup dialog box""" apply_button = QW.QPushButton(_("Apply")) apply_button.setIcon(get_icon("apply.svg")) apply_button.setToolTip(_("Apply cursor position")) xlabel = QW.QLabel("∆X=") ylabel = QW.QLabel("Y=") self.deltaxlineedit = QW.QLineEdit() self.deltaxlineedit.setReadOnly(True) self.deltaxlineedit.setDisabled(True) self.ylineedit = QW.QLineEdit() self.ylineedit.editingFinished.connect(self.ylineedit_editing_finished) y_validator = QG.QDoubleValidator() y_validator.setLocale(QC.QLocale("C")) self.ylineedit.setValidator(y_validator) xygroup = QW.QGroupBox(_("Cursor position")) xylayout = QW.QHBoxLayout() xylayout.addWidget(xlabel) xylayout.addWidget(self.deltaxlineedit) xylayout.addWidget(ylabel) xylayout.addWidget(self.ylineedit) xylayout.addWidget(apply_button) vlayout = QW.QVBoxLayout() vlayout.addLayout(xylayout) self.warning_label = QW.QLabel() vlayout.addWidget(self.warning_label) apply_button.clicked.connect(self.ylineedit_editing_finished) xygroup.setLayout(vlayout) self.button_layout.insertWidget(0, xygroup) obj = self.__signal with CURVESTYLES.alternative(self.__curve_styles): self.curve = create_adapter_from_object(obj).make_item() plot = self.get_plot() plot.set_antialiasing(True) self.delta_xrange = make.xrange(0.0, 1.0) self.delta_xrange.setVisible(False) self.delta_xrange.set_style("roi", "s/readonly") self.delta_xrange.set_selectable(False) plot.SIG_MARKER_CHANGED.connect(self.cursor_changed) self.hcursor = make.hcursor(np.mean(obj.y), "Y = %g") for item in (self.curve, self.delta_xrange, self.hcursor): plot.add_item(item) plot.replot() plot.set_active_item(self.hcursor) self.cursor_changed(self.hcursor) def cursor_changed(self, item: Marker) -> None: """Cursor changed""" sig = self.__signal _x, y = item.get_pos() try: with warnings.catch_warnings(record=True) as w: self.__coords = full_width_at_y(sig.x, sig.y, y) if np.nan in self.__coords: raise ValueError("Invalid coordinates") delta_str = f"{self.__coords[2] - self.__coords[0]:g}" ok = True if len(w) > 0: self.warning_label.setText("⚠� " + str(w[-1].message)) else: self.warning_label.setText("") self.delta_xrange.setVisible(True) self.delta_xrange.set_range(self.__coords[0], self.__coords[2]) except ValueError: delta_str = "" ok = False self.delta_xrange.setVisible(False) self.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(ok) self.deltaxlineedit.setText(delta_str) self.ylineedit.setText(f"{y:g}" if y is not None else "") def ylineedit_editing_finished(self) -> None: """Y line edit editing finished""" try: y = float(self.ylineedit.text()) x, _y = self.hcursor.get_pos() self.hcursor.set_pos(x, y) except ValueError: pass plot = self.get_plot() plot.replot() def get_coords(self) -> tuple[float, float, float, float]: """Return coordinates of segment associated to the width at Y""" return self.__coords def get_y_value(self) -> float: """Get cursor y value""" _x, y = self.hcursor.get_pos() return y DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/signalpeak.py000066400000000000000000000151601524425144300242610ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Signal peak detection feature .. autoclass:: SignalPeakDetectionDialog :members: """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations from typing import TYPE_CHECKING import numpy as np from guidata.configtools import get_icon from plotpy.builder import make from plotpy.plot import PlotDialog from qtpy import QtCore as QC from qtpy import QtWidgets as QW from sigima.tools.signal.peakdetection import peak_indices from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object from sigimax.config import _, get_conf from sigimax.utils.qthelpers import resize_widget_to_parent __all__ = [ "SignalPeakDetectionDialog", ] if TYPE_CHECKING: from plotpy.items import Marker from qtpy.QtWidgets import QWidget from sigima.objects import SignalObj class DistanceSlider(QW.QWidget): """Minimum distance slider Args: parent: parent widget. Defaults to None. """ TITLE = _("Minimum distance:") SIG_VALUE_CHANGED = QC.Signal(int) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.slider = QW.QSlider(QC.Qt.Horizontal) self.label = QW.QLabel() layout = QW.QHBoxLayout() layout.addWidget(self.label) layout.addWidget(self.slider) self.setLayout(layout) def value_changed(self, value: int) -> None: """Slider value has changed Args: value: slider value """ plural = "s" if value > 1 else "" self.label.setText(f"{self.TITLE} {value} point{plural}") self.SIG_VALUE_CHANGED.emit(value) def setup_slider(self, value: int, maxval: int) -> None: """Setup slider Args: value: initial value maxval: maximum value """ self.slider.setMinimum(1) self.slider.setMaximum(maxval) self.slider.setValue(value) self.slider.setTickPosition(QW.QSlider.TicksBothSides) self.value_changed(value) self.slider.valueChanged.connect(self.value_changed) class SignalPeakDetectionDialog(PlotDialog): """Signal Peak detection dialog Args: signal: signal object parent: parent widget. Defaults to None. """ def __init__(self, signal: SignalObj, parent: QWidget | None = None) -> None: self.__curve_styles = CURVESTYLES.style_generator() self.peaks = None self.peak_indices = None self.in_curve = None self.in_threshold = None self.in_threshold_cursor = None self.co_results = None self.co_positions = None self.co_markers = None self.min_distance = None self.distance_slider: DistanceSlider | None = None super().__init__(title=_("Signal peak detection"), edit=True, parent=parent) self.setObjectName("peakdetection") if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) legend = make.legend("TR") self.get_plot().add_item(legend) self.__signal = signal.copy() self.__setup_dialog() resize_widget_to_parent(self, parent, aspect_ratio=1.0) def populate_plot_layout(self) -> None: # Reimplement PlotDialog method """Populate the plot layout""" super().populate_plot_layout() self.distance_slider = DistanceSlider(self) self.add_widget(self.distance_slider, 1, 0, 1, 1) def __setup_dialog(self) -> None: """Setup dialog box""" obj = self.__signal with CURVESTYLES.alternative(self.__curve_styles): self.in_curve = create_adapter_from_object(obj).make_item() plot = self.get_plot() plot.set_antialiasing(True) plot.add_item(self.in_curve) self.in_threshold = 0.5 * (np.max(obj.y) - np.min(obj.y)) + np.min(obj.y) cursor = make.hcursor(self.in_threshold) self.in_threshold_cursor = cursor plot.add_item(self.in_threshold_cursor) self.co_results = make.label("", "TL", (0, 0), "TL") plot.add_item(self.co_results) plot.SIG_MARKER_CHANGED.connect(self.hcursor_changed) self.min_distance = 1 self.distance_slider.setup_slider(self.min_distance, len(obj.y) // 4) self.distance_slider.SIG_VALUE_CHANGED.connect(self.minimum_distance_changed) self.compute_peaks() # Replot, otherwise, it's not possible to set active item: plot.replot() plot.set_active_item(cursor) def get_peaks(self) -> list[tuple[float, float]]: """Return peaks coordinates""" return self.peaks def get_peak_indices(self) -> list[int]: """Return peak indices""" return self.peak_indices def get_threshold(self) -> float: """Return relative threshold""" y = self.__signal.y return (self.in_threshold - np.min(y)) / (np.max(y) - np.min(y)) def get_min_dist(self) -> int: """Return minimum distance""" return self.min_distance def compute_peaks(self) -> None: """Compute peak detection""" x, y = self.__signal.xydata plot = self.get_plot() self.peak_indices = peak_indices( y, thres=self.in_threshold, min_dist=self.min_distance, thres_abs=True, ) self.peaks = [(x[index], y[index]) for index in self.peak_indices] markers = [ make.marker( pos, movable=False, color="orange", markerstyle="|", linewidth=1, marker="NoSymbol", linestyle="DashLine", ) for pos in self.peaks ] if self.co_markers is not None: plot.del_items(self.co_markers) self.co_markers = markers for item in self.co_markers: plot.add_item(item) positions = [str(marker.get_pos()[0]) for marker in markers] prefix = f"{_('Peaks:')}
" self.co_results.set_text(prefix + "
".join(positions)) def hcursor_changed(self, marker: Marker) -> None: """Horizontal cursor position has changed Args: marker: marker item """ _x, y = marker.get_pos() self.in_threshold = y self.compute_peaks() def minimum_distance_changed(self, value: int) -> None: """Minimum distance changed Args: value: minimum distance value """ self.min_distance = value self.compute_peaks() self.get_plot().replot() DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/splashscreen.py000066400000000000000000000166341524425144300246440ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Splash screen ============= The :mod:`sigimax.widgets.splashscreen` module provides a configurable splash screen for SigimaX-derived applications. Derived applications can customize the splash screen by providing a :class:`SplashScreenConfig` instance, or by subclassing :class:`SigimaXSplashScreen` for advanced rendering. Basic usage:: from sigimax.widgets.splashscreen import SplashScreenConfig, SigimaXSplashScreen config = SplashScreenConfig( image_path="path/to/splash.png", app_name="MyApp", app_version="1.0.0", tagline="Scientific Data Processing", ) splash = SigimaXSplashScreen(config) splash.show() splash.show_message("Loading modules...") # ... heavy initialization ... splash.finish(main_window) Factory from global configuration:: splash = SigimaXSplashScreen.from_conf() if splash is not None: splash.show() # ... splash.finish(main_window) .. autoclass:: SplashScreenConfig .. autoclass:: SigimaXSplashScreen """ from __future__ import annotations import dataclasses from typing import TYPE_CHECKING from guidata.configtools import get_image_file_path from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW if TYPE_CHECKING: pass __all__ = [ "SigimaXSplashScreen", "SplashScreenConfig", ] @dataclasses.dataclass class SplashScreenConfig: """Configuration for a splash screen. All fields are optional except *image_path*. When *image_path* is empty or ``None``, no splash screen is shown. Args: image_path: Absolute or relative path to the splash image (PNG, SVG, or any format supported by :class:`QPixmap`). If empty or ``None``, the splash screen is disabled. app_name: Application name overlaid on the splash image. app_version: Application version overlaid on the splash image. tagline: Optional subtitle displayed below the version. show_progress: If ``True``, progress messages are displayed at the bottom of the splash screen via :meth:`SigimaXSplashScreen.show_message`. text_color: Color used for overlay text (default: white). text_alignment: Qt alignment flags for overlay text (default: bottom-left). """ image_path: str | None = None app_name: str = "" app_version: str = "" tagline: str = "" show_progress: bool = True text_color: QG.QColor = dataclasses.field( default_factory=lambda: QG.QColor("white") ) text_alignment: QC.Qt.AlignmentFlag = dataclasses.field( default_factory=lambda: QC.Qt.AlignBottom | QC.Qt.AlignLeft ) @property def is_enabled(self) -> bool: """Return ``True`` if the splash screen should be shown.""" return bool(self.image_path) @classmethod def from_conf(cls) -> SplashScreenConfig: """Build a :class:`SplashScreenConfig` from the global :data:`sigimax.config.CONF` options. Returns: Configuration instance populated from global options. """ # Import here to avoid circular imports from sigimax.config import get_conf # pylint: disable=import-outside-toplevel conf = get_conf() return cls( image_path=conf.splash_image_path.get() or None, app_name=conf.app_name.get(), app_version=conf.app_version.get(), tagline=conf.app_desc.get(), show_progress=conf.splash_show_progress.get(), ) class SigimaXSplashScreen(QW.QSplashScreen): """Configurable splash screen for SigimaX-derived applications. Creates a :class:`QSplashScreen` from a :class:`SplashScreenConfig`. If the configuration specifies an application name/version, they are painted as overlay text on top of the splash image. Args: config: Splash screen configuration. If ``None``, a default configuration is built from :data:`sigimax.config.CONF`. """ def __init__(self, config: SplashScreenConfig | None = None) -> None: self._config = config or SplashScreenConfig.from_conf() pixmap = self._load_pixmap() super().__init__(pixmap, QC.Qt.WindowStaysOnTopHint) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def show_message(self, message: str) -> None: """Display a progress message on the splash screen. The message is shown only if :attr:`SplashScreenConfig.show_progress` is ``True``. Args: message: The progress message to display. """ if self._config.show_progress: self.showMessage( message, int(self._config.text_alignment), self._config.text_color, ) # Process events so the message is actually painted QW.QApplication.processEvents() @classmethod def from_conf(cls) -> SigimaXSplashScreen | None: """Factory: build a splash screen from the global configuration. Returns: A :class:`SigimaXSplashScreen` instance, or ``None`` if the configuration does not specify a splash image. """ config = SplashScreenConfig.from_conf() if not config.is_enabled: return None return cls(config) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _load_pixmap(self) -> QG.QPixmap: """Load the splash image as a :class:`QPixmap`. Returns: The loaded pixmap. If the image cannot be loaded, a minimal fallback pixmap is generated. """ path = self._config.image_path or "" pixmap = QG.QPixmap(path) if pixmap.isNull() and path: try: resolved_path = get_image_file_path(path, default=None) except RuntimeError: resolved_path = "" pixmap = QG.QPixmap(resolved_path) if pixmap.isNull(): pixmap = self._create_fallback_pixmap() return pixmap def _create_fallback_pixmap(self) -> QG.QPixmap: """Create a minimal fallback pixmap when no image is available. Returns: A 480x280 pixmap with the application name drawn on a dark background. """ width, height = 480, 280 pixmap = QG.QPixmap(width, height) pixmap.fill(QG.QColor(40, 40, 40)) painter = QG.QPainter(pixmap) painter.setPen(self._config.text_color) # Application name font = painter.font() font.setPointSize(24) font.setBold(True) painter.setFont(font) name = self._config.app_name or "SigimaX" painter.drawText( QC.QRect(0, 0, width, height), int(QC.Qt.AlignCenter), name, ) # Version if self._config.app_version: font.setPointSize(12) font.setBold(False) painter.setFont(font) painter.drawText( QC.QRect(0, height // 2 + 20, width, 40), int(QC.Qt.AlignHCenter | QC.Qt.AlignTop), f"v{self._config.app_version}", ) painter.end() return pixmap DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/status.py000066400000000000000000000143641524425144300234730ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX main window status bar widgets .. autoclass:: BaseStatus :members: .. autoclass:: ConsoleStatus :members: .. autoclass:: MemoryStatus :members: """ from __future__ import annotations import os import psutil from guidata.configtools import get_icon from guidata.qthelpers import get_std_icon from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from sigimax.config import DEBUG, _, get_conf from sigimax.env import execenv __all__ = [ "BaseStatus", "ConsoleStatus", "MemoryStatus", ] class BaseStatus(QW.QWidget): """Base status widget. Args: delay (int | None): update interval (s). If None, widget will not be updated. parent (QWidget): parent widget """ def __init__( self, delay: int | None = None, parent: QW.QWidget | None = None ) -> None: super().__init__(parent) if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) layout = QW.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) self.icon = QW.QLabel() self.label = QW.QLabel() layout.addWidget(self.icon) layout.addWidget(self.label) if delay is not None: self.timer = QC.QTimer() self.timer.timeout.connect(self.update_status) self.timer.start(delay * 1000) def set_icon(self, icon: QG.QIcon | str | None) -> None: """Set icon. Args: icon (QIcon | None): icon """ size = self.label.sizeHint().height() if isinstance(icon, str): icon = get_icon(icon) pixmap = QG.QPixmap() if icon is None else icon.pixmap(size, size) self.icon.setPixmap(pixmap) def update_status(self) -> None: """Update status widget""" raise NotImplementedError class ConsoleStatus(BaseStatus): """Console status widget. Shows a message if an error or warning has been logged to the console. Shows a button to show the console, only if the console is hidden. Args: parent (QWidget): parent widget """ SIG_SHOW_CONSOLE = QC.Signal() def __init__(self, parent: QW.QWidget | None = None) -> None: super().__init__(None, parent) self.label.setText(_("Internal console")) self.label.setToolTip( _( "Click to show the internal console.\n" "The icon will turn red if an error or warning is logged." ) ) self.label.setCursor(QG.QCursor(QC.Qt.PointingHandCursor)) self.label.mouseReleaseEvent = self.on_click self.ok_icon = get_std_icon("MessageBoxInformation") self.ko_icon = get_std_icon("MessageBoxWarning") self.has_errors = False self.update_status() def on_click(self, event: QG.QMouseEvent) -> None: """Handle mouse click event on label. Args: event: mouse event """ if event.button() == QC.Qt.LeftButton: self.SIG_SHOW_CONSOLE.emit() def console_visibility_changed(self, visible: bool) -> None: """Handle console visibility changed event. Args: visible (bool): console visibility """ if visible: # Hide this status widget when console is visible self.hide() else: self.show() self.update_status() def exception_occurred(self) -> None: """Handle exception occurred event""" self.has_errors = True self.update_status() def update_status(self) -> None: """Update status widget""" if self.has_errors: self.set_icon(self.ko_icon) self.label.setStyleSheet("color: red") self.label.setToolTip( _( "Click to show the internal console.\n" "An error or warning has been logged." ) ) else: self.set_icon(self.ok_icon) self.label.setStyleSheet("") self.label.setToolTip( _( "Click to show the internal console.\n" "No error or warning has been logged." ) ) class MemoryStatus(BaseStatus): """Memory status widget. Args: threshold (int): available memory thresold (MB) delay (int): update interval (s) parent (QWidget): parent widget """ SIG_MEMORY_ALARM = QC.Signal(bool) def __init__( self, threshold: int = 500, delay: int = 2, parent: QW.QWidget | None = None ) -> None: super().__init__(delay, parent) self.demo_mode = False self.ko_icon = get_std_icon("MessageBoxWarning") self.__threshold = threshold * (1024**2) self.label.setMinimumWidth(self.label.fontMetrics().width("000%")) self.update_status() def set_demo_mode(self, state: bool) -> None: """Set demo mode state (used when taking screenshots). The demo mode allows to take screenshots which always look the same. (this will set memory usage to a constant value). If demo mode is set to False, memory usage will be set to actual value. Args: state (bool): demo mode state """ self.demo_mode = state self.update_status() def update_status(self) -> None: """Update status widget""" mem = psutil.virtual_memory() memok = mem.available > self.__threshold self.SIG_MEMORY_ALARM.emit(not memok) txtlist = [ f"%s {mem.available // (1024**2)} MB" % _("Memory available:"), f"%s {mem.used // (1024**2)} MB" % _("Memory used:"), f"%s {self.__threshold // (1024**2)} MB" % _("Alarm threshold:"), ] txt = os.linesep.join(txtlist) self.setToolTip(txt) if DEBUG and not memok: execenv.log(self, txt) self.label.setStyleSheet("" if memok else "color: red") self.set_icon("libre-tech-ram.svg" if memok else self.ko_icon) mem_percent = 65 if self.demo_mode else int(mem.percent) self.label.setText(_("Memory:") + f" {mem_percent}%") DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/warningerror.py000066400000000000000000000202061524425144300246570ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ Module providing a warning/error message box .. autoclass:: WarningErrorMessageBox :members: .. autofunction:: show_warning_error """ import os.path as osp import re import subprocess import traceback from guidata.config import CONF from guidata.configtools import get_font, get_icon from guidata.qthelpers import exec_dialog, get_std_icon from guidata.widgets.console.shell import PythonShellWidget from qtpy import QtCore as QC from qtpy import QtWidgets as QW from sigimax.config import _, get_conf, get_mod_source_dir __all__ = [ "WarningErrorMessageBox", "go_to_error", "show_warning_error", ] def go_to_error(text: str) -> None: """Go to error: open file with external editor, and go to line number Args: text (str): Error text """ pattern = r'File "(.+)", line (\d+),' match = re.search(pattern, text) if match: path = match.group(1) line_number = match.group(2) mod_src_dir = get_mod_source_dir() if not osp.isfile(path) and mod_src_dir is not None: otherpath = osp.join(mod_src_dir, path) if not osp.isfile(otherpath): # TODO: [P3] For frozen app, go to error is implemented only when the # source code is available locally (development mode). # How about using a web browser to open the source code on github? return path = otherpath if not osp.isfile(path): return # File not found (unhandled case) fdict = {"path": path, "line_number": line_number} args = get_conf().external_editor_args.get().format(**fdict).split(" ") editor_path = get_conf().external_editor_path.get() subprocess.run([editor_path] + args, shell=True, check=False) def insert_spaces(text: str, nbchars: int) -> str: """ Inserts spaces regularly in a string, every nbchars characters, after certain characters (",", ";", "-", "+", "*", ")"), and keeps searching until detecting one of those characters. Args: text (str): The input string. nbchars (int): The number of characters after which a space should be inserted. Returns: str: The modified string with spaces inserted. """ special_chars = (",", ";", "-", "+", "*", ")", "_") new_text = "" index = 0 while index < len(text): if ( index + nbchars < len(text) and text[index + nbchars] not in special_chars and not any(c in special_chars for c in text[index : index + nbchars + 1]) ): new_text += text[index : index + nbchars] # Append characters index += nbchars else: new_text += text[index : index + nbchars] + " " # Insert space index += nbchars return new_text class WarningErrorMessageBox(QW.QDialog): """Warning/Error message box Args: parent (QW.QWidget): parent widget category (str): message category ("error" or "warning") context (str | None): context. Defaults to None. message (str | None): message. Defaults to None. tip (str | None): tip. Defaults to None. """ def __init__( self, parent: QW.QWidget, category: str, context: str = None, message: str = None, tip: str = None, ) -> None: super().__init__(parent) assert category in ("error", "warning") self.setWindowTitle(parent.window().objectName()) if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) self.shell = PythonShellWidget(self, read_only=True) self.shell.go_to_error.connect(go_to_error) font = get_font(CONF, "console") font.setPointSize(9) self.shell.set_font(font) message = traceback.format_exc() if message is None else message self.shell.insert_text(message, at_end=True, error=True) bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok) bbox.accepted.connect(self.accept) if category == "warning": bbox.addButton(QW.QDialogButtonBox.Ignore).clicked.connect(self.ignore) layout = QW.QVBoxLayout() if category == "error": width, height = 725, 400 icon = "MessageBoxCritical" tb_title = _("Error message") tb_text = _("The following traceback may help to understand the problem:") else: width, height = 725, 200 icon = "MessageBoxWarning" tb_title = _("Warning message") tb_text = _("Please take into account the following warning message:") if context is not None: context = insert_spaces(context, 80) msgprefix = _("An error has occured during the following context:") text = "
".join([msgprefix, f"{context}"]) ct_groupbox = QW.QGroupBox(_("Context"), self) ct_layout = QW.QHBoxLayout() ct_image_layout = QW.QVBoxLayout() ct_image = QW.QLabel() ct_image.setPixmap(get_std_icon(icon).pixmap(24, 24)) ct_image.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed) ct_image_layout.addWidget(ct_image) ct_image_layout.addStretch() ct_layout.addLayout(ct_image_layout) ct_label = QW.QLabel(text) ct_label.setWordWrap(True) ct_label.setAlignment(QC.Qt.AlignLeft | QC.Qt.AlignTop) ct_layout.addWidget(ct_label) ct_groupbox.setLayout(ct_layout) ct_groupbox.setSizePolicy( QW.QSizePolicy.MinimumExpanding, QW.QSizePolicy.Fixed ) layout.addWidget(ct_groupbox) tb_groupbox = QW.QGroupBox(tb_title, self) tb_layout = QW.QVBoxLayout() tb_layout.addWidget(QW.QLabel(tb_text)) tb_layout.addWidget(self.shell) tb_groupbox.setLayout(tb_layout) layout.addWidget(tb_groupbox) if tip is not None: tip_groupbox = QW.QGroupBox(_("Tip"), self) tip_layout = QW.QHBoxLayout() tip_image_layout = QW.QVBoxLayout() tip_image = QW.QLabel() tip_image.setPixmap(get_std_icon("MessageBoxInformation").pixmap(24, 24)) tip_image.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed) tip_image_layout.addWidget(tip_image) tip_image_layout.addStretch() tip_layout.addLayout(tip_image_layout) tip_label = QW.QLabel(tip) tip_label.setWordWrap(True) tip_label.setAlignment(QC.Qt.AlignLeft | QC.Qt.AlignTop) tip_layout.addWidget(tip_label) tip_groupbox.setLayout(tip_layout) tip_groupbox.setSizePolicy( QW.QSizePolicy.MinimumExpanding, QW.QSizePolicy.Fixed ) layout.addWidget(tip_groupbox) layout.addSpacing(10) if category == "warning": layout.addWidget( QW.QLabel( _( "Please click on the 'Ignore' button to " "ignore this warning next time." ) ) ) layout.addSpacing(10) layout.addWidget(bbox) self.setLayout(layout) self.resize(width, height) bbox.button(QW.QDialogButtonBox.Ok).setFocus() def ignore(self): """Ignore warning next time""" get_conf().ignore_warnings.set(True) self.accept() def show_warning_error( parent: QW.QWidget, category: str, context: str = None, message: str = None, tip: str = None, ) -> None: """Show error message Args: parent (QW.QWidget): parent widget category (str): message category ("error" or "warning") context (str | None): context. Defaults to None. message (str | None): message. Defaults to None. tip (str | None): tip. Defaults to None. """ if category == "warning" and get_conf().ignore_warnings.get(): return dlg = WarningErrorMessageBox(parent, category, context, message, tip) exec_dialog(dlg) DataLab-Platform-SigimaX-0182bf4/sigimax/widgets/wizard.py000066400000000000000000000250261524425144300234450ustar00rootroot00000000000000# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ SigimaX Wizard Widget --------------------- The SigimaX Wizard is a widget that guides the user through a series of steps to complete a task. It is implemented as a series of pages, each of which is a separate widget. The `Wizard` class is the main widget that contains the pages. The `WizardPage` class is the base class for the pages. This module is strongly inspired from Qt's `QWizard` and `QWizardPage` classes. .. note:: The only motivation for reimplementing the wizard widget is to support complete styling with `QPalette` and `QStyle` (e.g. `guidata`'s dark mode is not supported on Windows). .. autoclass:: Wizard :members: .. autoclass:: WizardPage :members: """ from __future__ import annotations from guidata.configtools import get_icon from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from qtpy.compat import getopenfilename from qtpy.QtWidgets import QWidget from sigimax.config import _, get_conf __all__ = [ "Wizard", "WizardPage", ] class WizardPage(QW.QWidget): """Wizard page base class We create our own wizard page class instead of using QWizardPage because the latter does not support complete styling with `QPalette` and `QStyle` (e.g. `guidata`'s dark mode is not supported on Windows). This class reimplements the `QWizardPage` features. """ SIG_INITIALIZE_PAGE = QC.Signal() SIG_VALID_STATE_CHANGED = QC.Signal() def __init__(self, parent: QW.QWidget | None = None) -> None: super().__init__(parent) if parent is None: self.setWindowIcon(get_icon(get_conf().app_logo_path.get())) self.__is_valid: bool = True self.wizard: Wizard | None = None self._main_layout = QW.QVBoxLayout() self._user_layout = QW.QVBoxLayout() self._title_label = QW.QLabel("") font = self._title_label.font() font.setPointSize(font.pointSize() + 4) font.setBold(True) self._title_label.setFont(font) self._title_label.setStyleSheet("color: #1E90FF") horiz_line = QW.QFrame() horiz_line.setFrameShape(QW.QFrame.HLine) horiz_line.setFrameShadow(QW.QFrame.Sunken) self._subtitle_label = QW.QLabel("") self._main_layout.addWidget(self._title_label) self._main_layout.addWidget(self._subtitle_label) self._main_layout.addWidget(horiz_line) self._main_layout.addLayout(self._user_layout) self.setLayout(self._main_layout) def set_wizard(self, wizard: Wizard) -> None: """Set the wizard""" self.wizard = wizard def get_wizard(self) -> Wizard: """Return the wizard""" return self.wizard def set_title(self, title: str) -> None: """Set the title of the page""" self._title_label.setText(title) def set_subtitle(self, subtitle: str) -> None: """Set the subtitle of the page""" self._subtitle_label.setText(subtitle) def set_valid(self, is_valid: bool) -> None: """Set the page as valid""" self.__is_valid = is_valid self.SIG_VALID_STATE_CHANGED.emit() def is_valid(self) -> bool: """Return whether the page is valid""" return self.__is_valid def add_to_layout(self, layout: QW.QLayout | QW.QWidget) -> None: """Add a layout to the user layout""" if isinstance(layout, QW.QWidget): self._user_layout.addWidget(layout) else: self._user_layout.addLayout(layout) def add_stretch(self) -> None: """Add a stretch to the user layout""" self._user_layout.addStretch() def initialize_page(self) -> None: """Initialize the page""" self.SIG_INITIALIZE_PAGE.emit() def validate_page(self) -> bool: """Validate the page""" return self.is_valid() class Wizard(QW.QDialog): """Wizard base class""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) _main_layout = QW.QVBoxLayout() self.setLayout(_main_layout) self._pages_widget = QW.QStackedWidget() _main_layout.addWidget(self._pages_widget) btn_layout = QW.QHBoxLayout() self._back_btn = QW.QPushButton(_("Back")) self._back_btn.clicked.connect(self.go_to_previous_page) self._next_btn = QW.QPushButton(_("Next")) self._next_btn.clicked.connect(self.go_to_next_page) self._finish_btn = QW.QPushButton(_("Finish")) self._finish_btn.clicked.connect(self.accept) self._cancel_btn = QW.QPushButton(_("Cancel")) self._cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(self._back_btn) btn_layout.addWidget(self._next_btn) btn_layout.addWidget(self._finish_btn) btn_layout.addWidget(self._cancel_btn) _main_layout.addLayout(btn_layout) self.setSizePolicy( QW.QSizePolicy(QW.QSizePolicy.Minimum, QW.QSizePolicy.Minimum) ) def cleanup(self) -> None: """Release page resources before the dialog is destroyed. Pages that embed native-heavy widgets (e.g. a PlotPy plot) may expose a ``cleanup`` method to tear those resources down deterministically. This avoids a Qt/PlotPy native teardown race (access violation) when several wizards are created and destroyed in sequence. """ for index in range(self._pages_widget.count()): page = self._pages_widget.widget(index) page_cleanup = getattr(page, "cleanup", None) if callable(page_cleanup): page_cleanup() def closeEvent(self, event: QG.QCloseEvent) -> None: # pylint: disable=invalid-name """Release page resources when the dialog is closed""" self.cleanup() super().closeEvent(event) def add_page(self, page: WizardPage, last_page: bool = False) -> None: """Add a page to the wizard""" page.set_wizard(self) page.SIG_INITIALIZE_PAGE.connect(self.__update_button_states) page.SIG_VALID_STATE_CHANGED.connect(self.__update_button_states) self._pages_widget.addWidget(page) if last_page: self._pages_widget.widget(0).initialize_page() def __update_button_states(self, index: int | None = None) -> None: """Update button states""" if index is None: index = self._pages_widget.currentIndex() self._back_btn.setEnabled(index > 0) not_last_page = index < self._pages_widget.count() - 1 page_valid = self._pages_widget.currentWidget().is_valid() self._next_btn.setEnabled(not_last_page and page_valid) is_last_page = index == self._pages_widget.count() - 1 self._finish_btn.setEnabled(is_last_page and page_valid) def go_to_previous_page(self) -> None: """Go to the previous page""" self._pages_widget.setCurrentIndex(self._pages_widget.currentIndex() - 1) self.__update_button_states() def go_to_next_page(self) -> None: """Go to the next page""" if self.validate_page(): self._pages_widget.setCurrentIndex(self._pages_widget.currentIndex() + 1) self.initialize_page() def initialize_page(self) -> None: """Initialize the page""" self._pages_widget.currentWidget().initialize_page() def validate_page(self) -> bool: """Validate the page""" return self._pages_widget.currentWidget().validate_page() def accept(self) -> None: """Accept the wizard""" if self.validate_page(): super().accept() class ExamplePage1(WizardPage): """Example wizard page 1""" def __init__(self) -> None: super().__init__() self.set_title(_("Welcome to the Example Wizard")) self.set_subtitle( _("This wizard will guide you through the process of importing data.") ) def initialize_page(self) -> None: """Initialize the page""" print("ExamplePage1 initialized") super().initialize_page() def validate_page(self) -> bool: """Validate the page""" print("ExamplePage1 validated") return super().validate_page() class ExamplePage2(WizardPage): """Example wizard page 2""" def __init__(self) -> None: super().__init__() self.set_title(_("Select the Source of the Data")) self.set_subtitle( _("Select the source of the data to be imported (clipboard or file).") ) self._clipboard_rb = QW.QRadioButton(_("Clipboard")) self._file_rb = QW.QRadioButton(_("File")) self._file_rb.toggled.connect(self.file_rb_toggled) self._file_le = QW.QLineEdit() self._file_btn = QW.QPushButton(_("Browse...")) self._file_btn.clicked.connect(self.browse_file) self.add_to_layout(self._clipboard_rb) self.add_to_layout(self._file_rb) self.add_to_layout(self._file_le) self.add_to_layout(self._file_btn) def initialize_page(self) -> None: """Initialize the page""" print("ExamplePage2 initialized") super().initialize_page() def file_rb_toggled(self, checked: bool) -> None: """File radio button toggled""" self._file_le.setEnabled(checked) self._file_btn.setEnabled(checked) def browse_file(self) -> None: """Browse file""" file_name, _filt = getopenfilename( self, _("Select the File to Import"), "", _("CSV Files (*.csv);;Text Files (*.txt);;All Files (*)"), ) if file_name: self._file_le.setText(file_name) def validate_page(self) -> bool: """Validate the page""" if self._file_rb.isChecked() and not self._file_le.text(): QW.QMessageBox.critical( self, _("Error"), _("Please select the file to import."), QW.QMessageBox.Ok, ) return False return True class ExampleWizard(Wizard): """Example wizard widget""" def __init__(self) -> None: super().__init__() self.setWindowTitle(_("Example Wizard")) self.add_page(ExamplePage1()) self.add_page(ExamplePage2(), last_page=True) def test_example_wizard(): """Test the import wizard""" # pylint: disable=import-outside-toplevel from guidata.qthelpers import qt_app_context with qt_app_context(): wizard = ExampleWizard() wizard.exec() if __name__ == "__main__": test_example_wizard()

{dev_by}") if cprght: about_parts.append(f"
Copyright © {cprght}") # -- Application links links = [] if app_homeurl: links.append(f'
{_("Home page")}') if app_docurl: links.append(f'{_("Documentation")}') if app_supporturl: links.append(f'{_("Support")}') if links: about_parts.append("