diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000..407cee17a --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,192 @@ +# GitHub Actions Workflows + +This document describes the GitHub Actions workflows used in the Activity Browser project. + +## Overview + +The Activity Browser project uses five GitHub Actions workflows to automate testing, deployment, and project management tasks: + +1. **Automated Testing** - Runs tests on every push and PR +2. **Canary Installation** - Daily installation checks to catch dependency issues +3. **Beta Deployment** - Publishes beta releases to PyPI and Anaconda +4. **Stable Release** - Creates releases and publishes to Anaconda +5. **Milestone Comments** - Automatically notifies users when issues are resolved in releases + +--- + +## 1. Automated Testing (`testing.yaml`) + +**Trigger:** Push or pull request to the `major` branch + +**Purpose:** Ensures code quality by running the test suite across multiple operating systems and Python versions. + +### Matrix Strategy +- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) +- **Python Versions:** 3.10, 3.11, 3.12 +- **Total combinations:** 12 test runs per trigger + +### Steps +1. Checkout code +2. Set up Python for the specified version +3. Install Qt libraries (Linux only) +4. Update pip, setuptools, and wheel +5. Install package with testing dependencies: `pip install .[testing]` +6. Run pytest with minimal output: `pytest -s --no-header --no-summary -q` + +### Environment +- Sets `QT_QPA_PLATFORM=offscreen` for headless GUI testing +- Uses `fail-fast: false` to run all combinations even if some fail + +--- + +## 2. Canary Installation (`install-canary.yaml`) + +**Trigger:** Scheduled daily at 7:00 AM UTC (cron: `0 7 * * *`) + +**Purpose:** Proactively detects dependency issues by performing fresh installations of Activity Browser from PyPI daily. + +### Matrix Strategy +- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) +- **Python Versions:** 3.10, 3.11, 3.12 +- **Timeout:** 12 minutes per job + +### Steps +1. Checkout code +2. Set up Python +3. Install activity-browser from PyPI (not from source) +4. Generate environment info with `pip freeze` +5. Upload frozen requirements as artifact for each OS/Python combination + +### Notes +- Uses `bash -e {0}` shell to exit on error +- Helps catch breaking changes in dependencies before users encounter them +- Artifacts show exact dependency versions that successfully installed + +--- + +## 3. Beta Deployment (`python-package-deploy.yml`) + +**Trigger:** Push to `beta` branch or any tag + +**Purpose:** Publishes beta versions to PyPI (test and production) and Anaconda Cloud. + +### Version Scheme +- Beta version format: `3.0.0b` where N is the commit count since commit `199b6c3` +- Calculated dynamically: `git rev-list 199b6c3..HEAD --count` + +### Steps +1. Checkout with full git history (`fetch-depth: "0"`) +2. Calculate and set version number +3. Set up Python 3.11 +4. Install `build` package +5. Build wheel and source distribution +6. **PyPI Publishing:** + - Publish to Test PyPI (with `skip-existing: true`) + - Publish to production PyPI +7. **Conda Publishing:** + - Set up Conda environment from `.github/conda-envs/build.yml` + - Build Conda package: `conda build -c conda-forge -c cmutel ./recipe/` + - Upload to Anaconda Cloud using `CONDA_LCA` secret token + +### Permissions +- Requires `id-token: write` for PyPI trusted publishing + +--- + +## 4. Stable Release (`release.yaml`) + +**Trigger:** Push of any git tag + +**Purpose:** Creates GitHub releases with auto-generated changelogs and publishes stable versions to Anaconda. + +### Steps +1. Checkout code +2. **Generate Changelog:** + - Uses `mikepenz/release-changelog-builder-action@v4` + - Configuration from `.github/changelog-configuration.json` + - Builds changelog from PRs with labels +3. **Create GitHub Release:** + - Uses `ncipollo/release-action@v1` + - Includes generated changelog as release notes + - Targets `main` branch commit +4. **Build and Upload Conda Package:** + - Set up Conda environment (Python 3.11) + - Build with `conda build recipe/` + - Upload to Anaconda using `CONDA_UPLOAD_TOKEN` secret +5. **Update Wiki:** + - Runs `.github/scripts/update_wiki.sh` to automatically update documentation + +### Notes +- Only runs on tagged commits (version releases) +- Creates public GitHub releases visible to users +- Updates project wiki documentation automatically + +--- + +## 5. Milestone Comments (`comment-milestoned-issues.yaml`) + +**Trigger:** When a milestone is closed + +**Purpose:** Automatically notifies users on closed issues when their issue has been implemented in a release. + +### Steps +1. Uses `actions/github-script@v5` to run JavaScript automation +2. Gets milestone number and title from the event +3. Lists all issues associated with the milestone +4. For each closed issue (not PRs): + - Posts a comment with: + - Link to the new release + - Instructions to update Activity Browser + - Link to subscribe to the updates mailing list + - Bot disclaimer + +### Comment Template +The bot posts a formatted note: +- Informs that the issue is implemented in version X +- Provides update instructions +- Offers subscription to updates mailing list (brightway.groups.io) +- Includes bot identification + +--- + +## Workflow Dependencies + +### Secrets Required +- `GITHUB_TOKEN` - Automatically provided by GitHub Actions +- `CONDA_LCA` - Anaconda upload token for beta releases +- `CONDA_UPLOAD_TOKEN` - Anaconda upload token for stable releases + +### Configuration Files +- `.github/conda-envs/build.yml` - Conda environment for building packages +- `.github/changelog-configuration.json` - Changelog generation configuration +- `.github/scripts/update_wiki.sh` - Wiki update script +- `recipe/meta.yaml` - Conda package recipe +- `pyproject.toml` - Python package configuration + +--- + +## Development Notes + +### Running Tests Locally +To run the same tests that CI runs: +```bash +pip install .[testing] +pytest -s --no-header --no-summary -q +``` + +### Testing Matrix Changes +When modifying the test matrix (OS or Python versions): +- Update both `testing.yaml` and `install-canary.yaml` to keep them in sync +- Consider the maintenance burden of additional combinations +- Current support: Python 3.10-3.12, Ubuntu/Windows/macOS + +### Release Process +1. **Beta release:** Push to `beta` branch → Auto-publishes beta version +2. **Stable release:** Create and push a tag → Creates GitHub release and publishes to Anaconda +3. **Close milestone:** When closing a milestone → Users get notified automatically + +### Monitoring +- Check daily canary runs to catch dependency issues +- Review failed test runs in PR checks before merging +- Monitor PyPI and Anaconda Cloud for successful uploads + diff --git a/.github/workflows/install-canary.yaml b/.github/workflows/install-canary.yaml index 066bb8958..fccc5d456 100644 --- a/.github/workflows/install-canary.yaml +++ b/.github/workflows/install-canary.yaml @@ -3,113 +3,16 @@ on: schedule: # Run the tests once every 24 hours to catch dependency problems early - cron: '0 7 * * *' - push: - branches: - - install-canary jobs: - canary-installs: - timeout-minutes: 12 - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-13] - python-version: ["3.10", "3.11"] - defaults: - run: - shell: bash -l {0} - steps: - - name: Setup python ${{ matrix.python-version }} conda environment - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: ${{ matrix.python-version }} - miniconda-version: "latest" - - name: Install activity-browser - run: | - conda create -y -n ab -c conda-forge --solver libmamba activity-browser python=${{ matrix.python-version }} - - name: Environment info - run: | - conda activate ab - conda list - conda env export - conda env export -f env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }} - path: env.yaml - - # also run install with micromamba instead of conda to have a timing comparison - canary-installs-mamba: - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.11'] - defaults: - run: - shell: bash -l {0} - steps: - - name: Setup python ${{ matrix.python-version }} conda environment - uses: mamba-org/setup-micromamba@v1 - with: - micromamba-version: '1.5.9-1' - environment-name: ab - create-args: >- - python=${{ matrix.python-version }} - activity-browser - - name: Environment info - run: | - micromamba list - micromamba env export - micromamba env export > env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }}-mamba - path: env.yaml - - conda-micromamba-comparison: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - needs: - - canary-installs - - canary-installs-mamba - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - - name: show files - run: | - ls -la - - name: correct yaml formatting - # add correct indentation to make diffing possible - uses: mikefarah/yq@master - with: - cmd: | - ls | grep mamba | while read d; do yq -i $d/env.yaml; done - - name: diff ubuntu - run: | - diff -u env-ubuntu-latest-3.11* || : - - name: diff windows - run: | - diff -u env-windows-latest-3.11* || : - - name: diff macos - run: | - diff -u env-macos-latest-3.11* || : - canary-installs-pip: timeout-minutes: 12 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ ubuntu-latest, windows-latest, macos-13 ] - python-version: [ '3.10' ] + os: [ubuntu-latest, windows-latest, macos-15, macos-latest] + py-version: ["3.10", "3.11", "3.12"] defaults: run: shell: bash -e {0} diff --git a/.github/workflows/just-release.yaml b/.github/workflows/just-release.yaml deleted file mode 100644 index 6a8921d7b..000000000 --- a/.github/workflows/just-release.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: force stable release -on: - workflow_dispatch: - - -jobs: - release: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v3 - - name: Set up conda-build environment - uses: conda-incubator/setup-miniconda@v2 - with: - python-version: 3.11 - activate-environment: build - environment-file: .github/conda-envs/build.yml - - name: Build activity-browser stable - run: | - conda build recipe/ - - name: Upload to anaconda.org - run: | - anaconda -t ${{ secrets.CONDA_UPLOAD_TOKEN }} upload \ - /usr/share/miniconda/envs/build/conda-bld/noarch/*.tar.bz2 - - name: Update wiki - run: ./.github/scripts/update_wiki.sh "Automated documentation update for $GITHUB_REF_NAME" "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml deleted file mode 100644 index befb50e2e..000000000 --- a/.github/workflows/main.yaml +++ /dev/null @@ -1,181 +0,0 @@ -name: tests and development release -on: - pull_request: - branches: - - main - - minor - push: - branches: - - main - - major - -jobs: - patch-test-environment: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Patch test environment dependencies - # This step adds the run requirements from the stable recipe to the test environment - uses: mikefarah/yq@master - with: - cmd: | - yq eval-all 'select(fi == 0).dependencies += select(fi == 1).requirements.run | select(fi == 0)' .github/conda-envs/test.yml recipe/meta.yaml > patched-environment.yml - - name: Show patched environment - run: cat patched-environment.yml - - name: Upload patched environment as artifact - uses: actions/upload-artifact@v4 - with: - name: patched-environment - path: patched-environment.yml - - tests: - runs-on: ${{ matrix.os }} - timeout-minutes: 12 - needs: patch-test-environment - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.11'] - defaults: - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v3 - - name: Download patched test environment - uses: actions/download-artifact@v4 - with: - name: patched-environment - - name: Setup python ${{ matrix.python-version }} conda environment - uses: mamba-org/setup-micromamba@v1 - with: - micromamba-version: '1.5.9-1' - environment-name: test - environment-file: patched-environment.yml - create-args: >- - python=${{ matrix.python-version }} - - name: Environment info - run: | - micromamba list - micromamba env export - micromamba env export > env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }} - path: env.yaml - - name: Install linux dependencies - if: ${{ matrix.os == 'ubuntu-latest' }} - # https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions - run: | - sudo apt install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 \ - libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 \ - libxcb-xfixes0 xvfb x11-utils glibc-tools; - /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 +render -noreset; - - name: Install coveralls and coverage - if: ${{ matrix.os == 'ubuntu-latest' }} - run: | - micromamba install -q -y coveralls=3.3.1 coverage pytest-cov - - name: Run linux tests - if: ${{ matrix.os == 'ubuntu-latest' }} - env: - QT_DEBUG_PLUGINS: 1 - run: | - catchsegv xvfb-run --auto-servernum pytest --cov=activity_browser --cov-report=; - - name: Run tests - if: ${{ matrix.os != 'ubuntu-latest' }} - run: | - pytest - - name: Upload coverage - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} - # https://github.com/lemurheavy/coveralls-public/issues/1435#issuecomment-763357004 - # https://coveralls-python.readthedocs.io/en/latest/usage/configuration.html#github-actions-support - # https://github.com/TheKevJames/coveralls-python/issues/252 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_SERVICE_NAME: github - run: | - coveralls - - deploy-development: - # Make sure to only run a deploy if all tests pass. - needs: - - tests - # And only on a push event, not a pull_request. - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - env: - PKG_NAME: "activity-browser-dev" - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: "0" - - name: Build and deploy 3.11 - uses: conda-incubator/setup-miniconda@v2 - with: - python-version: 3.11 - activate-environment: build - environment-file: .github/conda-envs/build.yml - - name: Export version - run: | - echo "VERSION=$(git describe --tags --always | cut -d- -f1,2 | sed 's/-/dev/')" >> $GITHUB_ENV - - name: Patch recipe with run requirements from stable - uses: mikefarah/yq@master - # Adds the run dependencies from the stable recipe to the dev recipe (inplace) - with: - cmd: | - yq eval-all -i 'select(fi == 0).requirements.run += select(fi == 1).requirements.run | select(fi == 0)' .github/dev-recipe/meta.yaml recipe/meta.yaml - - name: Show patched dev recipe - run: cat .github/dev-recipe/meta.yaml - - name: Build development package - run: | - conda build .github/dev-recipe/ - - name: Upload the activity-browser-dev package - run: | - anaconda -t ${{ secrets.CONDA_UPLOAD_TOKEN }} upload \ - /usr/share/miniconda/envs/build/conda-bld/noarch/*.conda - - deploy-beta: - # And only on a push event, not a pull_request. - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/major' }} - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - env: - PKG_NAME: "activity-browser-beta" - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: "0" - - name: Build and deploy 3.11 - uses: conda-incubator/setup-miniconda@v2 - with: - python-version: 3.11 - activate-environment: build - environment-file: .github/conda-envs/build.yml - - name: Export version - run: | - ID=$(git rev-list 2.11.0..HEAD --count) - VERSION="3.b.${ID}" - echo "VERSION=$VERSION" >> $GITHUB_ENV - - name: Patch recipe with run requirements from stable - uses: mikefarah/yq@master - # Adds the run dependencies from the stable recipe to the dev recipe (inplace) - with: - cmd: | - yq eval-all -i 'select(fi == 0).requirements.run += select(fi == 1).requirements.run | select(fi == 0)' .github/dev-recipe/meta.yaml recipe/meta.yaml - - name: Show patched dev recipe - run: cat .github/dev-recipe/meta.yaml - - name: Build beta package - run: | - conda build .github/dev-recipe/ - - name: Upload the activity-browser-dev package - run: | - anaconda -t ${{ secrets.CONDA_MRVISSCHER }} upload \ - /usr/share/miniconda/envs/build/conda-bld/noarch/*.conda diff --git a/.github/workflows/manual_update_wiki.yml b/.github/workflows/manual_update_wiki.yml deleted file mode 100644 index b6ddb4ed9..000000000 --- a/.github/workflows/manual_update_wiki.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: manual update of wiki - -on: - workflow_dispatch: - -jobs: - update_wiki: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: update wiki - run: ./.github/scripts/update_wiki.sh "Manual wiki update for $GITHUB_REF_NAME" "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index ec25e782e..a0d5bee6e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-13, macos-latest] + os: [ubuntu-latest, windows-latest, macos-15, macos-latest] py-version: ["3.10", "3.11", "3.12"] env: QT_QPA_PLATFORM: 'offscreen' @@ -42,4 +42,4 @@ jobs: - name: Test with pytest run: | - pytest + pytest -s --no-header --no-summary -q diff --git a/activity_browser/README.md b/activity_browser/README.md new file mode 100644 index 000000000..9eb2b3f5b --- /dev/null +++ b/activity_browser/README.md @@ -0,0 +1,50 @@ +# activity_browser + +This is the main package directory for the Activity Browser application. + +## Overview + +Activity Browser is a Qt-based desktop application that provides a GUI front-end for Brightway2, enabling users to perform Life Cycle Assessment (LCA) calculations with an intuitive interface. + +## Directory Structure + +- **`app/`** - Main application logic, including the main window, actions, dialogs, pages, and panes +- **`bwutils/`** - Utility functions and helpers that extend Brightway2 functionality +- **`mod/`** - Monkey-patches and modifications to third-party libraries (bw2analyzer, bw2io, etc.) +- **`static/`** - Static resources including HTML templates, CSS, icons, fonts, and JavaScript files +- **`ui/`** - Core UI components including widgets, dialogs, wizards, and web views + +## Key Files + +- **`__init__.py`** - Package initialization with PySide6/typing compatibility patches +- **`__main__.py`** - Entry point for the application (`run_activity_browser` function) +- **`info.py`** - Version and application metadata + +## Entry Points + +The application can be started in multiple ways: +- Console script: `activity-browser` (installed via setuptools) +- Direct module execution: `python -m activity_browser` +- Script execution: `python run-activity-browser.py` + +All entry points lead to `activity_browser.__main__:run_activity_browser`. + +## Architecture + +The application follows an MVC-like pattern with: +- **Global signals** (`activity_browser.app.signals`) - Event bus for cross-component communication +- **Deferred imports** - Heavy modules are loaded in background threads during startup +- **Actions pattern** - UI operations encapsulated in `app/actions/` with a base class pattern + +## Dependencies + +Main dependencies include: +- **PySide6** (via qtpy) - Qt bindings for the GUI +- **Brightway2** ecosystem (bw2data, bw2calc, bw2analyzer, bw2io) - LCA calculation engine +- **loguru** - Logging framework + +## Development Notes + +- Avoid top-level imports of heavy modules (PySide6, bw2data) to keep tests fast +- Use project signals for cross-component communication instead of direct function calls +- Global shortcuts are registered via `@application.global_shortcut` decorator diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 4c44c175e..bc22de453 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,5 +14,26 @@ except ImportError: import qtpy +def setup_logging(): + """Configure loguru sinks for console and file logging.""" + from loguru import logger + import os + import platformdirs + + logger.level("SYNC", no=9, color="") + logger.level("TEST", no=19, color="") + + + logger.remove() + logger.add(sys.stderr, level=6, colorize=True, + format="{time:HH:mm:ss} | {level: <8} | {message}") + + log_dir = platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="pylca") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "activity_browser.log") + logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) + def run_activity_browser(): from .__main__ import run_activity_browser + +setup_logging() \ No newline at end of file diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 3f88c55d0..a84595500 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -95,7 +95,6 @@ def load_layout(self): def load_finished(self): from activity_browser import app - app.main_window.sync() app.main_window.show() self.deleteLater() @@ -112,24 +111,11 @@ def run(self): import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils -def setup_logging(): - """Configure loguru sinks for console and file logging.""" - logger.remove() - logger.add(sys.stderr, level="DEBUG", colorize=True, - format="{time:HH:mm:ss} | {level: <8} | {message}") - - log_dir = platformdirs.user_log_dir("ActivityBrowser", "ActivityBrowser") - os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, "activity_browser.log") - logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) - - def run_activity_browser(): from activity_browser.ui.core.application import ABApplication app = ABApplication() pre_flight_checks() - setup_logging() loader = ABLoader() loader.show() @@ -139,7 +125,6 @@ def run_activity_browser(): def run_activity_browser_no_launcher(): pre_flight_checks() - setup_logging() modules = ModuleThread() modules.run() @@ -147,7 +132,6 @@ def run_activity_browser_no_launcher(): from .ui.widgets import CentralTabWidget from .app import panes, pages, application, metadata - application.main_window.sync() application.main_window.show() application.set_icon() # setting this here seems to fix the icon not showing sometimes diff --git a/activity_browser/app/README.md b/activity_browser/app/README.md new file mode 100644 index 000000000..785b676f7 --- /dev/null +++ b/activity_browser/app/README.md @@ -0,0 +1,70 @@ +# app + +Main application module containing the core logic and structure of the Activity Browser. + +## Overview + +This module orchestrates the main application components including the main window, menu bar, signal handling, and various UI elements organized into actions, dialogs, pages, and panes. + +## Directory Structure + +- **`actions/`** - Encapsulated UI operations and commands (activity, database, calculation setup, etc.) +- **`dialogs/`** - Dialog windows for user interactions +- **`pages/`** - Main content pages displayed in the application (activity details, calculations, parameters, etc.) +- **`panes/`** - Dock-able panes that can be arranged around the main content area + +## Key Files + +- **`__init__.py`** - Module initialization creating singleton instances: + - `application` - ABApplication instance + - `metadata` - MetaDataStore instance + - `settings` - Settings instance + - `signals` - ABSignals instance (event bus) + - `main_window` - MainWindow instance + +- **`main_window.py`** - MainWindow class that holds the central widget and dock panes +- **`menu_bar.py`** - Application menu bar with File, Edit, View, Tools, Help menus +- **`signalling.py`** - ABSignals class that bridges bw2data signals to Qt signals + +## Architecture + +The app module creates and wires together the core application components: + +1. **Application** (`ABApplication`) - Qt application instance with global shortcut management +2. **Signals** (`ABSignals`) - Project-wide event bus for cross-component communication +3. **Main Window** (`MainWindow`) - Main application window with pages and panes +4. **Actions** - Command pattern implementation for menu items and toolbar actions +5. **Pages** - Content area widgets for different application views +6. **Panes** - Dock-able side panels + +## Signal Flow + +The signals instance serves as the central event bus: +- Bridges Brightway2 data events to Qt signals +- Enables loose coupling between UI components +- Used throughout the application for state updates + +## Usage Pattern + +Components should access the application objects via: + +```python +from activity_browser import app + +# Access global instances +app.application # ABApplication instance +app.signals # Event bus +app.settings # Settings manager +app.metadata # Metadata store +app.main_window # Main window +``` + +## Actions Pattern + +Actions encapsulate user commands and are defined in the `actions/` subdirectory. Each action: +- Inherits from `ABAction` base class +- Defines icon, text, tooltip +- Implements a `run()` static method +- Can be converted to QAction or QPushButton + +See `actions/base.py` for the action framework. diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index b4c28d862..275cdb6b2 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- __all__ = ["panes", "pages", "application", "signals", "metadata", "main_window", "actions"] +import os + from activity_browser.ui.core.application import ABApplication from activity_browser.bwutils.metadata import MetaDataStore from activity_browser.bwutils.settings import Settings -from .main_window import MainWindow +from .main import MainWindow application = ABApplication() -metadata = MetaDataStore() +metadata = MetaDataStore(application) settings = Settings() # modules dependent on application instance @@ -19,8 +21,11 @@ from . import actions from . import panes from . import pages +from . import dialogs main_window = MainWindow() application.main_window = main_window -main_window.apply_settings(load=True) # Ensure settings are applied at startup + +if not os.environ.get("AB_SKIP_SETTINGS_ON_STARTUP"): + main_window.apply_settings(load=True) # Ensure settings are applied at startup diff --git a/activity_browser/app/actions/README.md b/activity_browser/app/actions/README.md new file mode 100644 index 000000000..a6f513443 --- /dev/null +++ b/activity_browser/app/actions/README.md @@ -0,0 +1,116 @@ +# actions + +Encapsulated UI operations and commands following the action pattern. + +## Overview + +This directory contains all user-triggered actions in Activity Browser. Each action represents a discrete operation that can be invoked from menus, toolbars, or keyboard shortcuts. + +## Directory Structure + +- **`activity/`** - Actions related to activities (create, edit, delete, duplicate, etc.) +- **`calculation_setup/`** - Actions for calculation setup management +- **`database/`** - Database operations (import, export, delete, backup, etc.) +- **`exchange/`** - Actions for exchanges between activities +- **`method/`** - Impact assessment method management +- **`parameter/`** - Parameter management actions +- **`project/`** - Project-level operations +- **`tools/`** - Various tools and utilities accessible via actions + +## Key Files + +- **`base.py`** - `ABAction` base class that all actions inherit from +- **`metadatastore_open.py`** - Action to open the metadata store dialog +- **`migrations_install.py`** - Database migration actions +- **`node_select_open.py`** - Node selection dialog action +- **`pyside_upgrade.py`** - PySide upgrade helper action +- **`save_parameters_to_excel.py`** - Export parameters to Excel +- **`settings_wizard_open.py`** - Settings wizard dialog action + +## Action Pattern + +All actions follow a consistent pattern defined in `base.py`: + +```python +class MyAction(ABAction): + icon = QtGui.QIcon(...) # Action icon + text = "My Action" # Display text + tooltip = "Description" # Tooltip text + + @staticmethod + def run(*args, **kwargs): + # Action implementation + pass +``` + +### Key Features: + +1. **Declarative** - Icon, text, and tooltip defined as class attributes +2. **Callable arguments** - Arguments can be functions (evaluated at runtime) +3. **Qt integration** - Can be converted to QAction or QPushButton +4. **Exception handling** - Optional decorator for error dialogs +5. **Flexible invocation** - Triggered from menus, buttons, shortcuts + +## Usage + +Actions can be used in multiple ways: + +### As Menu Items +```python +action = MyAction.get_QAction(parent=menu) +menu.addAction(action) +``` + +### As Buttons +```python +button = MyAction.get_QButton() +layout.addWidget(button) +``` + +### Direct Invocation +```python +MyAction.run(arg1, arg2) +``` + +## Subdirectory Organization + +Each subdirectory groups related actions: + +- **`activity/`** - Activity CRUD operations, navigation, graph viewing +- **`calculation_setup/`** - Setup creation, modification, calculation execution +- **`database/`** - Import from various sources, export, deletion, backup/restore +- **`exchange/`** - Add/remove/modify exchanges, uncertainty, formulas +- **`method/`** - Method import, export, modification, deletion +- **`parameter/`** - Parameter creation, editing, scenarios +- **`project/`** - Project creation, switching, deletion, settings +- **`tools/`** - Monte Carlo, sensitivity analysis, superstructure tools + +## Development Guidelines + +When adding new actions: + +1. Inherit from `ABAction` base class +2. Define icon, text, and tooltip class attributes +3. Implement the `run()` static method with the action logic +4. Place in the appropriate subdirectory by functionality +5. Use `@exception_dialogs` decorator for user-facing error handling +6. Import and register in the parent `__init__.py` +7. Connect to global signals when state changes + +## Signal Integration + +Actions should emit signals when they modify application state: + +```python +from activity_browser import app + +class MyAction(ABAction): + @staticmethod + def run(): + # Perform operation + ... + # Emit signal + app.signals.database_changed.emit() +``` + +This ensures other components can react to state changes without tight coupling. diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py index 236bdc483..6ca690aaa 100644 --- a/activity_browser/app/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -74,6 +74,7 @@ from .parameter.parameter_uncertainty_remove import ParameterUncertaintyRemove from .parameter.parameter_uncertainty_modify import ParameterUncertaintyModify from .parameter.parameter_clear_broken import ParameterClearBroken +from .parameter.parameter_group_delete import ParameterGroupDelete from .project.project_new import ProjectNew from .project.project_duplicate import ProjectDuplicate @@ -91,7 +92,8 @@ from .project.project_create_template import ProjectCreateTemplate from .project.project_new_template import ProjectNewFromTemplate -from .settings_wizard_open import SettingsWizardOpen from .migrations_install import MigrationsInstall from .pyside_upgrade import PysideUpgrade from .metadatastore_open import MetaDataStoreOpen +from .node_select_open import NodeSelectOpen +from .save_parameters_to_excel import SaveParametersToExcel diff --git a/activity_browser/app/actions/activity/activity_duplicate_to_db.py b/activity_browser/app/actions/activity/activity_duplicate_to_db.py index 349cff6e1..d677590a1 100644 --- a/activity_browser/app/actions/activity/activity_duplicate_to_db.py +++ b/activity_browser/app/actions/activity/activity_duplicate_to_db.py @@ -47,7 +47,7 @@ def run(cls, nodes: List[tuple | int | bd.Node], to_db_name: str = None): elif from_db_backend == "functional_sqlite" and to_db_backend == "sqlite": new_nodes = cls.duplicate_functional_sqlite_to_sqlite(nodes, to_db_name) else: - raise NotImplementedError(f"Moving from {from_db_backend} to {to_db_backend} is not supported.") + raise NotImplementedError(f"Copying from {from_db_backend} to {to_db_backend} is not supported.") ActivityOpen.run(new_nodes) diff --git a/activity_browser/app/actions/activity/activity_new_process.py b/activity_browser/app/actions/activity/activity_new_process.py index 1ea601192..79725152c 100644 --- a/activity_browser/app/actions/activity/activity_new_process.py +++ b/activity_browser/app/actions/activity/activity_new_process.py @@ -1,13 +1,12 @@ from uuid import uuid4 -from qtpy.QtWidgets import QDialog +from qtpy import QtWidgets import bw2data as bd from activity_browser import app from activity_browser.bwutils.commontasks import database_is_legacy from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog from .activity_open import ActivityOpen @@ -27,7 +26,7 @@ def run(database_name: str): # ask the user to provide a name for the new activity dialog = NewNodeDialog(app.main_window) # if the user cancels, return - if dialog.exec_() != QDialog.Accepted: + if dialog.exec_() != QtWidgets.QDialog.DialogCode.Accepted: return name, ref_product, unit, location = dialog.get_new_process_data() # if no name is provided, return @@ -72,3 +71,60 @@ def run(database_name: str): prod.save() ActivityOpen.run([new_process.key]) + + +class NewNodeDialog(QtWidgets.QDialog): + """ + Gathers the paremeters for creating a new process. + """ + + def __init__(self, process: bool = True, parent = None): + super().__init__(parent) + layout = QtWidgets.QGridLayout() + row = 0 + if process: + self.setWindowTitle("New process") + layout.addWidget(QtWidgets.QLabel("Process name"), row, 0) + else: + self.setWindowTitle("New product") + layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) + self._process_name_edit = QtWidgets.QLineEdit() + self._process_name_edit.textChanged.connect(self._handle_text_changed) + layout.addWidget(self._process_name_edit, row, 1) + row += 1 + self._ref_product_name_edit = QtWidgets.QLineEdit() + if process: + layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) + layout.addWidget(self._ref_product_name_edit, row, 1) + row += 1 + layout.addWidget(QtWidgets.QLabel("Unit"), row, 0) + self._unit_edit = QtWidgets.QLineEdit("kilogram") + layout.addWidget(self._unit_edit, row, 1) + row += 1 + layout.addWidget(QtWidgets.QLabel("Location"), row, 0) + default_loc = "GLO" if process else "" + self._location_edit = QtWidgets.QLineEdit(default_loc) + layout.addWidget(self._location_edit, row, 1) + row += 1 + self._ok_button = QtWidgets.QPushButton("OK") + self._ok_button.clicked.connect(self.accept) + self._ok_button.setEnabled(False) + layout.addWidget(self._ok_button, row, 0) + cancel_button = QtWidgets.QPushButton("Cancel") + cancel_button.clicked.connect(self.reject) + layout.addWidget(cancel_button, row, 1) + self.setLayout(layout) + + def _handle_text_changed(self, text: str): + self._ok_button.setEnabled(text != "") + self._ref_product_name_edit.setPlaceholderText(text) + + def get_new_process_data(self) -> tuple[str, str, str, str]: + """Return the parameters the user entered.""" + return ( + self._process_name_edit.text(), + self._ref_product_name_edit.text(), + self._unit_edit.text(), + self._location_edit.text() + ) + diff --git a/activity_browser/app/actions/calculation_setup/cs_duplicate.py b/activity_browser/app/actions/calculation_setup/cs_duplicate.py index 87cfab311..aa49be61a 100644 --- a/activity_browser/app/actions/calculation_setup/cs_duplicate.py +++ b/activity_browser/app/actions/calculation_setup/cs_duplicate.py @@ -2,11 +2,12 @@ from qtpy import QtWidgets -from activity_browser.app import application, signals +from activity_browser.app import application from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons +from .cs_open import CSOpen @@ -44,5 +45,5 @@ def run(cs_name: str): return bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() - signals.calculation_setup_selected.emit(new_name) logger.info(f"Copied calculation setup {cs_name} as {new_name}") + CSOpen.run(new_name) diff --git a/activity_browser/app/actions/calculation_setup/cs_rename.py b/activity_browser/app/actions/calculation_setup/cs_rename.py index 419b6e904..72ad71041 100644 --- a/activity_browser/app/actions/calculation_setup/cs_rename.py +++ b/activity_browser/app/actions/calculation_setup/cs_rename.py @@ -46,5 +46,4 @@ def run(cs_name: str, new_name: str = None): # instruct the CalculationSetupController to rename the CS to the new name bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() del bd.calculation_setups[cs_name] - signals.calculation_setup_selected.emit(new_name) logger.info(f"Renamed calculation setup from {cs_name} to {new_name}") diff --git a/activity_browser/app/actions/database/database_delete.py b/activity_browser/app/actions/database/database_delete.py index a3cade06d..81af59e79 100644 --- a/activity_browser/app/actions/database/database_delete.py +++ b/activity_browser/app/actions/database/database_delete.py @@ -6,7 +6,7 @@ from bw2data.parameters import Group from bw2data.backends.proxies import ExchangeDataset, Exchanges -from activity_browser import app, settings +from activity_browser import app from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -86,9 +86,6 @@ def run(db_names: List[str]): # delete database parameters Group.delete().where(Group.name == db_name).execute() - # remove database from project settings - settings.project_settings.remove_db(db_name) - QtWidgets.QApplication.restoreOverrideCursor() diff --git a/activity_browser/app/actions/database/database_duplicate.py b/activity_browser/app/actions/database/database_duplicate.py index 5c8e3db3c..3ca59654b 100644 --- a/activity_browser/app/actions/database/database_duplicate.py +++ b/activity_browser/app/actions/database/database_duplicate.py @@ -91,7 +91,7 @@ def run_safely(self, copy_from, copy_to, backend): metadata = copy.copy(database.metadata) metadata["format"] = f"Copied from '{copy_from}'" metadata["backend"] = backend - new_database.register(**metadata) + new_database.register(write_empty=False, **metadata) if database.backend == "sqlite" and backend == "functional_sqlite": data = bf.convert_sqlite_to_functional_sqlite(data) diff --git a/activity_browser/app/actions/database/database_export_bw2package.py b/activity_browser/app/actions/database/database_export_bw2package.py index 05f7e9485..db5fdd166 100644 --- a/activity_browser/app/actions/database/database_export_bw2package.py +++ b/activity_browser/app/actions/database/database_export_bw2package.py @@ -3,15 +3,13 @@ from qtpy import QtWidgets -from activity_browser.app import application +from activity_browser.app import application, dialogs from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters from activity_browser.ui.core import threading - - class DatabaseExportBW2Package(ABAction): """ ABAction to export database(s) to BW2Package format (.bw2package). @@ -26,7 +24,7 @@ class DatabaseExportBW2Package(ABAction): def run(cls, db_names: List[str] = None): if db_names is None: import bw2data as bd - dialog = widgets.ABDatabaseSelectionDialog( + dialog = dialogs.DatabaseSelectDialog( parent=application.main_window, databases=sorted(bd.databases), title="Select databases to export to BW2Package" diff --git a/activity_browser/app/actions/database/database_export_excel.py b/activity_browser/app/actions/database/database_export_excel.py index c72f0137f..319c83e90 100644 --- a/activity_browser/app/actions/database/database_export_excel.py +++ b/activity_browser/app/actions/database/database_export_excel.py @@ -3,16 +3,13 @@ from qtpy import QtWidgets -from activity_browser.app import application +from activity_browser.app import application, dialogs from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters from activity_browser.ui.core import threading - - - class DatabaseExportExcel(ABAction): """ ABAction to export database(s) to Excel format (.xlsx). @@ -27,7 +24,7 @@ class DatabaseExportExcel(ABAction): def run(cls, db_names: List[str] = None): if db_names is None: import bw2data as bd - dialog = widgets.ABDatabaseSelectionDialog( + dialog = dialogs.DatabaseSelectDialog( parent=application.main_window, databases=sorted(bd.databases), title="Select databases to export to Excel" diff --git a/activity_browser/app/actions/database/database_import_from_ecoinvent.py b/activity_browser/app/actions/database/database_import_from_ecoinvent.py index 45792b826..10e89eb5e 100644 --- a/activity_browser/app/actions/database/database_import_from_ecoinvent.py +++ b/activity_browser/app/actions/database/database_import_from_ecoinvent.py @@ -54,6 +54,7 @@ class RemoteOrLocalPage(widgets.ABWizardPage): """Wizard page to choose between remote or local ecoinvent release""" title = "Import from ecoinvent" subtitle = "Choose whether to import from a remote or local ecoinvent release." + buttonLayout = ["Stretch", "CancelButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -78,6 +79,7 @@ class LocalSelectPage(widgets.ABWizardPage): """Wizard page to select a local ecoinvent .7z file""" title = "Import from ecoinvent" subtitle = "Select local ecoinvent .7z." + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -111,6 +113,7 @@ class LoginPage(widgets.ABWizardPage): """Wizard page to login with ecoinvent credentials""" title = "Login" subtitle = "Login with your ecoinvent credentials to authorize the download" + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -171,6 +174,7 @@ class EcoinventVersionPage(widgets.ABWizardPage): """Wizard page to choose ecoinvent version and system model""" title = "Choose version" subtitle = "Choose ecoinvent version and system model" + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -210,6 +214,7 @@ class EcoinventDownloadPage(widgets.ABThreadedWizardPage): """Wizard page to download the selected ecoinvent release""" title = "Download ecoinvent" subtitle = "Downloading the selected ecoinvent release" + buttonLayout = ["Stretch", "NextButton"] class Thread(threading.ABThread): """Thread to handle the download process""" @@ -254,6 +259,7 @@ class BiosphereSetupPage(widgets.ABWizardPage): """Wizard page to choose biosphere setup options""" title = "Biosphere setup" subtitle = "Choose whether to import the biosphere database or connect to an existing one" + buttonLayout = ["Stretch", "CancelButton", "CommitButton"] def __init__(self, parent=None): super().__init__(parent) @@ -304,6 +310,7 @@ class BiosphereInstallPage(widgets.ABThreadedWizardPage): """Wizard page to install the biosphere database""" title = "Installing biosphere database" subtitle = "Installing bundled biosphere database into the project" + buttonLayout = ["Stretch", "NextButton"] class Thread(threading.ABThread): """Thread to handle the biosphere installation process""" @@ -324,6 +331,7 @@ class MethodsSetupPage(widgets.ABWizardPage): """Wizard page to choose methods setup options""" title = "Methods setup" subtitle = "Choose whether to import methods from ecoinvent or from file" + buttonLayout = ["Stretch", "CommitButton"] def __init__(self, parent=None): super().__init__(parent) @@ -377,6 +385,7 @@ class MethodsInstallPage(widgets.ABThreadedWizardPage): """Wizard page to install the selected methods""" title = "Installing methods" subtitle = "Installing selected methods and linking to the biosphere" + buttonLayout = ["Stretch", "NextButton"] class Thread(threading.ABThread): """Thread to handle the methods installation process""" @@ -409,6 +418,7 @@ class EcoinventSetupPage(widgets.ABWizardPage): """Wizard page to set up the ecoinvent database""" title = "Ecoinvent setup" subtitle = "Choose name for ecoinvent database" + buttonLayout = ["Stretch", "CancelButton", "CommitButton"] def __init__(self, parent=None): super().__init__(parent) @@ -440,6 +450,7 @@ class EcoinventInstallPage(widgets.ABThreadedWizardPage): """Wizard page to install the ecoinvent database""" title = "Installing ecoinvent" subtitle = "Installing ecoinvent database into the project" + buttonLayout = ["Stretch", "FinishButton"] class Thread(threading.ABThread): """Thread to handle the ecoinvent installation process""" diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py index 66249b8ff..6604add2b 100644 --- a/activity_browser/app/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -38,21 +38,43 @@ def run(cls): class ImportSetup(widgets.ABWizard): + def customButtonOne(self): + def callback(): + importer : ABExcelImporter = self.context.get("importer") + if not importer: + return + dialog = app.dialogs.ImportPreviewDialog(importer, parent=app.main_window) + dialog.exec_() + return "Data", callback + class ExtractPage(widgets.ABThreadedWizardPage): title = "Extracting Database" subtitle = "Extracting database from excel file" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] + customButton1Text = "Show extracted data" class Thread(threading.ABThread): loaded: SignalInstance = Signal(object) def run_safely(self, path: str): importer = ABExcelImporter(path) + importer.apply_basic_strategies() self.loaded.emit(importer) def initializePage(self, context: dict): """Start the download thread""" self.thread.start(context["path"]) - self.thread.loaded.connect(lambda i: context.__setitem__("importer", i)) + self.thread.loaded.connect(self.thread_finished) + + button = self.wizard().button(QtWidgets.QWizard.CustomButton1) + button.setEnabled(False) + + def thread_finished(self, importer: ABExcelImporter): + logger.debug("Extraction thread finished") + self.context()["importer"] = importer + + button = self.wizard().button(QtWidgets.QWizard.CustomButton1) + button.setEnabled(True) def nextPage(self) -> type[QtWidgets.QWizardPage] | None: return ImportSetup.DatabaseName @@ -60,6 +82,7 @@ def nextPage(self) -> type[QtWidgets.QWizardPage] | None: class DatabaseName(widgets.ABWizardPage): title = "Database Name" subtitle = "Enter the name of the database you wish to create" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -78,8 +101,12 @@ def isComplete(self): def initializePage(self, context: dict): self.db_name_edit.setText(context["importer"].db_name) + self.wizard().setButtonText(QtWidgets.QWizard.WizardButton.NextButton, "Apply") def finalize(self, context: dict): + importer = context["importer"] + importer.apply_db_name(self.db_name_edit.text()) + context["database_name"] = self.db_name_edit.text() def nextPage(self): @@ -88,6 +115,7 @@ def nextPage(self): class DatabaseLink(widgets.ABWizardPage): title = "Link Databases" subtitle = "Link the imported database to existing databases" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -120,10 +148,42 @@ def initializePage(self, context: dict): self.link_dict_edit[db] = drop_down - def finalize(self, context: dict): + importer = context["importer"] + importer.apply_linking({k: v.currentText() for k, v in self.link_dict_edit.items()}) + context["linking_dict"] = {k: v.currentText() for k, v in self.link_dict_edit.items()} + def nextPage(self): + return ImportSetup.ConfirmPage + + class ConfirmPage(widgets.ABWizardPage): + title = "Database Overview" + subtitle = "Confirming and installing the database" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "CommitButton"] + + def __init__(self, parent=None): + super().__init__(parent) + layout = QtWidgets.QGridLayout(self) + self.setLayout(layout) + + def isComplete(self): + return True + + def initializePage(self, context: dict): + importer = context["importer"] + layout = self.layout() + row = 0 + for key, value in { + "Database Name": importer.db_name, + "Number of Activities": len(importer.data), + "Number of Exchanges": sum(len(act.get("exchanges", [])) for act in importer.data), + "Number of Unlinked Exchanges": len(list(importer.unlinked)), + }.items(): + layout.addWidget(QtWidgets.QLabel(f"{key}:"), row, 0) + layout.addWidget(QtWidgets.QLabel(str(value)), row, 1) + row += 1 + def nextPage(self): return ImportSetup.InstallPage @@ -136,10 +196,16 @@ class Thread(threading.ABThread): def run_safely(self, importer: ABExcelImporter, database_name: str, linking_dict: dict): """Download the ecoinvent release""" - importer.automated_import(database_name, linking_dict) + importer.write_database() def initializePage(self, context: dict): """Start the download thread""" - self.thread.start(context["importer"], context["database_name"], context["linking_dict"]) + importer = context["importer"] + database_name = context["database_name"] + linking_dict = context.get("linking_dict", {}) + + self.thread.start(importer, database_name, linking_dict) + + pages = [ExtractPage, DatabaseName, DatabaseLink, ConfirmPage, InstallPage] + - pages = [ExtractPage, DatabaseName, DatabaseLink, InstallPage] diff --git a/activity_browser/app/actions/database/database_relink.py b/activity_browser/app/actions/database/database_relink.py index cd3c968c3..ba6325b92 100644 --- a/activity_browser/app/actions/database/database_relink.py +++ b/activity_browser/app/actions/database/database_relink.py @@ -1,9 +1,10 @@ from qtpy import QtCore, QtWidgets -from activity_browser.app import application +import bw2data as bd +from bw2data.backends import ExchangeDataset, sqlite3_lci_db + +from activity_browser.app import application, metadata from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils.strategies import relink_exchanges_existing_db -from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -23,8 +24,10 @@ def run(db_name: str): # get brightway database object db = bd.Database(db_name) + depends = ExchangeDataset.select(ExchangeDataset.input_database).where(ExchangeDataset.output_database == db_name) + depends = set([d.input_database for d in depends if d.input_database != db_name]) + # find the dependencies of the database and construct a list of suitable candidates - depends = db.find_dependents() options = [(depend, list(bd.databases)) for depend in depends] # construct a dialog in which the user chan choose which depending database to connect to which candidate @@ -36,25 +39,65 @@ def run(db_name: str): if dialog.exec_() != DatabaseLinkingDialog.Accepted: return - # else, start the relinking - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - relinking_results = dict() + linking_dict = {k: v for k, v in dialog.links.items() if k != v} - # relink using relink_exchanges_existing_db strategy - for old, new in dialog.relink.items(): - other = bd.Database(new) - failed, succeeded, examples = relink_exchanges_existing_db(db, old, other) - relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) - - QtWidgets.QApplication.restoreOverrideCursor() + if not linking_dict: + return - # if any failed, present user with results dialog - if failed > 0: - relinking_dialog = DatabaseLinkingResultsDialog.present_relinking_results( - application.main_window, relinking_results, examples + relink_keys = DatabaseRelink.get_input_keys(db_name, list(linking_dict.keys())) + datasets = metadata.get_metadata(relink_keys, ["name", "product", "unit", "categories", "location"]) + + relink_key_map = {} + for ds in datasets.itertuples(): + key = ds.Index + database = linking_dict.get(key[0]) + match = metadata.match( + name=ds.name, + product=ds.product, + unit=ds.unit, + categories=ds.categories, + location=ds.location, + database=database, ) - relinking_dialog.exec_() - relinking_dialog.open_activity() + + if not len(match) == 1: + raise Exception(f"Could not uniquely relink exchange from {key} in database {database}") + + relink_key_map[key] = match.index[0] + + DatabaseRelink.set_input_keys(db_name, relink_key_map) + + QtWidgets.QMessageBox.information( + application.main_window, + "Database relinked", + f"Successfully relinked database '{db_name}'." + ) + + @staticmethod + def get_input_keys(output_db: str, db_list: list[str]) -> list[tuple[str, str]]: + return list( + ( + ExchangeDataset + .select(ExchangeDataset.input_database, ExchangeDataset.input_code) + .where( + (ExchangeDataset.output_database == output_db) & + (ExchangeDataset.input_database << db_list) + ) + ).tuples() + ) + + @staticmethod + def set_input_keys(output_db: str, key_map: dict[tuple[str, str], tuple[str, str]]) -> None: + with sqlite3_lci_db.db.atomic(): + for old_key, new_key in key_map.items(): + ExchangeDataset.update( + input_database=new_key[0], + input_code=new_key[1] + ).where( + (ExchangeDataset.output_database == output_db) & + (ExchangeDataset.input_database == old_key[0]) & + (ExchangeDataset.input_code == old_key[1]) + ).execute() class DatabaseLinkingDialog(QtWidgets.QDialog): diff --git a/activity_browser/app/actions/exchange/exchange_new.py b/activity_browser/app/actions/exchange/exchange_new.py index 6f3dd247c..454ce537a 100644 --- a/activity_browser/app/actions/exchange/exchange_new.py +++ b/activity_browser/app/actions/exchange/exchange_new.py @@ -16,8 +16,8 @@ class ExchangeNew(ABAction): @staticmethod @exception_dialogs - def run(from_keys: List[tuple], to_key: tuple, type: str): + def run(from_keys: List[tuple], to_key: tuple, type: str, amount: float = 1): to_activity = bd.get_activity(to_key) for from_key in from_keys: - exchange = to_activity.new_exchange(input=from_key, type=type, amount=1) + exchange = to_activity.new_exchange(input=from_key, type=type, amount=amount) exchange.save() diff --git a/activity_browser/app/actions/method/cf_uncertainty_modify.py b/activity_browser/app/actions/method/cf_uncertainty_modify.py index ac794fec5..2670650bd 100644 --- a/activity_browser/app/actions/method/cf_uncertainty_modify.py +++ b/activity_browser/app/actions/method/cf_uncertainty_modify.py @@ -19,28 +19,29 @@ class CFUncertaintyModify(ABAction): @classmethod @exception_dialogs - def run(cls, method_name: tuple, char_factors: List[tuple]): + def run(cls, method_name: tuple, char_factors: List[tuple], uncertainty_dict: dict = None): - initial = char_factors[0][1] - initial = initial if isinstance(initial, dict) else {} - - ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( - parent=app.main_window, - initial=initial, - ) - - if not ok: - return + if uncertainty_dict is None: + initial = char_factors[0][1] + initial = initial if isinstance(initial, dict) else {} + + ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( + parent=app.main_window, + initial=initial, + ) + + if not ok: + return method = bd.Method(method_name) method_dict = {cf[0]: cf[1] for cf in method.load()} for cf in char_factors: if isinstance(cf[1], dict): - cf[1].update(uc_dict) + cf[1].update(uncertainty_dict) method_dict[cf[0]] = cf[1] else: - uc_dict["amount"] = cf[1] - method_dict[cf[0]] = uc_dict + uncertainty_dict["amount"] = cf[1] + method_dict[cf[0]] = uncertainty_dict method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/node_select_open.py b/activity_browser/app/actions/node_select_open.py new file mode 100644 index 000000000..5050f7fe6 --- /dev/null +++ b/activity_browser/app/actions/node_select_open.py @@ -0,0 +1,29 @@ +from loguru import logger + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.application import global_shortcut + +from .activity.activity_open import ActivityOpen + +class NodeSelectOpen(ABAction): + + icon = qicons.search + text = "Search project" + + @staticmethod + @global_shortcut("Ctrl+Shift+F") + @exception_dialogs + def run(): + from activity_browser.app import dialogs + dialog = dialogs.NodeSelectDialog(parent=app.main_window, drag_enabled=True) + + dialog.exec_() + if dialog.result() != dialog.DialogCode.Accepted: + return + + selected_node = dialog.get_selected_node() + if selected_node: + logger.debug(f"Opening node: {selected_node}") + ActivityOpen.run([selected_node]) \ No newline at end of file diff --git a/activity_browser/app/actions/parameter/parameter_delete.py b/activity_browser/app/actions/parameter/parameter_delete.py index d1f335297..d29ae2297 100644 --- a/activity_browser/app/actions/parameter/parameter_delete.py +++ b/activity_browser/app/actions/parameter/parameter_delete.py @@ -7,6 +7,7 @@ GroupDependency, parameters) from activity_browser.ui.icons import qicons +from activity_browser.bwutils.utils import Parameter class ParameterDelete(ABAction): @@ -19,38 +20,45 @@ class ParameterDelete(ABAction): @staticmethod @exception_dialogs - def run(parameter: Any): - if isinstance(parameter, ActivityParameter): - db = parameter.database - code = parameter.code - amount = ( - ActivityParameter.select() - .where( - (ActivityParameter.database == db) - & (ActivityParameter.code == code) + def run(parameter: Any or list[Any]): + if not isinstance(parameter, list): + parameter = [parameter] + + for parameter in parameter: + if isinstance(parameter, Parameter): + parameter = parameter.to_peewee_model() + + if isinstance(parameter, ActivityParameter): + db = parameter.database + code = parameter.code + amount = ( + ActivityParameter.select() + .where( + (ActivityParameter.database == db) + & (ActivityParameter.code == code) + ) + .count() ) - .count() - ) - if amount > 1: - parameter.delete_instance() + if amount > 1: + parameter.delete_instance() + else: + group = parameter.group + act = get_activity((db, code)) + parameters.remove_from_group(group, act) + # Also clear the group if there are no more parameters in it + + if ( + not ActivityParameter.select() + .where(ActivityParameter.group == group) + .exists() + ): + Group.delete().where(Group.name == group).execute() + GroupDependency.delete().where( + GroupDependency.group == group + ).execute() else: - group = parameter.group - act = get_activity((db, code)) - parameters.remove_from_group(group, act) - # Also clear the group if there are no more parameters in it - - if ( - not ActivityParameter.select() - .where(ActivityParameter.group == group) - .exists() - ): - Group.delete().where(Group.name == group).execute() - GroupDependency.delete().where( - GroupDependency.group == group - ).execute() - else: - parameter.delete_instance() + parameter.delete_instance() # After deleting things, recalculate and signal changes parameters.recalculate() diff --git a/activity_browser/app/actions/parameter/parameter_group_delete.py b/activity_browser/app/actions/parameter/parameter_group_delete.py new file mode 100644 index 000000000..6902d1517 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_group_delete.py @@ -0,0 +1,46 @@ +from typing import Any + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from bw2data import get_activity +from bw2data.parameters import (ActivityParameter, Group, + GroupDependency, + parameters) +from activity_browser.ui.icons import qicons +from activity_browser.bwutils.utils import Parameter + + +class ParameterGroupDelete(ABAction): + """ + ABAction to delete an existing parameter. + """ + + icon = qicons.delete + text = "Delete parameter group..." + + @staticmethod + @exception_dialogs + def run(parameter_groups: list[str]): + for group in parameter_groups: + group_entry = Group.get(Group.name == group) + + # Delete all parameters in the group + params_in_group = ActivityParameter.select().where(ActivityParameter.group == group) + if any([ActivityParameter.is_dependent_on(p.name, p.group) for p in params_in_group]): + raise Exception(f"Cannot delete parameter group '{group}' because some parameters are dependencies for other parameters.") + + for param in params_in_group: + param.delete_instance() + + # Delete group dependencies + GroupDependency.delete().where(GroupDependency.group == group).execute() + # Delete the group itself + group_entry.delete_instance() + + + # After deleting things, recalculate and signal changes + parameters.recalculate() + + # No fire when everything is still fresh after recalculation, so need to fire manually to be sure everything is + # updated correctly. + app.signals.parameter.recalculated.emit() diff --git a/activity_browser/app/actions/project/project_delete.py b/activity_browser/app/actions/project/project_delete.py index 513b14454..3552f3825 100644 --- a/activity_browser/app/actions/project/project_delete.py +++ b/activity_browser/app/actions/project/project_delete.py @@ -6,7 +6,7 @@ from bw2data.project import ProjectDataset from bw2data.utils import safe_filename -from activity_browser import settings, app +from activity_browser import app from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -54,7 +54,7 @@ def run(project_names: list[str] = None): return # if it's the startup project: reject deletion and inform user - if settings.ab_settings.startup_project in project_names: + if app.settings["startup"]["startup_project"] in project_names: QtWidgets.QMessageBox.information( app.main_window, "Not possible", diff --git a/activity_browser/app/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py index a573337ab..b6e8abe48 100644 --- a/activity_browser/app/actions/project/project_switch.py +++ b/activity_browser/app/actions/project/project_switch.py @@ -45,13 +45,11 @@ def run(project_name: str): dialog = ProjectChangeDialog(project_name, app.main_window) dialog.show() - app.application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + app.application.processEvents() # switch to the new project, don't auto update to brightway25 bd.projects.set_current(project_name, update=False) - dialog.close() - if not bd.projects.twofive: logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") ProjectSwitch.set_warning_bar() @@ -62,6 +60,9 @@ def run(project_name: str): bd.projects.dataset.data["last_opened"] = datetime.datetime.now().isoformat() bd.projects.dataset.save() + app.application.processEvents() + dialog.close() + @staticmethod def set_warning_bar(): app.main_window.addToolBar(ProjectWarningBar()) diff --git a/activity_browser/app/actions/save_parameters_to_excel.py b/activity_browser/app/actions/save_parameters_to_excel.py new file mode 100644 index 000000000..50543d9cb --- /dev/null +++ b/activity_browser/app/actions/save_parameters_to_excel.py @@ -0,0 +1,39 @@ +import os + +import pandas as pd + +from qtpy import QtWidgets + +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.utils import Parameters + + +class SaveParametersToExcel(ABAction): + """ + ABAction to export database(s) to Excel format (.xlsx). + """ + text = "Save parameters to Excel (.xlsx)" + tool_tip = "Save parameters to Excel format" + + @classmethod + @exception_dialogs + def run(cls, file_path: str = None): + if file_path is None: + suggestion = os.path.expanduser("~/parameters.xlsx") + + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=application.main_window, + caption=f'Export parameters to Excel', + dir=suggestion, + filter='Excel spreadsheet (*.xlsx);; All files (*.*)' + ) + + if not file_path: + return + + data = [p[:3] for p in Parameters.from_bw_parameters()] + df = pd.DataFrame(data, columns=["Name", "Group", "default"]).set_index("Name") + df.to_excel(file_path) + + os.startfile(file_path) diff --git a/activity_browser/app/actions/settings_wizard_open.py b/activity_browser/app/actions/settings_wizard_open.py deleted file mode 100644 index 8118611fc..000000000 --- a/activity_browser/app/actions/settings_wizard_open.py +++ /dev/null @@ -1,16 +0,0 @@ -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards.settings_wizard import SettingsWizard - - -class SettingsWizardOpen(ABAction): - """ABAction to open the SettingsWizard""" - - icon = qicons.settings - text = "Settings..." - - @staticmethod - @exception_dialogs - def run(): - SettingsWizard(app.main_window).show() diff --git a/activity_browser/app/dialogs/README.md b/activity_browser/app/dialogs/README.md new file mode 100644 index 000000000..639148e6b --- /dev/null +++ b/activity_browser/app/dialogs/README.md @@ -0,0 +1,120 @@ +# dialogs + +Dialog windows for user interactions throughout Activity Browser. + +## Overview + +This directory contains modal and non-modal dialog windows used for various user interactions such as data entry, configuration, selection, and information display. + +## Purpose + +Dialogs provide focused interfaces for: +- User input and data entry +- Configuration and settings +- Selection of items (activities, methods, databases) +- Information display and confirmations +- Multi-step workflows (see also `ui/wizards/`) + +## Common Dialog Types + +### Input Dialogs +- Text input fields +- Numeric value entry +- Date/time selection +- Multi-line text editing + +### Selection Dialogs +- List/tree item selection +- Database/activity pickers +- Method selection +- File/directory choosers + +### Configuration Dialogs +- Settings editors +- Preference panels +- Option configuration + +### Information Dialogs +- Progress indicators +- Status messages +- Warnings and errors +- About/help information + +## Design Guidelines + +Dialogs in Activity Browser should: + +1. **Be modal when appropriate** - Block parent window for critical decisions +2. **Provide clear actions** - OK/Cancel, Accept/Reject, or custom actions +3. **Validate input** - Check data before accepting +4. **Give feedback** - Show errors, warnings, progress +5. **Be responsive** - Use threading for long operations +6. **Follow Qt conventions** - Inherit from QDialog, use standard buttons + +## Usage Pattern + +```python +from qtpy.QtWidgets import QDialog, QDialogButtonBox + +class MyDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + # Build dialog UI + pass + + def accept(self): + # Validate and process input + if self.validate(): + super().accept() +``` + +## Integration with Actions + +Dialogs are typically opened via actions: + +```python +from activity_browser.app.actions.base import ABAction + +class OpenMyDialog(ABAction): + @staticmethod + def run(): + dialog = MyDialog() + if dialog.exec_() == QDialog.Accepted: + # Process result + pass +``` + +## Threading Considerations + +Long-running operations in dialogs should use worker threads: + +```python +from activity_browser.ui.core.threading import ABThread + +class MyDialog(QDialog): + def perform_long_operation(self): + worker = ABThread(self.expensive_task) + worker.finished.connect(self.on_complete) + worker.start() +``` + +## Signal Emission + +Dialogs should emit signals to notify the application of changes: + +```python +from activity_browser import app + +class MyDialog(QDialog): + def accept(self): + # Save changes + self.save_data() + # Notify application + app.signals.data_changed.emit() + super().accept() +``` + +This ensures the rest of the application can react to changes made in dialogs without tight coupling. diff --git a/activity_browser/app/dialogs/__init__.py b/activity_browser/app/dialogs/__init__.py new file mode 100644 index 000000000..5a337f238 --- /dev/null +++ b/activity_browser/app/dialogs/__init__.py @@ -0,0 +1,3 @@ +from .import_preview_dialog import ImportPreviewDialog +from .node_select_dialog import NodeSelectDialog +from .database_select_dialog import DatabaseSelectDialog diff --git a/activity_browser/ui/dialogs/database_selection_dialog.py b/activity_browser/app/dialogs/database_select_dialog.py similarity index 95% rename from activity_browser/ui/dialogs/database_selection_dialog.py rename to activity_browser/app/dialogs/database_select_dialog.py index ab3c0af26..8639ef2c2 100644 --- a/activity_browser/ui/dialogs/database_selection_dialog.py +++ b/activity_browser/app/dialogs/database_select_dialog.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets -class ABDatabaseSelectionDialog(QtWidgets.QDialog): +class DatabaseSelectDialog(QtWidgets.QDialog): """Dialog to select one or more databases for export.""" def __init__(self, parent=None, databases=None, title="Select databases"): diff --git a/activity_browser/app/dialogs/import_preview_dialog/__init__.py b/activity_browser/app/dialogs/import_preview_dialog/__init__.py new file mode 100644 index 000000000..885add2a0 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/__init__.py @@ -0,0 +1 @@ +from .import_preview_dialog import ImportPreviewDialog \ No newline at end of file diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py new file mode 100644 index 000000000..203863cfa --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -0,0 +1,253 @@ +from PySide6.QtCore import QModelIndex +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +from loguru import logger + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core, delegates, icons + +from ..node_select_dialog import NodeSelectDialog + + +class ImportPreviewEdgeTab(QtWidgets.QWidget): + standardEdgeColumns = ["linked", "type", "amount", "unit", "input", "name", "location", "database", "formula"] + + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.importer = importer + self.simple = True + self.old_links: dict[tuple[int, int], tuple[str, str] | None] = {} + + layout = QtWidgets.QVBoxLayout(self) + + self.edge_model = ImportPreviewEdgeModel(parent=self) + self.edge_model.set_dataframe(self.build_df()) + self.edge_model.group(["_node"]) + + self.edge_view = ImportPreviewEdgeView(importer, self) + self.edge_view.setUniformRowHeights(False) + self.edge_view.setModel(self.edge_model) + self.edge_view.setColumnWidth(0, 0) + + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + + # Create top bar with toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addStretch() + top_bar.addWidget(self.view_toggle) + + layout.addLayout(top_bar) + layout.addWidget(self.edge_view) + + self.sync() + + def sync(self): + """Synchronize the view based on simple/detailed mode.""" + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.edge_view.header().setHidden(self.simple) + self.edge_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.edge_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + df = self.build_df() + + if self.simple and "_exc" in df.columns: + df.rename(columns={"_exc": "exc"}, inplace=True) + elif not self.simple and "node" in df.columns: + df.rename(columns={"exc": "_exc"}, inplace=True) + + self.edge_model.update_dataframe(df) + + for col in self.edge_model.columns(): + if col == "index": + continue + index = self.edge_model.columns().index(col) + + hidden = (self.simple and not col == "exc") or (not self.simple and col == "exc") + self.edge_view.setColumnHidden(index, hidden) + + def build_df(self): + + exchanges = [] + for node_i, node in enumerate(self.importer.data): + summary = [ + node.get("name"), + node.get("location"), + node.get("database"), + node.get("code"), + ] + summary = " | ".join([str(part) for part in summary if part]) + + for exc_i, exc in enumerate(node.get("exchanges", [])): + exc = exc.copy() + exc["_node"] = summary + exc["_location"] = (node_i, exc_i) + exchanges.append(exc) + + df = pd.DataFrame(exchanges) + for col in [col for col in self.standardEdgeColumns if col not in df.columns]: + df[col] = None + df["exc"] = None + + def determine_link_status(row): + input_val = row["input"] + location = row["_location"] + + if not isinstance(input_val, tuple): + return "unlinked" + elif location in self.old_links: + return "relinked" + else: + return "linked" + + df["linked"] = df.apply(determine_link_status, axis=1) + + return df + + def on_mode_switch(self, check: Qt.CheckState): + """Handle the mode switch between simple and detailed view.""" + self.simple = check == Qt.CheckState.Unchecked + self.sync() + + def relink_selected_exchanges(self): + """Open a dialog to link selected exchanges to existing nodes.""" + exchange_locations = self.edge_view.selected_exchanges + if not exchange_locations: + return + + dialog = NodeSelectDialog(parent=self) + if not dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: + return + + selected_node = dialog.get_selected_node() + + for loc in exchange_locations: + node_i, exc_i = loc + + if loc not in self.old_links: + self.old_links[loc] = self.importer.data[node_i]["exchanges"][exc_i].get("input") + + self.importer.data[node_i]["exchanges"][exc_i]["input"] = (selected_node["database"], selected_node["code"]) + + self.sync() + + +class ShiftedCardDelegate(delegates.CardDelegate): + """ + Delegate that shifts the card content to the left to compensate for indentation. + """ + def paint(self, painter, option, index): + # Adjust the rect to shift content left, compensating for indentation + adjusted_option = QtWidgets.QStyleOptionViewItem(option) + adjusted_option.rect.adjust(-28, 0, 0, 0) + + # Call the original paint with adjusted rect + super().paint(painter, adjusted_option, index) + + +class ImportPreviewEdgeView(widgets.ABTreeView): + """View for displaying import preview nodes.""" + + defaultColumnDelegates = { + "exc": ShiftedCardDelegate, + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.callback( + text="Link exchange" if len(p.selected_exchanges) == 1 else "Link exchanges", + func=p.tab.relink_selected_exchanges, + ) + ] + + def __init__(self, importer: LCIImporter, tab: ImportPreviewEdgeTab): + super().__init__(tab) + self.importer = importer + self.old_links = {} + self.tab = tab + + @property + def selected_exchanges(self): + """ + Returns a list of selected exchange locations as (node_index, exchange_index) tuples. These can be used to + identify and manipulate the selected exchanges in the importer's data, which is a list of lists. + """ + return list(set([self.model().get(index, "_location") for index in self.selectedIndexes()])) + + +class ImportPreviewEdgeModel(core.ABTreeModel): + """Model for import preview nodes with node delegate support.""" + + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "exc" or self.row(index) is None: + return super().displayData(index) + + row_data = self.row(index).copy() + row_data.dropna(inplace=True) + + # Build the card information + title = row_data.get('reference product') or row_data.get('name') + subtitle = row_data.get('name') + detail = f"{row_data.get('amount')} {row_data.get('unit')}" + + # Build categories list from unit, location + categories = [] + if row_data.get("type"): + categories.append(str(row_data.get("type"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("categories"): + categories.append(", ".join([str(cat) for cat in row_data.get("categories")])) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + "detail": detail, + } + + + def decorationData(self, index: QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + column_name = self.columns()[index.column()] + if not column_name in ["exc"]: + return super().decorationData(index) + + linked = self.get(index, "linked") + if linked == "linked": + return icons.qicons.link + elif linked == "unlinked": + return icons.qicons.unlink + elif linked == "relinked": + return icons.qicons.relink + return icons.qicons.empty + + def indexSelectable(self, index: QModelIndex) -> bool: + # Don't make the tree column selectable + if index.column() == 0: + return False + return True + + + + + + diff --git a/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py new file mode 100644 index 000000000..4c4eb3c0d --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py @@ -0,0 +1,30 @@ +from qtpy import QtWidgets, QtCore, QtGui + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core + +from .node_tab import ImportPreviewNodeTab +from .edge_tab import ImportPreviewEdgeTab + + +class ImportPreviewDialog(QtWidgets.QDialog): + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.setWindowTitle("Import Preview") + self.resize(600, 400) + + self.importer = importer + self.tabs = QtWidgets.QTabWidget(self) + + self.node_tab = ImportPreviewNodeTab(importer, self) + self.edge_tab = ImportPreviewEdgeTab(importer, self) + + self.tabs.addTab(self.node_tab, "Nodes") + self.tabs.addTab(self.edge_tab, "Edges") + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.tabs) + self.setLayout(layout) diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py new file mode 100644 index 000000000..de6e72f02 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -0,0 +1,172 @@ +from PySide6.QtCore import QModelIndex +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +from loguru import logger + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core, delegates, icons + + +class ImportPreviewNodeTab(QtWidgets.QWidget): + standardNodeColumns = ["type", "name", "product", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", + "database"] + standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] + + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.importer = importer + self.simple = True + + layout = QtWidgets.QVBoxLayout(self) + + self.node_model = ImportPreviewNodeModel(parent=self) + self.node_model.set_dataframe(self.build_df()) + + self.node_view = ImportPreviewNodeView(parent=self) + self.node_view.setModel(self.node_model) + + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + + # Create top bar with toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addStretch() + top_bar.addWidget(self.view_toggle) + + layout.addLayout(top_bar) + layout.addWidget(self.node_view) + + self.sync() + + def sync(self): + """Synchronize the view based on simple/detailed mode.""" + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.node_view.header().setHidden(self.simple) + self.node_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.node_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + df = self.node_model.df.copy() + if self.simple and "_node" in df.columns: + df.rename(columns={"_node": "node"}, inplace=True) + elif not self.simple and "node" in df.columns: + df.rename(columns={"node": "_node"}, inplace=True) + self.node_model.set_dataframe(df) + + for col in self.node_model.columns(): + if col == "index": + continue + index = self.node_model.columns().index(col) + + hidden = (self.simple and not col == "node") or (not self.simple and col == "node") + self.node_view.setColumnHidden(index, hidden) + + def build_df(self): + node_df = pd.DataFrame(self.importer.data) + for col in [col for col in self.standardNodeColumns if col not in node_df.columns]: + node_df[col] = None + + node_df["_exchanges"] = node_df["exchanges"] + node_df["unlinked_exchanges"] = node_df["exchanges"].apply( + lambda x: sum(1 for ex in x if not ex.get("input")) if isinstance(x, list) else 0 + ) + node_df["exchanges"] = node_df["exchanges"].apply(lambda x: len(x) if isinstance(x, list) else 0) + + node_df = node_df[ + self.standardNodeColumns + + [col for col in node_df.columns if col not in self.standardNodeColumns] + ] + node_df["_importer_index"] = range(len(node_df)) + + node_df["node"] = None + + return node_df + + def on_mode_switch(self, check: Qt.CheckState): + """Handle the mode switch between simple and detailed view.""" + self.simple = check == Qt.CheckState.Unchecked + self.sync() + + +class ImportPreviewNodeView(widgets.ABTreeView): + """View for displaying import preview nodes.""" + + defaultColumnDelegates = { + "node": delegates.CardDelegate, + } + + +class ImportPreviewNodeModel(core.ABTreeModel): + """Model for import preview nodes with node delegate support.""" + + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "node": + return super().displayData(index) + + row_data = self.row(index).copy() + row_data.dropna(inplace=True) + + # Get the product or name for title + title = row_data.get("product") or row_data.get("name") + + # Build subtitle with type and database + if row_data.get("categories"): + subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) + elif row_data.get("product"): + subtitle = row_data.get("name") + else: + excs = row_data.get("exchanges") + unlinked = row_data.get("unlinked_exchanges") + nomination = "exchanges" if excs != 1 else "exchange" + + subtitle = f"{excs} {nomination}, {unlinked} unlinked" + + # Build categories list from unit, location + categories = [] + if row_data.get("unit"): + categories.append(str(row_data.get("unit"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + } + + + def decorationData(self, index: QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + column_name = self.columns()[index.column()] + if not column_name in ["node", "type"]: + return super().decorationData(index) + + node_type = self.get(index, "type") + + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py new file mode 100644 index 000000000..ffc805af5 --- /dev/null +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -0,0 +1,196 @@ +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt +import pandas as pd + +from activity_browser.ui import widgets, core, delegates, icons +from activity_browser.app import metadata +from activity_browser.bwutils.commontasks import refresh_node + + +class NodeSelectDialog(QtWidgets.QDialog): + node_selected = QtCore.Signal(dict) + + def __init__(self, parent=None, drag_enabled=False): + super().__init__(parent) + + self.setWindowFlags( + QtCore.Qt.WindowType.Popup | + QtCore.Qt.WindowType.FramelessWindowHint + ) + self.setFixedWidth(400) + self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum) + + self.edit = widgets.ABLineEdit(self) + self.edit.setPlaceholderText("Enter text to search for a node") + self.edit.textChangedDebounce.connect(self.on_search) + + # Create model and tree view for results + self.model = NodeSearchModel(parent=self) + self.tree_view = NodeSearchView(self) + self.tree_view.setModel(self.model) + + self.tree_view.clicked.connect(self.accept) + self.tree_view.dragStarted.connect(self.on_drag_started) + self.tree_view.setDragEnabled(drag_enabled) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 0) + layout.addWidget(self.edit) + layout.addWidget(self.tree_view) + self.setLayout(layout) + + self.setFixedHeight(self.sizeHint().height()) + + def showEvent(self, event): + super().showEvent(event) + self.edit.setFocus() + + def on_search(self, text: str): + if not text.strip(): + # Clear results + self.model.set_dataframe(pd.DataFrame()) + self.tree_view.setFixedHeight(0) + self.adjustSize() + self.setFixedHeight(self.sizeHint().height()) + return + + # Search and get results + result_df = metadata.search(text) + result_df = result_df[0:10] if len(result_df) > 10 else result_df + + # Add a placeholder "node" column for the CardDelegate + result_df["node"] = None + + # Update model with search results + self.model.set_dataframe(result_df) + + # Adjust height based on results + if len(result_df) > 0: + self.tree_view.setFixedHeight(min(400, len(result_df) * 80 + 20)) + else: + self.tree_view.setFixedHeight(0) + + # Adjust dialog to minimum size + self.adjustSize() + self.setFixedHeight(self.sizeHint().height()) + + def on_drag_started(self): + """Handle when a drag operation is started""" + self.hide() # Close the dialog + + def get_selected_node(self): + """Return the currently selected node data""" + index = self.tree_view.currentIndex() + if not index.isValid(): + return None + node_id = self.model.get(index, "id") + if not node_id: + return None + return refresh_node(node_id) + + +class NodeSearchModel(core.ABTreeModel): + """Model for displaying search results in the node select dialog.""" + + def columns(self) -> list[str]: + return ["index", "node"] + + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + return True + + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "node": + return super().displayData(index) + + row_data = self.row(index).copy() + row_data.dropna(inplace=True) + + # Get the product or name for title + title = row_data.get("product") or row_data.get("name") + + # Build subtitle with type and database + if row_data.get("categories"): + subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) + elif row_data.get("product"): + subtitle = row_data.get("name") + else: + subtitle = "" + + # Build categories list from unit, location + categories = [] + if row_data.get("unit"): + categories.append(str(row_data.get("unit"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + } + + def decorationData(self, index: QtCore.QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + node_type = self.get(index, "type") + + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + + def mimeData(self, indices: list[QtCore.QModelIndex]): + """ + Returns the mime data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the mime data for. + + Returns: + core.ABMimeData: The mime data. + """ + data = core.ABMimeData() + keys = [self.row(index).get("key") for index in indices if index.isValid()] + keys = {key for key in keys if isinstance(key, tuple)} + data.setPickleData("application/bw-nodekeylist", list(keys)) + return data + + +class NodeSearchView(widgets.ABTreeView): + """Tree view for displaying node search results.""" + dragStarted: QtCore.SignalInstance = QtCore.Signal() + + defaultColumnDelegates = { + "node": delegates.CardDelegate, + } + + def __init__(self, parent: NodeSelectDialog): + super().__init__(parent) + self.setSelectionBehavior(widgets.ABTreeView.SelectionBehavior.SelectRows) + self.setSelectionMode(widgets.ABTreeView.SelectionMode.SingleSelection) + self.viewport().setBackgroundRole(QtGui.QPalette.ColorRole.Window) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + + self.setHeaderHidden(True) + + self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.setFixedHeight(0) + + + def startDrag(self, supportedActions: Qt.DropAction) -> None: + self.dragStarted.emit() + super().startDrag(supportedActions) + diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main.py similarity index 92% rename from activity_browser/app/main_window.py rename to activity_browser/app/main.py index 45fb30c93..3c0e99629 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main.py @@ -1,7 +1,7 @@ from pathlib import Path from loguru import logger -from qtpy import QtCore, QtWidgets, QtGui +from qtpy import QtCore, QtWidgets import bw2data as bd from activity_browser import app @@ -50,8 +50,21 @@ def __init__(self, parent=None): self.central_widget.tabCloseRequested.connect(self._on_tab_close_requested) self.connect_signals() + self.destroyed.connect(lambda: logger.warning("MainWindow destroyed")) + + def event(self, event): + if event.type() == QtCore.QEvent.Type.DeferredDelete: + for page in self.base_pages.values(): + logger.debug(f"Destroying base page {page.__class__.__name__}: {id(page)}") + try: + page.deleteLater() + except RuntimeError: + # page already deleted + pass + return super().event(event) def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.sync_panes() self.sync_pages() @@ -245,6 +258,7 @@ def connect_signals(self): def clearPanes(self): for pane in self.panes(): + logger.debug(f"Clearing pane {pane.__class__.__name__}: {id(pane)}") pane.deleteLater() def panes(self): @@ -252,6 +266,7 @@ def panes(self): Return a list of all panes in the main window. """ from activity_browser.ui import widgets + QtWidgets.QApplication.processEvents() return self.findChildren(widgets.ABAbstractPane) def set_titlebar(self): diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index cca441e39..46f25507f 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -1,11 +1,12 @@ from importlib.metadata import version +from loguru import logger import bw2data as bd -from qtpy import QtGui, QtWidgets -from qtpy.QtCore import QSize, QUrl +from qtpy import QtGui, QtWidgets, QtCore +from qtpy.QtCore import QSize, QUrl, Qt -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import get_templates from ..ui.icons import qicons @@ -21,15 +22,22 @@ def __init__(self, window): self.project_menu = ProjectMenu(self) self.view_menu = ViewMenu(self) self.calculate_menu = CalculateMenu(self) - # self.tools_menu = ToolsMenu(self) self.help_menu = HelpMenu(self) self.addMenu(self.project_menu) self.addMenu(self.view_menu) self.addMenu(self.calculate_menu) - # self.addMenu(self.tools_menu) self.addMenu(self.help_menu) + self.search_button = QtWidgets.QPushButton(self) + self.search_button.setFlat(True) + self.search_button.setIcon(qicons.search) + self.search_button.setIconSize(QtCore.QSize(13, 13)) + self.search_button.setToolTip("Search project (Ctrl+Shift+F)") + self.search_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.search_button.clicked.connect(app.actions.NodeSelectOpen.run) + self.setCornerWidget(self.search_button, Qt.Corner.TopRightCorner) + class ProjectMenu(QtWidgets.QMenu): """ @@ -159,6 +167,8 @@ def __init__(self, parent=None) -> None: app.signals.meta.calculation_setups_changed.connect(self.sync) def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.cs_actions.clear() for cs in bd.calculation_setups: action = app.actions.CSOpen.get_QAction(cs) diff --git a/activity_browser/app/pages/README.md b/activity_browser/app/pages/README.md new file mode 100644 index 000000000..8802c4c21 --- /dev/null +++ b/activity_browser/app/pages/README.md @@ -0,0 +1,126 @@ +# pages + +Main content pages displayed in the Activity Browser application. + +## Overview + +This directory contains the primary content pages that users interact with in Activity Browser. Each page represents a major functional area and is displayed in the central widget of the main window. + +## Directory Structure + +- **`activity_details/`** - Activity information display and editing +- **`calculation_setup/`** - Calculation setup configuration and management +- **`impact_category_details/`** - Impact category information and visualization +- **`lca_results/`** - LCA calculation results display and analysis +- **`parameters/`** - Parameter management and scenario configuration +- **`settings/`** - Application settings and preferences + +## Key Files + +- **`welcome.py`** - Welcome page shown when no project is open or on first launch +- **`metadatastore.py`** - Metadata management page + +## Page Architecture + +Pages inherit from `AbstractPage` (in `ui/widgets/abstract_page.py`) which provides: +- Consistent layout structure +- Signal connections +- Toolbar integration +- State management + +## Page Lifecycle + +1. **Creation** - Page is instantiated and added to the main window +2. **Display** - User navigates to the page (shown in central widget) +3. **Updates** - Page responds to signals and refreshes data +4. **Interaction** - User performs actions within the page +5. **Persistence** - Page state may be saved when switching away + +## Common Page Features + +### Toolbars +Most pages include a toolbar with actions: +```python +self.toolbar = QToolBar() +self.toolbar.addAction(MyAction.get_QAction()) +``` + +### Data Display +Pages typically contain: +- Tables showing lists of items +- Tree views for hierarchical data +- Charts and plots for visualizations +- Forms for data entry + +### Signal Handling +Pages connect to global signals: +```python +from activity_browser import app + +app.signals.database_changed.connect(self.update_content) +``` + +## Page Navigation + +Users navigate between pages via: +- Menu bar (View menu) +- Toolbar buttons +- Context menus +- Actions triggered by events (e.g., double-click activity → show details) + +## Development Guidelines + +When creating new pages: + +1. **Inherit from AbstractPage** - Use the base class for consistency +2. **Set page title** - Provide a clear, descriptive title +3. **Create toolbar** - Add relevant actions for the page +4. **Connect signals** - Listen for relevant application events +5. **Handle updates** - Refresh data when underlying state changes +6. **Manage state** - Save/restore page state when appropriate +7. **Use threading** - Long operations should not block the UI + +## Subdirectory Details + +### `activity_details/` +Display and edit activity information including: +- Basic activity data (name, location, unit, etc.) +- Exchanges (inputs/outputs) +- Parameters and formulas +- Metadata and classifications + +### `calculation_setup/` +Configure and manage calculation setups: +- Reference flows (functional units) +- Impact assessment methods +- Scenario selections +- Calculation execution + +### `impact_category_details/` +Show impact category information: +- Characterization factors +- Method hierarchy +- Method metadata + +### `lca_results/` +Display LCA calculation results: +- Impact scores +- Contribution analyses +- Sankey diagrams +- Graph visualizations +- Export options + +### `parameters/` +Manage parameters and scenarios: +- Project parameters +- Database parameters +- Activity parameters +- Parameter formulas +- Scenario management + +### `settings/` +Application configuration: +- General preferences +- Project settings +- Plugin configuration +- Import/export settings diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py index f18c0126c..5b2ae11e6 100644 --- a/activity_browser/app/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -17,8 +17,6 @@ from .consumers_tab import ConsumersTab - - class ActivityDetailsPage(QtWidgets.QWidget): """ A widget that displays detailed information about a specific activity. @@ -150,6 +148,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.activity = refresh_node_or_none(self.activity) if self.activity is None: diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index dcb259beb..c9828ef82 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -1,9 +1,10 @@ -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtCore +from loguru import logger import bw2data as bd import bw_functional as bf -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets @@ -38,6 +39,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.activity = refresh_node(self.activity) self.clear_layout() diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 92e10b28d..6240fb7e9 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -1,10 +1,11 @@ from qtpy import QtWidgets +from loguru import logger import pandas as pd import bw2data as bd import bw_functional as bf -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node from activity_browser.ui import widgets, icons, core @@ -51,6 +52,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.activity = refresh_node(self.activity) exchanges = [] if isinstance(self.activity, bf.Process): @@ -97,7 +100,7 @@ def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: return df[cols] -class ConsumersView(widgets.ABNewTreeView): +class ConsumersView(widgets.ABTreeView): """ A view that displays the consumers in a tree structure. """ diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index 0920025a4..b9d2da129 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -1,4 +1,5 @@ from qtpy import QtWidgets, QtCore +from loguru import logger import pandas as pd import bw2data as bd @@ -55,6 +56,8 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.activity = refresh_node(self.activity) df = self.build_df() df.reset_index(drop=True, inplace=True) @@ -90,7 +93,7 @@ def build_df(self) -> pd.DataFrame: return df[cols] -class DataView(widgets.ABNewTreeView): +class DataView(widgets.ABTreeView): """ A view that displays the data in a tree structure. diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py index c4b6c6533..c5605c7bb 100644 --- a/activity_browser/app/pages/activity_details/description_tab.py +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -1,4 +1,5 @@ from qtpy import QtWidgets, QtGui +from loguru import logger import bw2data as bd @@ -29,6 +30,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.activity = refresh_node(self.activity) self.setText(self.activity.get("comment", "")) self.moveCursor(QtGui.QTextCursor.MoveOperation.End) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index b7a788f64..d6a6a9ec1 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -1,4 +1,6 @@ +from PySide6.QtCore import QModelIndex from loguru import logger +from typing import Literal from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt @@ -8,7 +10,7 @@ import bw_functional as bf -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy, is_node_product, is_node_biosphere, parameters_in_scope from activity_browser.ui import widgets, icons, delegates, core @@ -95,6 +97,8 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + # Refresh the activity node self.activity = refresh_node(self.activity) @@ -102,15 +106,20 @@ def sync(self) -> None: production = self.activity.production() technosphere = self.activity.technosphere() biosphere = self.activity.biosphere() + substitution = self.activity.substitution() # Filter inputs and outputs based on the amount and type inputs = ([x for x in production if x["amount"] < 0] + [x for x in technosphere if x["amount"] >= 0] + - [x for x in biosphere if (x.input["type"] != "emission" and x["amount"] >= 0) or (x.input["type"] == "emission" and x["amount"] < 0)]) + [x for x in biosphere if (x.input["type"] != "emission" and x["amount"] >= 0) or (x.input["type"] == "emission" and x["amount"] < 0)] + + [x for x in substitution if x["amount"] < 0] + ) outputs = ([x for x in production if x["amount"] >= 0] + [x for x in technosphere if x["amount"] < 0] + - [x for x in biosphere if (x.input["type"] == "emission" and x["amount"] >= 0) or (x.input["type"] != "emission" and x["amount"] < 0)]) + [x for x in biosphere if (x.input["type"] == "emission" and x["amount"] >= 0) or (x.input["type"] != "emission" and x["amount"] < 0)] + + [x for x in substitution if x["amount"] >= 0] + ) # Update the models with the new data output_df = self.build_df(outputs) @@ -138,8 +147,7 @@ def build_df(self, exchanges) -> pd.DataFrame: "properties", "processor", "categories", "type"] # Create a DataFrame from the exchanges - exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment"]) - exc_df["type"] = [x["type"] for x in exchanges] + exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment", "type"]) exc_df["uncertainty"] = [x.uncertainty for x in exchanges] act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols).rename(columns={"type": "_producer_type"}) @@ -150,6 +158,9 @@ def build_df(self, exchanges) -> pd.DataFrame: right_on="key" ).drop(columns=["key"]) + # Set allocation_factor to NA for non-production exchanges + df.loc[df["type"] != "production", "allocation_factor"] = pd.NA + # Handle properties data if available if not act_df.properties.isna().all(): props_df = act_df[act_df.properties.notna()] @@ -179,7 +190,6 @@ def build_df(self, exchanges) -> pd.DataFrame: # Define the order of columns for the final DataFrame cols = ["amount", "unit", "product", "producer", "location", "categories", "database"] - cols += ["substitute_name", "substitution_factor"] if "substitute_name" in df.columns else [] cols += ["allocation_factor"] if not database_is_legacy(self.activity.get("database")) else [] cols += [col for col in df.columns if col.startswith("property")] cols += ["formula", "comment", "uncertainty"] @@ -197,10 +207,65 @@ def dragEnterEvent(self, event): if database_is_locked(self.activity["database"]): return - if event.mimeData().hasFormat("application/bw-nodekeylist"): - self.overlay = widgets.ABDropOverlay(self) - self.overlay.show() - event.accept() + has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") + has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") + + if not has_nodes and not has_exchanges: + return + + event.accept() + action = self.action_from_mime(event.mimeData()) + + self.input_view.overlay.show() + self.output_view.overlay.show() + + if action == "product": + self.output_view.overlay.setText("Drop to substitute production") + self.input_view.overlay.setText("Drop to consume product") + return + + if action == "waste": + self.output_view.overlay.setText("Drop to produce waste") + self.input_view.overlay.setText("Drop to substitute waste consumption") + return + + if action == "resource": + self.output_view.overlay.hide() + self.input_view.overlay.setText("Drop to consume natural resource") + return + + if action == "emission": + self.input_view.overlay.hide() + self.output_view.overlay.setText("Drop to emit to environment") + return + + + def dragMoveEvent(self, event): + """ + Handles the drag move event to adjust overlay opacity based on hover position. + + Args: + event: The drag move event. + """ + has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") + has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") + + if not has_nodes and not has_exchanges: + return + + if self.input_view.overlay.hovering(): + self.input_view.overlay.setOpacity("high") + self.output_view.overlay.setOpacity("medium") + elif self.output_view.overlay.hovering(): + self.output_view.overlay.setOpacity("high") + self.input_view.overlay.setOpacity("medium") + else: + self.input_view.overlay.setOpacity("medium") + self.output_view.overlay.setOpacity("medium") + event.ignore() + return + + event.accept() def dragLeaveEvent(self, event): """ @@ -210,7 +275,8 @@ def dragLeaveEvent(self, event): event: The drag leave event. """ # Reset the palette on drag leave - self.overlay.deleteLater() + self.input_view.overlay.hide() + self.output_view.overlay.hide() def dropEvent(self, event): """ @@ -220,21 +286,55 @@ def dropEvent(self, event): event: The drop event. """ logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") - # Reset the palette on drop - self.overlay.deleteLater() + self.input_view.overlay.hide() + self.output_view.overlay.hide() + + output = self.output_view.overlay.hovering() keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - exchanges = {"technosphere": set(), "biosphere": set()} + exchanges = {"technosphere": set(), "biosphere": set(), "substitution": set()} for key in keys: - if exc_type := get_exchange_type(key): + if exc_type := get_exchange_type(key, output=output): exchanges[exc_type].add(key) # Run the action for new exchanges for exc_type, keys in exchanges.items(): app.actions.ExchangeNew.run(keys, self.activity.key, exc_type) -def get_exchange_type(activity_key: tuple) -> str | None: + def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", "resource", "emission", "generic"]: + """ + Determines the appropriate action based on the mime data. + + Args: + mime (core.ABMimeData): The mime data. + + """ + keys = mime.retrievePickleData("application/bw-nodekeylist") + data = app.metadata.get_metadata(keys, ["type"]) + data = set(data["type"].unique()) + data.discard("process") + data.discard("multifunctional") + data.discard("nonfunctional") + + if len(data) != 1: + return "generic" + + node_type = data.pop() + if node_type in ["product", "processwithreferenceproduct"]: + return "product" + if node_type == "waste": + return "waste" + if node_type == "natural resource": + return "resource" + if node_type == "emission": + return "emission" + else: + return "generic" + +def get_exchange_type(activity_key: tuple, output=False) -> str | None: + if output and is_node_product(activity_key): + return "substitution" if is_node_product(activity_key): return "technosphere" elif is_node_biosphere(activity_key): @@ -305,7 +405,7 @@ def setModelData(self, editor: QtWidgets.QComboBox, model, index): ) -class ExchangesView(widgets.ABNewTreeView): +class ExchangesView(widgets.ABTreeView): """ A view that displays the exchanges in a tree structure. @@ -431,7 +531,8 @@ def activity(self): @property def exchanges(self): indexes = self.parent().selectedIndexes() - return list(set(idx.internalPointer().exchange for idx in indexes if idx.isValid())) + exchanges = [i.model().get(i, "_exchange") for i in indexes] + return list(set(exchanges)) def __init__(self, parent): """ @@ -443,6 +544,12 @@ def __init__(self, parent): super().__init__(parent) self.setSortingEnabled(True) + # Enable drag and drop + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.DragDrop) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.drag_drop_hint = QtWidgets.QLabel("Drag products here to create new exchanges.", self) fnt = self.drag_drop_hint.font() fnt.setPointSize(fnt.pointSize() + 2) @@ -452,11 +559,13 @@ def __init__(self, parent): # Set up the layout layout = QtWidgets.QVBoxLayout(self) layout.addStretch() - layout.addWidget(self.drag_drop_hint, alignment=Qt.AlignCenter) # Center horizontally + layout.addWidget(self.drag_drop_hint, alignment=Qt.AlignmentFlag.AlignCenter) # Center horizontally layout.addStretch() # Set the property delegate self.propertyDelegate = delegates.PropertyDelegate(self) + self.overlay = widgets.ABDropOverlay(self) + self.overlay.hide() @property def activity(self): @@ -481,6 +590,18 @@ def setDefaultColumnDelegates(self): # Set the delegate for property columns self.setItemDelegateForColumn(i, self.propertyDelegate) + def startDrag(self, supportedActions: Qt.DropAction) -> None: + """ + Initiates a drag operation with the selected exchanges. + + Args: + supportedActions: The supported drop actions. + """ + if database_is_locked(self.activity["database"]): + return + + super().startDrag(supportedActions) + class ExchangesModel(core.ABTreeModel): """ @@ -489,7 +610,32 @@ class ExchangesModel(core.ABTreeModel): def __init__(self, tab: ExchangesTab): super().__init__(parent=tab) self.tab = tab - + + def mimeTypes(self) -> list[str]: + """ + Returns the list of MIME types that this model supports. + + Returns: + list[str]: List of supported MIME types. + """ + return ["application/bw-exchangelist"] + + def mimeData(self, indices: list[QtCore.QModelIndex]) -> core.ABMimeData: + """ + Returns the MIME data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the MIME data for. + + Returns: + core.ABMimeData: The MIME data containing the exchanges. + """ + data = core.ABMimeData() + exchanges = [self.get(index, "_exchange") for index in indices if index.isValid() and index.column() == 0] + exchanges = [exc for exc in exchanges if exc is not None] + data.setPickleData("application/bw-exchangelist", exchanges) + return data + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ Sets the data for the given index. @@ -590,6 +736,12 @@ def fontData(self, index: QtCore.QModelIndex) -> any: Returns: QtGui.QFont: The font data for the index. """ + if self.substituted(index): + font = QtGui.QFont() + font.setItalic(True) + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + if self.functional(index): font = QtGui.QFont() font.setWeight(QtGui.QFont.Weight.DemiBold) @@ -628,6 +780,9 @@ def indexEditable(self, index): return True return False + + def indexDragEnabled(self, index: QModelIndex) -> bool: + return True def functional(self, index): """ @@ -640,6 +795,18 @@ def functional(self, index): bool: True if the index is functional, False otherwise. """ return self.get(index, "_exchange_type") == "production" + + def substituted(self, index): + """ + Returns whether the index is functional. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is functional, False otherwise. + """ + return self.get(index, "_exchange_type") == "substitution" def scoped_parameters(self, index): """ diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py index ebb28d61c..8b44a7f86 100644 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -73,6 +73,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.activity = refresh_node(self.activity) json = self.build_json() self.bridge.update_graph.emit(json) diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 2dc2cc2b1..c8fbe6395 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -1,5 +1,6 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt +from loguru import logger import pandas as pd import bw2data as bd @@ -7,7 +8,6 @@ from activity_browser import app from activity_browser.ui import widgets, icons, delegates, core from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group -from activity_browser.bwutils.utils import Parameter class ParametersTab(QtWidgets.QWidget): @@ -59,6 +59,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.activity = refresh_node(self.activity) df = self.build_df() df.reset_index(drop=True, inplace=True) @@ -79,7 +81,7 @@ def build_df(self) -> pd.DataFrame: for name, param in data.items(): row = param._asdict() - row["uncertainty"] = param.data.get("uncertainty type") + row["uncertainty"] = param.uncertainty row["formula"] = param.data.get("formula") row["comment"] = param.data.get("comment") row["_parameter"] = param @@ -100,7 +102,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(translated, columns=columns) -class ParametersView(widgets.ABNewTreeView): +class ParametersView(widgets.ABTreeView): """ A view that displays the parameters in a tree structure. @@ -199,6 +201,12 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. app.actions.ParameterModify.run(parameter, column_name, value) return True + if column_name == "uncertainty": + parameter = refresh_parameter(parameter) + app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) + + return True + return False def decorationData(self, index: QtCore.QModelIndex) -> any: diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py index c2a9f0775..17e2dc0f6 100644 --- a/activity_browser/app/pages/calculation_setup/calculation_setup.py +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -1,6 +1,7 @@ from qtpy import QtWidgets +from loguru import logger -from activity_browser import app, app +from activity_browser import app from activity_browser.ui import widgets, icons from .scenario_section import ScenarioSection @@ -70,6 +71,8 @@ def connect_signals(self): self.run_button.released.connect(self.run_calculation) def sync(self) -> None: + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.functional_unit_section.sync() self.impact_category_section.sync() diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index d29026d3e..2a0c6145a 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -1,5 +1,6 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt +from loguru import logger import bw2data as bd import pandas as pd @@ -28,6 +29,8 @@ def build_layout(self): self.setLayout(layout) def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] df = self.build_df() @@ -84,7 +87,7 @@ def build_df(self): return act_df[cols].reset_index(drop=True) -class FunctionalUnitView(widgets.ABNewTreeView): +class FunctionalUnitView(widgets.ABTreeView): defaultColumnDelegates = { "amount": delegates.AmountDelegate } diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index c9db43f66..d3119a331 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -1,5 +1,5 @@ -from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Qt +from qtpy import QtWidgets +from loguru import logger import bw2data as bd import pandas as pd @@ -27,6 +27,8 @@ def build_layout(self): self.setLayout(layout) def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] df = self.build_df() @@ -48,7 +50,7 @@ def build_df(self): return df[cols] -class ImpactCategoryView(widgets.ABNewTreeView): +class ImpactCategoryView(widgets.ABTreeView): defaultColumnDelegates = { "name": delegates.StringDelegate } diff --git a/activity_browser/app/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py index a578868c7..ff7040fef 100644 --- a/activity_browser/app/pages/calculation_setup/scenario_section.py +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -25,6 +25,9 @@ def __init__(self, parent=None): self._scenario_dataframe = pd.DataFrame() # set up the control buttons + self.get_template_btn = app.actions.SaveParametersToExcel.get_QButton() + self.get_template_btn.setText("Parameter template...") + self.table_btn = QtWidgets.QPushButton("Add scenarios...", self) self.save_scenario = QtWidgets.QPushButton("Save to file...", self) @@ -63,6 +66,7 @@ def __init__(self, parent=None): tool_row.addWidget(widgets.ABLabel.demiBold(" Scenarios:", self)) tool_row.addStretch() + tool_row.addWidget(self.get_template_btn) tool_row.addWidget(self.table_btn) tool_row.addWidget(self.save_scenario) tool_row.addWidget(self.group_box) diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 4569c9aa5..e46fd9537 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -1,5 +1,6 @@ from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt +from loguru import logger import bw2data as bd import pandas as pd @@ -46,6 +47,8 @@ def on_method_deleted(self, method): self.deleteLater() def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + if self.name not in bd.methods: self.deleteLater() return @@ -66,7 +69,7 @@ def build_layout(self): def build_df(self): df = pd.DataFrame(self.impact_category.load(), columns=["id", "data"]) df["amount"] = df["data"].apply(lambda x: x if isinstance(x, (float, int)) else x.get("amount")) - df["uncertainty"] = df["data"].apply(lambda x: 0 if isinstance(x, (float, int)) else x.get("uncertainty type")) + df["uncertainty"] = df["data"].apply(self.uncertainty_from_cf) other = app.metadata.dataframe[["id", "name", "categories", "database", "unit"]] @@ -77,8 +80,22 @@ def build_df(self): cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf", "_editable"] return df[cols] - -class CharacterizationFactorsView(widgets.ABNewTreeView): + def uncertainty_from_cf(self, cf): + if isinstance(cf, dict): + uncertainty_keys = { + "uncertainty type", + "loc", + "scale", + "shape", + "minimum", + "maximum", + "negative", + } + return {k: v for k, v in cf.items() if k in uncertainty_keys} + return 0 + + +class CharacterizationFactorsView(widgets.ABTreeView): defaultColumnDelegates = { "amount": delegates.FloatDelegate, "categories": delegates.ListDelegate, @@ -188,9 +205,22 @@ class CharacterizationFactorsModel(core.ABTreeModel): A model representing the characterization factors data. """ def __init__(self, page: ImpactCategoryDetailsPage): - super().__init__(parent=page) + super().__init__(parent=page, enable_sorting=True) self.page = page + def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + """ + Sorts the model based on the given column and order. + + Args: + column (int): The column index to sort by. + order (Qt.SortOrder): The order to sort (ascending or descending). + """ + column_name = self.columns()[column] + if column_name == "uncertainty": + return + super().sort(column, order) + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ Sets the data for the given index. @@ -216,6 +246,12 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. app.actions.CFAmountModify.run(row["_impact_category_name"], row["_id"], value) return True + if column_name == "uncertainty": + app.actions.CFUncertaintyModify.run( + row["_impact_category_name"], [(row["_id"], row["_cf"])], uncertainty_dict=value + ) + return True + return False def decorationData(self, index: QtCore.QModelIndex) -> any: diff --git a/activity_browser/app/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py index 2b26afbd9..492728e7c 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_header.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_header.py @@ -1,4 +1,6 @@ from qtpy import QtWidgets, QtCore +from loguru import logger + from activity_browser import app from activity_browser.ui import widgets @@ -42,6 +44,8 @@ def sync(self): Synchronizes the widget with the current state of the impact category. Switches between editable and view-only headers based on edit mode. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.impact_category = self.parent().impact_category # Update both headers with current data @@ -101,6 +105,8 @@ def sync(self): """ Updates the displayed information from the current impact category. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + impact_category = self.parent().impact_category self.name_label.setText(" | ".join(impact_category.name)) self.unit_label.setText(impact_category.metadata.get("unit", "Undefined")) @@ -145,6 +151,8 @@ def sync(self): """ Updates the displayed information from the current impact category. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + impact_category = self.parent().impact_category self.name_label.setText(f"{' | '.join(impact_category.name)}") self.unit_edit.setText(impact_category.metadata.get("unit", "Undefined")) diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py index fb8666cdf..b475f091c 100644 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -11,21 +11,21 @@ from stats_arrays.errors import InvalidParamsError -from activity_browser import app, settings +from activity_browser import app from activity_browser.bwutils.commontasks import unit_of_method, get_LCIA_method_name_dict, format_activity_label from activity_browser.bwutils.sensitivity_analysis import GlobalSensitivityAnalysis from activity_browser.mod.bw2analyzer import ABContributionAnalysis -from activity_browser.ui import icons, web, widgets +from activity_browser.ui import icons, widgets from .style import header, horizontal_line, vertical_line from .tables import ContributionTable, InventoryTable, LCAResultsTable from .plots import ContributionPlot, CorrelationPlot, LCAResultsBarChart, LCAResultsPlot, MonteCarloPlot +from .sankey_navigator import SankeyNavigatorWidget +from .tree_navigator import TreeNavigatorWidget ca = ABContributionAnalysis() - - def get_header_layout(header_text: str) -> QtWidgets.QVBoxLayout: vlayout = QtWidgets.QVBoxLayout() vlayout.addWidget(header(header_text)) @@ -118,8 +118,8 @@ def __init__(self, cs_name, mlca, contributions, mc, parent=None): ef=ElementaryFlowContributionTab(self), process=ProcessContributionsTab(self), # ft=FirstTierContributionsTab(self.cs_name, parent=self), - sankey=web.SankeyNavigatorWidget(self.cs_name, parent=self), - tree=web.TreeNavigatorWidget(self.cs_name, parent=self), + sankey=SankeyNavigatorWidget(self.cs_name, parent=self), + tree=TreeNavigatorWidget(self.cs_name, parent=self), mc=MonteCarloTab( self ), # mc=None if self.mc is None else MonteCarloTab(self), @@ -330,10 +330,6 @@ def total_check(self, checked: bool): def score_mrk_check(self, checked: bool): self.score_marker = checked - settings.project_settings.settings["analysis_tab"] = settings.project_settings.settings.get("analysis_tab", {}) - settings.project_settings.settings["analysis_tab"][f"{self.__class__.__name__}score_marker_enabled"] = checked - settings.project_settings.write_settings() - self.update_tab() def get_scenario_labels(self) -> List[str]: @@ -967,7 +963,7 @@ def __init__(self, parent, **kwargs): self.total_group.addButton(self.total_menu.score) self.total_group.addButton(self.total_menu.range) - self.score_marker = settings.project_settings.settings.get("analysis_tab", {}).get(f"{self.__class__.__name__}score_marker_enabled", False) + self.score_marker = False self.score_mrk_checkbox = QtWidgets.QCheckBox("Score Marker") self.score_mrk_checkbox.setToolTip( "Shows the score marker. When there are both positive and negative results,\n" diff --git a/activity_browser/ui/web/sankey_navigator.py b/activity_browser/app/pages/lca_results/sankey_navigator.py similarity index 96% rename from activity_browser/ui/web/sankey_navigator.py rename to activity_browser/app/pages/lca_results/sankey_navigator.py index dab378aeb..a71948e4b 100644 --- a/activity_browser/ui/web/sankey_navigator.py +++ b/activity_browser/app/pages/lca_results/sankey_navigator.py @@ -6,7 +6,6 @@ from loguru import logger import bw2calc as bc -import bw2data as bd import numpy from bw_graph_tools.graph_traversal import Edge as GraphEdge from bw_graph_tools.graph_traversal import NewNodeEachVisitGraphTraversal @@ -19,26 +18,13 @@ from activity_browser.mod import bw2data as bd from bw2data.backends import ActivityDataset -from ...bwutils.commontasks import identify_activity_type -from .base import BaseGraph, BaseNavigatorWidget +from activity_browser.bwutils.commontasks import identify_activity_type +from activity_browser.bwutils.filesystem import get_package_path +from activity_browser.ui import widgets - -# TODO: -# switch between percent and absolute values -# when avoided impacts, then the scaling between 0-1 of relative impacts does not work properly -# ability to navigate to activities -# ability to calculate LCA for selected activities -# ability to expand (or reduce) the graph -# save graph as image -# random_graph should not work for biosphere - -# in Javascript: -# - zoom behaviour - - -class SankeyNavigatorWidget(BaseNavigatorWidget): +class SankeyNavigatorWidget(widgets.ABAbstractNavigator): HELP_TEXT = """ LCA Sankey: @@ -46,9 +32,7 @@ class SankeyNavigatorWidget(BaseNavigatorWidget): Green flows: Avoided impacts """ - HTML_FILE = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "../../static/sankey_navigator.html" - ) + HTML_FILE = str(get_package_path() / "static" / "sankey_navigator.html") def __init__(self, cs_name, parent=None): super().__init__(parent, css_file="sankey_navigator.css") @@ -331,7 +315,7 @@ def make_serializable(data: dict) -> dict: return data -class Graph(BaseGraph): +class Graph(widgets.ABAbstractGraph): """ Python side representation of the graph. Functionality for graph navigation (e.g. adding and removing nodes). diff --git a/activity_browser/app/pages/lca_results/tables.py b/activity_browser/app/pages/lca_results/tables.py index d575ea62a..a44677581 100644 --- a/activity_browser/app/pages/lca_results/tables.py +++ b/activity_browser/app/pages/lca_results/tables.py @@ -12,9 +12,9 @@ from qtpy.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal, Slot, SignalInstance from qtpy.QtWidgets import QSizePolicy, QTableView -from activity_browser.settings import ab_settings from activity_browser.ui.icons import qicons from activity_browser.ui import delegates +from activity_browser.bwutils import filesystem from .dialogs import FilterManagerDialog, SimpleFilterDialog @@ -517,7 +517,7 @@ def savefilepath( filepath, _ = QtWidgets.QFileDialog.getSaveFileName( parent=self, caption=caption, - dir=str(os.path.join(ab_settings.data_dir, safe_name)), + dir=str(os.path.join(filesystem.get_project_path(), safe_name)), filter=file_filter or self.ALL_FILTER, ) # getSaveFileName can now weirdly return Path objects. diff --git a/activity_browser/ui/web/tree_navigator.py b/activity_browser/app/pages/lca_results/tree_navigator.py similarity index 98% rename from activity_browser/ui/web/tree_navigator.py rename to activity_browser/app/pages/lca_results/tree_navigator.py index 50bca12f9..d3d2ba158 100644 --- a/activity_browser/ui/web/tree_navigator.py +++ b/activity_browser/app/pages/lca_results/tree_navigator.py @@ -24,9 +24,7 @@ from activity_browser import app from activity_browser.bwutils.filesystem import get_package_path from activity_browser.bwutils.commontasks import identify_activity_type -from activity_browser.ui.widgets import CheckableComboBox - -from .base import BaseGraph, BaseNavigatorWidget +from activity_browser.ui import widgets class SmallComboBox(QtWidgets.QComboBox): @@ -39,7 +37,8 @@ def __init__(self, parent=None): self.setMaximumWidth(200) self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContentsOnFirstShow) -class TreeNavigatorWidget(BaseNavigatorWidget): + +class TreeNavigatorWidget(widgets.ABAbstractNavigator): HELP_TEXT = """ LCA Dynamic Tree Navigator: @@ -68,7 +67,7 @@ def __init__(self, cs_name, parent=None): self.func_unit_cb = SmallComboBox() self.method_cb = SmallComboBox() self.scenario_cb = SmallComboBox() - self.tag_cb = CheckableComboBox() + self.tag_cb = widgets.CheckableComboBox() self.cutoff_sb = QtWidgets.QDoubleSpinBox() self.max_calc_sb = QtWidgets.QDoubleSpinBox() self.button_calculate = QtWidgets.QPushButton("Calculate") @@ -326,7 +325,7 @@ def update_graph(self, click_dict: dict) -> None: self.send_json() -class Graph(BaseGraph): +class Graph(widgets.ABAbstractGraph): """ Python side representation of the graph. Functionality for graph navigation (e.g. adding and removing nodes). diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py index 11f5ddf19..b1336b260 100644 --- a/activity_browser/app/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -1,6 +1,7 @@ from qtpy import QtWidgets +from loguru import logger -from activity_browser.ui import widgets, delegates +from activity_browser.ui import widgets, delegates, core from activity_browser.app import metadata, signals @@ -9,7 +10,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("MetaDataStorePage") - self.model = MDSModel(self, metadata.dataframe) + self.model = core.ABTreeModel(metadata.dataframe, self, chunk_size=50) self.view = MDSView(self) self.view.setModel(self.model) @@ -20,7 +21,8 @@ def connect_signals(self): signals.metadata.synced.connect(self.sync) def sync(self): - self.model.setDataFrame(metadata.dataframe) + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.model.set_dataframe(metadata.dataframe) def build_layout(self): layout = QtWidgets.QVBoxLayout() @@ -32,9 +34,3 @@ class MDSView(widgets.ABTreeView): def __init__(self, parent=None): super().__init__(parent) self.setItemDelegate(delegates.StringDelegate(self)) - -class MDSItem(widgets.ABDataItem): - pass - -class MDSModel(widgets.ABItemModel): - pass diff --git a/activity_browser/app/pages/parameters/__init__.py b/activity_browser/app/pages/parameters/__init__.py index 7a1d89bfd..dd02adad4 100644 --- a/activity_browser/app/pages/parameters/__init__.py +++ b/activity_browser/app/pages/parameters/__init__.py @@ -1,2 +1,2 @@ -from .parameters_new import ParametersPage +from .parameters import ParametersPage diff --git a/activity_browser/app/pages/parameters/base.py b/activity_browser/app/pages/parameters/base.py deleted file mode 100644 index fd99f3e5c..000000000 --- a/activity_browser/app/pages/parameters/base.py +++ /dev/null @@ -1,1197 +0,0 @@ -import os -import datetime -from typing import Optional, Any -from loguru import logger - -import arrow -import numpy as np -import pandas as pd -import bw2data as bd - -from qtpy.QtCore import QAbstractItemModel, QAbstractTableModel, QModelIndex, QSortFilterProxyModel -from qtpy import QtGui, QtWidgets -from qtpy.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal, Slot -from qtpy.QtWidgets import QApplication, QSizePolicy, QTableView - -from activity_browser.settings import ab_settings -from activity_browser.ui import icons, widgets, delegates - - - - -class ABSortProxyModel(QSortFilterProxyModel): - """Reimplementation to allow for sorting on the actual data in cells instead of the visible data. - - See this for context: https://github.com/LCA-ActivityBrowser/activity-browser/pull/1151 - """ - - def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: - """Override to sort actual data, expects `left` and `right` are comparable. - - If `left` and `right` are not the same type, we check if numerical and empty string are compared, if that is the - case, we assume empty string == 0. - Added this case for: https://github.com/LCA-ActivityBrowser/activity-browser/issues/1215 - """ - left_data = self.sourceModel().data(left, "sorting") - right_data = self.sourceModel().data(right, "sorting") - - if not left_data and not right_data: - return True - if type(left_data) is type(right_data): - return left_data < right_data - - # comparing Falsys with types - if (isinstance(left_data, (int, float)) - and not right_data - ): # comparing left number with nothing, compare against '0' instead - return left_data < 0 - if (isinstance(left_data, str) - and not right_data - ): # comparing left str with nothing, compare against "" instead - return left_data < "" # note we use '>' instead of '<', content should be above empty fields - if (isinstance(right_data, (int, float)) - and not left_data - ): # comparing right number with nothing, compare against '0' instead - return 0 < right_data - if (isinstance(right_data, str) - and not left_data - ): # comparing right str with nothing, compare against "" instead - return right_data < "" # note we use '>' instead of '<', content should be above empty fields - - raise ValueError( - f"Cannot compare {left_data} and {right_data}, incompatible types." - ) - - -class ABDataFrameView(QtWidgets.QTableView): - """Base class for showing pandas dataframe objects as tables.""" - - ALL_FILTER = "All Files (*.*)" - CSV_FILTER = "CSV (*.csv);; All Files (*.*)" - TSV_FILTER = "TSV (*.tsv);; All Files (*.*)" - EXCEL_FILTER = "Excel (*.xlsx);; All Files (*.*)" - - def __init__(self, parent=None): - super().__init__(parent) - self.setVerticalScrollMode(QTableView.ScrollPerPixel) - self.setHorizontalScrollMode(QTableView.ScrollPerPixel) - - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) - - self.setWordWrap(True) - self.setAlternatingRowColors(True) - self.setSortingEnabled(True) - - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setHighlightSections(False) - self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) - - self.verticalHeader().setDefaultSectionSize(22) # row height - self.verticalHeader().setVisible(False) - # Use a custom ViewOnly delegate by default. - # Can be overridden table-wide or per column in child classes. - self.setItemDelegate(delegates.ViewOnlyDelegate(self)) - - self.table_name = "LCA results" - # Initialize attributes which are set during the `sync` step. - # Creating (and typing) them here allows PyCharm to see them as - # valid attributes. - self.model: Optional[PandasModel] = None - self.proxy_model: Optional[ABSortProxyModel] = None - - def rowCount(self) -> int: - return 0 if self.model is None else self.model.rowCount() - - @Slot(name="updateProxyModel") - def update_proxy_model(self) -> None: - self.proxy_model = ABSortProxyModel(self) - self.proxy_model.setSourceModel(self.model) - self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - @Slot(name="exportToClipboard") - def to_clipboard(self): - """Copy dataframe to clipboard""" - rows = list(range(self.model.rowCount())) - cols = list(range(self.model.columnCount())) - self.model.to_clipboard(rows, cols, include_header=True) - - def savefilepath( - self, default_file_name: str, caption: str = None, file_filter: str = None - ): - """Construct and return default path where data is stored - - Uses the application directory for AB - """ - safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) - caption = caption or "Choose location to save lca results" - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption=caption, - dir=os.path.join(ab_settings.data_dir, safe_name), - filter=file_filter or self.ALL_FILTER, - ) - # getSaveFileName can now weirdly return Path objects. - return str(filepath) if filepath else filepath - - @Slot(name="exportToCsv") - def to_csv(self): - """Save the dataframe data to a CSV file.""" - filepath = self.savefilepath(self.table_name, file_filter=self.CSV_FILTER) - if filepath: - if not filepath.endswith(".csv"): - filepath += ".csv" - self.model.to_csv(filepath) - - @Slot(name="exportToExcel") - def to_excel(self, caption: str = None): - """Save the dataframe data to an excel file.""" - filepath = self.savefilepath( - self.table_name, caption, file_filter=self.EXCEL_FILTER - ) - if filepath: - if not filepath.endswith(".xlsx"): - filepath += ".xlsx" - self.model.to_excel(filepath) - - @Slot(QtGui.QKeyEvent, name="copyEvent") - def keyPressEvent(self, e): - """Allow user to copy selected data from the table - - NOTE: by default, the table headers (column names) are also copied. - """ - if e.modifiers() & Qt.ControlModifier: - # Should we include headers? - headers = e.modifiers() & Qt.ShiftModifier - if e.key() == Qt.Key_C: # copy - selection = [ - self.model.proxy_to_source(p) for p in self.selectedIndexes() - ] - rows = [index.row() for index in selection] - columns = [index.column() for index in selection] - rows = sorted(set(rows), key=rows.index) - columns = sorted(set(columns), key=columns.index) - self.model.to_clipboard(rows, columns, headers) - - -class ABFilterableDataFrameView(ABDataFrameView): - """Filterable base class for showing pandas dataframe objects as tables. - - To use this table, the following MUST be set in the table model: - - self.filterable_columns: dict - --> these columns are available for filtering - --> key is column name, value is column index - - To use this table, the following MUST be set in the table view: - - self.header.column_indices = list(self.model.filterable_columns.values()) - --> If not set, no filter buttons will appear. - --> Probably wise to set in a `if isinstance(self.model.filterable_columns, dict):` - --> This variable must be set any time the columns of the table change - - To use this table, the following can be set in the table model: - - self.different_column_types: dict - --> these columns require a different filter type than 'str' - --> e.g. self.different_column_types = {'col_name': 'num'} - """ - - FILTER_TYPES = { - "str": [ - "contains", - "does not contain", - "equals", - "does not equal", - "starts with", - "does not start with", - "ends with", - "does not end with", - ], - "str_tt": [ - "values in the column contain", - "values in the column do not contain", - "values in the column equal", - "values in the column do not equal", - "values in the column start with", - "values in the column do not start with", - "values in the column end with", - "values in the column do not end with", - ], - "num": ["=", "!=", ">=", "<=", "<= x <="], - "num_tt": [ - "values in the column equal", - "values in the column do not equal", - "values in the column are greater than or equal to", - "values in the column are smaller than or equal to", - "values in the column are between", - ], - } - - def __init__(self, parent=None): - super().__init__(parent) - - self.header = CustomHeader() - self.setHorizontalHeader(self.header) - - self.filters = None - self.different_column_types = {} - self.header.clicked.connect(self.header_filter_button_clicked) - self.selected_column = 0 - - # quick-filter setup: - self.prev_quick_filter = {} - self.debounce_quick_filter = QTimer() - self.debounce_quick_filter.setInterval(300) - self.debounce_quick_filter.setSingleShot(True) - self.debounce_quick_filter.timeout.connect(self.quick_filter) - - def header_filter_button_clicked(self, column: int, button: str) -> None: - self.selected_column = column - # this function is separate from the context menu in case we want to add right-click options later - if button == "LeftButton": - self.header_context_menu() - - def header_context_menu(self) -> None: - menu = QtWidgets.QMenu(self) - menu.setToolTipsVisible(True) - - col_type = self.model.different_column_types.get( - {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ], - "str", - ) - - # quick-filter bar - self.input_line = QtWidgets.QLineEdit() - self.input_line.setFocusPolicy(Qt.StrongFocus) - if col_type == "num": - self.input_line.setValidator(QtGui.QDoubleValidator()) - search = QtWidgets.QToolButton() - search.setIcon(icons.qicons.search) - search.clicked.connect(menu.close) - quick_filter_layout = QtWidgets.QHBoxLayout() - quick_filter_layout.addWidget(self.input_line) - quick_filter_layout.addWidget(search) - quick_filter_widget = QtWidgets.QWidget() - quick_filter_widget.setLayout(quick_filter_layout) - quick_filter_widget.setToolTip( - "Filter this column on the input,\n" - "press 'enter' or the search button to filter" - ) - # write previous filter to the quick-filter input if we have one - if prev_filter := self.prev_quick_filter.get(self.selected_column, False): - self.input_line.setText(prev_filter[1]) - else: - self.input_line.setPlaceholderText("Quick filter ...") - self.input_line.textChanged.connect(self.debounce_quick_filter.start) - self.input_line.returnPressed.connect(menu.close) - QAline = QtWidgets.QWidgetAction(self) - QAline.setDefaultWidget(quick_filter_widget) - menu.addAction(QAline) - - # More filters submenu - mf_menu = QtWidgets.QMenu(menu) - mf_menu.setToolTipsVisible(True) - mf_menu.setIcon(icons.qicons.filter) - mf_menu.setTitle("More filters") - filter_actions = [] - for i, f in enumerate(self.FILTER_TYPES[col_type]): - fa = QtWidgets.QAction(text=f) - fa.setToolTip(self.FILTER_TYPES[col_type + "_tt"][i]) - fa.triggered.connect(self.simple_filter_dialog) - filter_actions.append(fa) - for fa in filter_actions: - mf_menu.addAction(fa) - menu.addMenu(mf_menu) - # edit filters main menu - filter_man = QtWidgets.QAction(icons.qicons.edit, "Manage filters") - filter_man.triggered.connect(self.filter_manager_dialog) - filter_man.setToolTip("Open the filter management menu") - menu.addAction(filter_man) - # delete column filters option - col_del = QtWidgets.QAction(icons.qicons.delete, "Remove column filters") - col_del.triggered.connect(self.reset_column_filters) - col_del.setToolTip("Remove all filters on this column") - menu.addAction(col_del) - col_del.setEnabled(False) - if isinstance(self.filters, dict) and self.filters.get( - self.selected_column, False - ): - col_del.setEnabled(True) - # delete all filters option - all_del = QtWidgets.QAction(icons.qicons.delete, "Remove all filters") - all_del.triggered.connect(self.reset_filters) - all_del.setToolTip("Remove all filters in this table") - menu.addAction(all_del) - all_del.setEnabled(False) - if isinstance(self.filters, dict): - all_del.setEnabled(True) - - # Show existing filters for column - if isinstance(self.filters, dict) and self.filters.get( - self.selected_column, False - ): - menu.addSeparator() - active_filters_label = QtWidgets.QAction( - icons.qicons.filter, "Active column filters:" - ) - active_filters_label.setEnabled(False) - menu.addAction(active_filters_label) - active_filters = [] - for filter_data in self.filters[self.selected_column]["filters"]: - if filter_data[0] == "<= x <=": - q = " and ".join(filter_data[1]) - else: - q = filter_data[1] - filter_str = ": ".join([filter_data[0], q]) - f = QtWidgets.QAction(text=filter_str) - f.setEnabled(False) - active_filters.append(f) - for f in active_filters: - menu.addAction(f) - - self.input_line.setFocus() - loc = self.header.event_pos - menu.exec_(self.mapToGlobal(loc)) - - @Slot(name="updateProxyModel") - def update_proxy_model(self) -> None: - self.proxy_model = ABMultiColumnSortProxyModel(self) - self.proxy_model.setSourceModel(self.model) - self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - def quick_filter(self) -> None: - # remove weird whitespace from input - query = ( - self.input_line.text().translate(str.maketrans("", "", "\n\t\r")).strip() - ) - - # convert to filter - col_name = {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ] - if self.model.different_column_types.get(col_name): - # column is type 'num' - filt = ("=", query) - else: - # column is type 'str' - filt = ("contains", query, False) - # check if quick filter exists for this col, if so; remove from self.filters - if prev_filter := self.prev_quick_filter.get(self.selected_column, False): - self.filters[self.selected_column]["filters"].remove(prev_filter) - - # place the filter in self.prev_quick_filter for next quick filter on this column - self.prev_quick_filter[self.selected_column] = filt - - # apply the right filters - if query != "": - # the query is not empty, add it to the filters and apply them - self.add_filter(filt) - self.apply_filters() - elif len(self.filters[self.selected_column]["filters"]) > 0: - # the query is empty, but there are still filters for this column, so apply the filters - self.apply_filters() - else: - # the query is empty, and there are no more filters for this column, reset this filter. - self.reset_column_filters() - - def filter_manager_dialog(self) -> None: - # get right data - column_names = self.model.filterable_columns - - # show dialog - dialog = widgets.dialog.FilterManagerDialog( - column_names=column_names, - filters=self.filters, - filter_types=self.FILTER_TYPES, - selected_column=self.selected_column, - column_types=self.model.different_column_types, - ) - if dialog.exec_() == widgets.dialog.FilterManagerDialog.Accepted: - # set the filters - filters = dialog.get_filters - if filters != self.filters: - # the filters returned from the dialog are different, actually apply the filters - rm = [] - for col, qf in self.prev_quick_filter.items(): - # check if quickfilters exist for these columns, otherwise remove them - if ( - filters.get(col, False) and qf not in filters[col]["filters"] - ) or not filters.get(col, False): - rm.append(col) - for col in rm: - self.prev_quick_filter.pop(col) - self.write_filters(filters) - self.apply_filters() - - def simple_filter_dialog(self, preset_type: str = None) -> None: - if not preset_type: - preset_type = self.sender().text() - - # get right data - column_name = {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ] - col_type = self.model.different_column_types.get(column_name, "str") - - # show dialog - dialog = widgets.dialog.SimpleFilterDialog( - column_name=column_name, - filter_types=self.FILTER_TYPES, - column_type=col_type, - preset_type=preset_type, - ) - if dialog.exec_() == widgets.dialog.SimpleFilterDialog.Accepted: - new_filter = dialog.get_filter - # add the filter to existing filters - if new_filter: - self.add_filter(new_filter) - self.apply_filters() - - def add_filter(self, new_filter: tuple) -> None: - """Add a single filter to self.filters.""" - if isinstance(self.filters, dict): - # filters exist - all_filters = self.filters - if all_filters.get(self.selected_column, False): - # filters exist for this column - all_filters[self.selected_column]["filters"].append(new_filter) - if ( - not all_filters[self.selected_column].get("mode", False) - and len(all_filters[self.selected_column]["filters"]) > 1 - ): - # a mode does not exist, but there are multiple filters - all_filters[self.selected_column]["mode"] = "OR" - else: - # filters don't yet exist for this column: - all_filters[self.selected_column] = {"filters": [new_filter]} - else: - # no filters exist - all_filters = { - self.selected_column: {"filters": [new_filter]}, - "mode": "AND", - } - - self.write_filters(all_filters) - - def write_filters(self, filters: dict) -> None: - self.filters = filters - - def apply_filters(self) -> None: - if self.filters: - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - # only allow filters that are for columns that may be filtered on - filters = { - k: v - for k, v in self.filters.items() - if k in list(self.model.filterable_columns.values()) + ["mode"] - } - self.proxy_model.set_filters(self.model.get_filter_mask(filters)) - self.header.has_active_filters = list(filters.keys()) - QtWidgets.QApplication.restoreOverrideCursor() - else: - self.reset_filters() - - def reset_column_filters(self) -> None: - """Reset all filters for this column.""" - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - f = self.filters - if f.get(self.selected_column, False): - f.pop(self.selected_column) - if self.prev_quick_filter.get(self.selected_column, False): - self.prev_quick_filter.pop(self.selected_column) - self.write_filters(f) - if len(self.filters) == 1 and self.filters.get("mode"): - # the only thing in filters remaining is the mode --> there are no filters - self.reset_filters() - else: - self.header.has_active_filters = list(self.filters.keys()) - self.apply_filters() - QtWidgets.QApplication.restoreOverrideCursor() - - def reset_filters(self) -> None: - """Reset all filters for this entire table.""" - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - self.write_filters(None) - self.header.has_active_filters = [] - self.prev_quick_filter = {} - self.proxy_model.clear_filters() - QtWidgets.QApplication.restoreOverrideCursor() - - -class CustomHeader(QtWidgets.QHeaderView): - """Header which has a filter button on each cell that can trigger a signal. - - Largely based on https://stackoverflow.com/a/30938728 - """ - - clicked = Signal(int, str) - - _x_offset = 0 - _y_offset = ( - 0 # This value is calculated later, based on the height of the paint rect - ) - _width = 18 - _height = 18 - - def __init__(self, orientation=Qt.Horizontal, parent=None): - super(CustomHeader, self).__init__(orientation, parent) - self.setSectionsClickable(True) - - self.column_indices = [] - self.has_active_filters = [] # list of column indices that have filters active - self.event_pos = None - - def paintSection(self, painter, rect, logical_index): - """Paint the button onto the column header.""" - painter.save() - super(CustomHeader, self).paintSection(painter, rect, logical_index) - painter.restore() - - self._y_offset = int(rect.height() - self._width) - - if logical_index in self.column_indices: - option = QtWidgets.QStyleOptionButton() - option.rect = QRect( - rect.x() + self._x_offset, - rect.y() + self._y_offset, - self._width, - self._height, - ) - option.state = ( - QtWidgets.QStyle.State_Enabled | QtWidgets.QStyle.State_Active - ) - - # put the filter icon onto the label - if logical_index in self.has_active_filters: - option.icon = icons.qicons.filter - else: - option.icon = icons.qicons.filter_outline - option.iconSize = QSize(16, 16) - - # set the settings to a PushButton - self.style().drawControl(QtWidgets.QStyle.CE_PushButton, option, painter) - - def mousePressEvent(self, event): - index = self.logicalIndexAt(event.pos()) - if index in self.column_indices: - x = self.sectionPosition(index) - if ( - x + self._x_offset < event.pos().x() < x + self._x_offset + self._width - and self._y_offset < event.pos().y() < self._y_offset + self._height - ): - # the button is clicked - - # set the position of the lower left point of the filter button to spawn a menu - pos = QPoint() - pos.setX(x + self._x_offset + self._width) - pos.setY(self._y_offset + self._height) - self.event_pos = pos - - # emit the column index and the button (left/right) pressed - self.clicked.emit(index, str(event.button()).split(".")[-1]) - else: - # pass the event to the header (for sorting) - super(CustomHeader, self).mousePressEvent(event) - else: - # pass the event to the header (for sorting) - super(CustomHeader, self).mousePressEvent(event) - self.viewport().update() - - -class ABMultiColumnSortProxyModel(ABSortProxyModel): - """Subclass of QSortFilterProxyModel to enable sorting on multiple columns. - - The main purpose of this subclass is to override def filterAcceptsRow(). - - Subclass based on various ideas from: - https://stackoverflow.com/questions/47201539/how-to-filter-multiple-column-in-qtableview - http://www.dayofthenewdan.com/2013/02/09/Qt_QSortFilterProxyModel.html - https://gist.github.com/dbridges/4732790 - """ - - def __init__(self, parent=None): - super(ABMultiColumnSortProxyModel, self).__init__(parent) - - # the filter mask, an iterable array with boolean values on whether or not to keep the row - self.mask = None - - # metric to keep track of successful matches on filter - self.matches = 0 - - # custom filter activation - self.activate_filter = False - - def set_filters(self, mask) -> None: - self.mask = mask - self.matches = 0 - self.activate_filter = True - self.invalidateFilter() - self.activate_filter = False - logger.info("{} filter matches found".format(self.matches)) - - def clear_filters(self) -> None: - self.mask = None - self.invalidateFilter() - - def filterAcceptsRow(self, row: int, parent) -> bool: - # check if self.activate_filter is enabled, else return True - if not self.activate_filter: - return True - # get the right index from the mask - matched = self.mask.iloc[row] - if matched: - self.matches += 1 - return matched - - -class ABDictTreeView(QtWidgets.QTreeView): - def __init__(self, parent=None): - super().__init__(parent) - self.setUniformRowHeights(True) - self.data = {} - - @Slot(name="resizeView") - def custom_view_sizing(self) -> None: - """Resize the first column (usually 'name') whenever an item is - expanded or collapsed. - """ - self.resizeColumnToContents(0) - - @Slot(name="expandSelectedBranch") - def expand_branch(self): - """Expand selected branch.""" - index = self.currentIndex() - self.expand_or_collapse(index, True) - - @Slot(name="collapseSelectedBranch") - def collapse_branch(self): - """Collapse selected branch.""" - index = self.currentIndex() - self.expand_or_collapse(index, False) - - def expand_or_collapse(self, index, expand): - """Expand or collapse branch. - - Will expand or collapse any branch and sub-branches given in index. - expand is a boolean that defines expand (True) or collapse (False).""" - - # based on: https://stackoverflow.com/a/4208240 - def recursive_expand_or_collapse(index, childCount, expand): - for childNo in range(0, childCount): - childIndex = index.child(childNo, 0) - if expand: # if expanding, do that first (wonky animation otherwise) - self.setExpanded(childIndex, expand) - subChildCount = childIndex.internalPointer().childCount() - if subChildCount > 0: - recursive_expand_or_collapse(childIndex, subChildCount, expand) - if not expand: # if collapsing, do it last (wonky animation otherwise) - self.setExpanded(childIndex, expand) - - QApplication.setOverrideCursor(Qt.WaitCursor) - if not expand: # if collapsing, do that first (wonky animation otherwise) - self.setExpanded(index, expand) - childCount = index.internalPointer().childCount() - recursive_expand_or_collapse(index, childCount, expand) - if expand: # if expanding, do that last (wonky animation otherwise) - self.setExpanded(index, expand) - QApplication.restoreOverrideCursor() - - -class PandasModel(QAbstractTableModel): - """Abstract pandas table model adapted from - https://stackoverflow.com/a/42955764. - """ - - HEADERS = [] - updated = Signal() - - def __init__(self, df: pd.DataFrame = None, parent=None): - super().__init__(parent) - self._dataframe: Optional[pd.DataFrame] = df - self.filterable_columns = None - self.different_column_types = {} - # The list of columns which should be editable by the builtin checkbox editor - # The value of the dict holds whether the value should also be displayed as text - self._checkbox_editors: dict[int, tuple[bool, Any, Any]] = {} - self._columns: list[str] = [] - - @property - def columns(self) -> list[str]: - if self._dataframe is not None: - return self._dataframe.columns - return [] - - def rowCount(self, parent=None, *args, **kwargs): - return 0 if self._dataframe is None else self._dataframe.shape[0] - - def columnCount(self, parent=None, *args, **kwargs): - return 0 if self._dataframe is None else self._dataframe.shape[1] - - def data(self, index, role=Qt.DisplayRole): - """ - Return value for table index based on a certain DisplayRole enum. - - More on DisplayRole enums: https://doc.qt.io/qt-5/qt.html#ItemDataRole-enum - """ - if not index.isValid(): - return None - # instantiate value only in case of DisplayRole or ToolTipRole - value = None - tt_date_flag = False # flag to indicate if value is datetime object and role is ToolTipRole - if role in [Qt.DisplayRole, Qt.ToolTipRole, "sorting", Qt.EditRole]: - value = self._dataframe.iat[index.row(), index.column()] - if isinstance(value, np.float64): - value = float(value) - elif isinstance(value, bool): - value = str(value) - elif isinstance(value, np.int64): - value = value.item() - elif isinstance(value, tuple): - value = str(value) - elif isinstance(value, datetime.datetime) and ( - Qt.DisplayRole or Qt.ToolTipRole - ): - tz = datetime.datetime.now(datetime.timezone.utc).astimezone() - time_shift = -tz.utcoffset().total_seconds() - if role == Qt.ToolTipRole: - value = ( - arrow.get(value) - .shift(seconds=time_shift) - .format("YYYY-MM-DD HH:mm:ss") - ) - tt_date_flag = True - elif role == Qt.DisplayRole: - value = arrow.get(value).shift(seconds=time_shift).humanize() - - # Handle checkbox editors - # Checkbox editors can return two values for one cell: the usual display value - # and a checked / not checked enum. It is useful to return both, when the - # underlying data is not bool, but text to visualize eventual errors. - if index.column() in self._checkbox_editors: - if role == Qt.ItemDataRole.CheckStateRole: - value = self._dataframe.iat[index.row(), index.column()] - if isinstance(value, str): - logger.error(f"Expected bool, received str: {value}!!") - true_value = self._checkbox_editors[index.column()][1] - # Convert the data to an appropriate value for the checkbox - return Qt.CheckState.Checked if value == true_value else Qt.CheckState.Unchecked - display_value = self._checkbox_editors[index.column()][0] - if role == Qt.ItemDataRole.DisplayRole and not display_value: - return None - - # immediately return value in case of DisplayRole or sorting - if role == Qt.DisplayRole or role == "sorting": - return value - - # in case of ToolTipRole and date, always show the full date - if tt_date_flag and role == Qt.ToolTipRole: - return value - - # in case of ToolTipRole, check whether content fits the cell - if role == Qt.ToolTipRole: - parent = self.parent() - fontMetrics = parent.fontMetrics() - - # get the width of both the cell, and the text - column_width = parent.columnWidth(index.column()) - text_width = fontMetrics.horizontalAdvance(str(value)) - margin = 10 - - # only show tooltip if the text is wider then the cell minus the margin - if text_width > column_width - margin: - return value - - return None - - def flags(self, index): - return Qt.ItemIsSelectable | Qt.ItemIsEnabled - - def headerData(self, section, orientation, role=Qt.DisplayRole): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - return self._dataframe.columns[section] - elif orientation == Qt.Vertical and role == Qt.DisplayRole: - return self._dataframe.index[section] - return None - - def row_data(self, index: int) -> list: - """Return the row at index as a list.""" - return self._dataframe.iloc[index, :].tolist() - - def to_clipboard(self, rows, columns, include_header: bool = False): - """Copy the given rows and columns of the dataframe to clipboard""" - self._dataframe.iloc[rows, columns].to_clipboard( - index=False, header=include_header - ) - - def to_csv(self, path: str) -> None: - """Store the dataframe as csv in the given path.""" - self._dataframe.to_csv(path) - - def to_excel(self, path: str) -> None: - """Store the underlying dataframe as excel in the given path""" - self._dataframe.to_excel(excel_writer=path) - - def sync(self, *args, **kwargs) -> None: - """(Re)build the dataframe according to the given arguments.""" - self._dataframe = pd.DataFrame([], columns=self.HEADERS) - - @staticmethod - def proxy_to_source(proxy: QModelIndex) -> QModelIndex: - """Step from the QSortFilterProxyModel to the underlying PandasModel.""" - model = proxy.model() - if not hasattr(model, "mapToSource"): - return proxy # Proxy is actually the PandasModel - return model.mapToSource(proxy) - - def test_query_on_column( - self, test_type: str, col_data: pd.Series, query - ) -> pd.Series: - """Compare query and col_data on test_type, return array with boolean test results.""" - if test_type == "equals": - return col_data == query - elif test_type == "does not equal": - return col_data != query - elif test_type == "contains": - return col_data.str.contains(query, regex=False) - elif test_type == "does not contain": - return ~col_data.str.contains(query, regex=False) - elif test_type == "starts with": - return col_data.str.startswith(query) - elif test_type == "does not start with": - return ~col_data.str.startswith(query) - elif test_type == "ends with": - return col_data.str.endswith(query) - elif test_type == "does not end with": - return ~col_data.str.endswith(query) - elif test_type == "=": - return col_data.astype(float) == float(query) - elif test_type == "!=": - return col_data.astype(float) != float(query) - elif test_type == ">=": - return col_data.astype(float) >= float(query) - elif test_type == "<=": - return col_data.astype(float) <= float(query) - elif test_type == "<= x <=": - return (float(query[0]) <= col_data.astype(float)) & ( - col_data.astype(float) <= float(query[1]) - ) - else: - logger.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) - return col_data == query - - def get_filter_mask(self, filters: dict) -> pd.Series: - """Generate a filter mask of the dataframe based on the filters. - - Returns a pd.Series of boolean results (the mask). - """ - # get the column name from index - fc_rev = {v: k for k, v in self.filterable_columns.items()} - - all_mode = filters["mode"] - all_mask = None - # iterate over columns - for col_idx, col_filters in filters.items(): - if col_idx == "mode": - continue - col_name = fc_rev[col_idx] - col_data = self._dataframe[col_name] - col_mode = col_filters.get("mode", False) - col_mask = None - # iterate over filters within column - for col_filt in col_filters["filters"]: - if self.different_column_types.get(col_name, False): - # this is a 'num' column - filt_type, query = col_filt - col_data_ = col_data - else: - # this is a 'str' column - filt_type, query, case_sensitive = col_filt - if case_sensitive: - col_data_ = col_data.astype(str) - else: - col_data_ = col_data.astype(str).str.upper() - query = query.upper() - - # run the test - new_mask = self.test_query_on_column(filt_type, col_data_, query) - if not any(new_mask): - # no matches for this mask, let user know: - logger.info( - "There were no matches for filter: {}: '{}'".format( - col_filt[0], col_filt[1] - ) - ) - - # create or combine new mask within column - if isinstance(col_mask, pd.Series) and col_mode == "AND": - col_mask = col_mask & new_mask - elif isinstance(col_mask, pd.Series) and col_mode == "OR": - col_mask = col_mask + new_mask - else: - col_mask = new_mask - - # create or combine new mask on columns - if isinstance(all_mask, pd.Series) and all_mode == "AND": - all_mask = all_mask & col_mask - elif isinstance(all_mask, pd.Series) and all_mode == "OR": - all_mask = all_mask + col_mask - else: - all_mask = col_mask - return all_mask - - def set_read_only(self, read_only: bool): - """Interface function, to support editable models""" - pass - - def is_read_only(self) -> bool: - """Interface function, to support editable models""" - return True - - def set_builtin_checkbox_delegate(self, column: int, show_text_value: bool, - true_value: Any = True, false_value: Any = False): - """ - Enables the builtin checkbox delegate for columns. - Can be used on bool values only. - As the underlying data can be bool or string, we provide the values to be - stored as parameters. - """ - self._checkbox_editors[column] = (show_text_value, true_value, false_value) - - -class EditablePandasModel(PandasModel): - """Allows underlying dataframe to be edited through Delegate classes.""" - - def __init__(self, df: pd.DataFrame = None, parent=None): - super().__init__(df, parent) - self._read_only = True - # The list of columns which should always be read-only - self._read_only_columns: list[int] = [] - - def flags(self, index: QModelIndex) -> Qt.ItemFlags: - """Returns ItemIsEditable flag only if the model is not read only - This prevents editing of data on QAbstractTableModel level. - """ - if index.isValid(): - result = super().flags(index) - if not self._read_only and not index.column() in self._read_only_columns: - result |= Qt.ItemIsEditable - # Qt.ItemIsUserCheckable is also editable, it allows the clicking - # of the checkbox - if index.column() in self._checkbox_editors: - result |= Qt.ItemIsUserCheckable - return result - return Qt.ItemFlag.NoItemFlags - - def prepare_set_value(self, index: QModelIndex, value: Any, - role: int = Qt.EditRole) -> tuple[Any, bool]: - check_ok = False - if index.isValid(): - if role == Qt.CheckStateRole and index.column() in self._checkbox_editors: - true_value = self._checkbox_editors[index.column()][1] - false_value = self._checkbox_editors[index.column()][2] - value = true_value if value == Qt.CheckState.Checked else false_value - check_ok = True - return (value, check_ok) - - def setData(self, index, value, role=Qt.EditRole): - """Inserts the given validated data into the given index""" - if index.isValid(): - value, check_ok = self.prepare_set_value(index, value, role) - if role == Qt.EditRole or check_ok: - self._dataframe.iat[index.row(), index.column()] = value - self.dataChanged.emit(index, index, [role]) - return True - return False - - def set_read_only(self, read_only: bool): - """Allows to set the model to editable""" - self._read_only = read_only - - def is_read_only(self) -> bool: - """Returns if the model is editable""" - return self._read_only - - def insertRows(self, position, rows=1, parent=QModelIndex()): - """Add new rows to the underlying dataframe""" - self.beginInsertRows(parent, position, position + rows - 1) - new_rows = pd.DataFrame( - [[None] * self.columnCount()] * rows, columns=self._dataframe.columns - ) - self._dataframe = pd.concat( - [self._dataframe.iloc[:position], new_rows, self._dataframe.iloc[position:]] - ).reset_index(drop=True) - self.endInsertRows() - return True - - def removeRows(self, position, rows=1, parent=QModelIndex()): - """Remove rows from the underlying dataframe""" - self.beginRemoveRows(parent, position, position + rows - 1) - self._dataframe = self._dataframe.drop( - self._dataframe.index[position : position + rows] - ).reset_index(drop=True) - self.endRemoveRows() - return True - - def set_readonly_column(self, column: int): - if column not in self._read_only_columns: - self._read_only_columns.append(column) - - -# Take the classes defined above and add the ItemIsDragEnabled flag -class DragPandasModel(PandasModel): - """Same as PandasModel, but enabling dragging.""" - - def flags(self, index): - return super().flags(index) | Qt.ItemIsDragEnabled - - -class EditableDragPandasModel(EditablePandasModel): - def flags(self, index): - return super().flags(index) | Qt.ItemIsDragEnabled - - -class TreeItem(object): - __slots__ = ["_data", "_parent", "_children"] - - def __init__(self, data: list, parent=None): - self._data = data - self._parent = parent - self._children = [] - - @classmethod - def build_root(cls, cols: list) -> "TreeItem": - return cls(cols) - - def clear(self) -> None: - """Use this method to recursively prune a branch from a tree model. - When called on the root item, removes the entire tree. - - Make sure to only use this in conjunction with model.beginModelReset - and model.endModelReset to avoid python crashing. - """ - for c in self._children: - c.clear() - self._children = [] - - def appendChild(self, item) -> None: - self._children.append(item) - - def child(self, row: int) -> "TreeItem": - return self._children[row] - - @property - def children(self) -> list: - return self._children - - def childCount(self) -> int: - return len(self._children) - - def data(self, column: int): - return self._data[column] - - def parent(self) -> Optional["TreeItem"]: - return self._parent - - def row(self) -> int: - return self._parent.children.index(self) if self._parent else 0 - - def __repr__(self) -> str: - return "({})".format(", ".join(str(x) for x in self._data)) - - -class BaseTreeModel(QAbstractItemModel): - """Base Model used to present data for QTreeView.""" - - HEADERS = [] - updated = Signal() - - def __init__(self, parent=None, *args, **kwargs): - super().__init__(parent) - self.root = None - self._data = {} - - def columnCount(self, parent: QModelIndex = None, *args, **kwargs) -> int: - return len(self.HEADERS) - - def data(self, index, role: int = Qt.DisplayRole): - if not index.isValid(): - return None - - if role == Qt.DisplayRole: - item = index.internalPointer() - return str(item.data(index.column())) - - def headerData(self, column, orientation, role: int = Qt.DisplayRole): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - try: - return self.HEADERS[column] - except IndexError: - pass - return None - - def index(self, row: int, column: int, parent: QModelIndex = None, *args, **kwargs): - if not self.hasIndex(row, column, parent): - return QModelIndex() - - parent = parent.internalPointer() if parent.isValid() else self.root - child = parent.child(row) - if child: - return self.createIndex(row, column, child) - else: - return QModelIndex() - - def iterator(self, item: TreeItem = None): - """ - An iterator for the TreeModel items, providing an initial object of type - None returns a series of objects contained in the TreeModel (including the - root item as the first returned object). Returns a final None type object - upon termination. - """ - if item == None: - return self.root - if item.childCount() > 0: # if its not a leaf - return item.child(0) # return the first child - if item == self.root: - return - if item.parent().childCount() > item.row() + 1: # if there's still a sibling - return item.parent().child(item.row() + 1) - else: # look for siblings from previous "generations" - parent = item.parent() - while parent != self.root: - if parent.parent().childCount() > parent.row() + 1: - return parent.parent().child(parent.row() + 1) - parent = parent.parent() - # if there are no siblings left return None - return None - - def parent(self, child: QModelIndex = None): - if not child.isValid(): - return QModelIndex() - - child = child.internalPointer() - parent = child.parent() - if parent == self.root: - return QModelIndex() - - return self.createIndex(parent.row(), 0, parent) - - def rowCount(self, parent=None, *args, **kwargs): - if not parent or parent.column() > 0: - return 0 - parent = parent.internalPointer() if parent.isValid() else self.root - return parent.childCount() - - def flags(self, index): - if not index.isValid(): - return Qt.NoItemFlags - - return Qt.ItemIsEnabled | Qt.ItemIsSelectable - - def setup_model_data(self) -> None: - """Method used to construct the tree of items for the model.""" - raise NotImplementedError - - def sync(self, *args, **kwargs) -> None: - pass - diff --git a/activity_browser/app/pages/parameters/parameter_models.py b/activity_browser/app/pages/parameters/parameter_models.py deleted file mode 100644 index cbc25e8df..000000000 --- a/activity_browser/app/pages/parameters/parameter_models.py +++ /dev/null @@ -1,545 +0,0 @@ -import itertools -from typing import Iterable, Tuple -from loguru import logger - -import pandas as pd -import numpy as np -from asteval import Interpreter -from peewee import DoesNotExist - -from qtpy import QtWidgets -from qtpy.QtCore import QModelIndex, Slot - -from bw2data.parameters import ActivityParameter, DatabaseParameter, Group, ProjectParameter - -from activity_browser import app, signals, application -from activity_browser.bwutils.utils import Parameters -from activity_browser.mod import bw2data as bd -from activity_browser.ui.dialogs import UncertaintyWizard - -from .base import BaseTreeModel, EditablePandasModel, TreeItem, PandasModel - - - - -class BaseParameterModel(EditablePandasModel): - COLUMNS = [] - UNCERTAINTY = ["uncertainty type", "loc", "scale", "shape", "minimum", "maximum"] - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.param_col = 0 - self.comment_col = 0 - self.dataChanged.connect(self.edit_single_parameter) - self.set_read_only(False) - - signals.project.changed.connect(self.sync) - signals.parameter.changed.connect(self.sync) - signals.parameter.recalculated.connect(self.sync) - - def get_parameter(self, proxy: QModelIndex) -> object: - idx = self.proxy_to_source(proxy) - return self._dataframe.iat[idx.row(), self.param_col] - - def get_key(self, *args) -> tuple: - """Use this to build a (partial) key for the current index.""" - return "", "" - - def get_group(self, *args) -> str: - """Retrieve the group of the parameter currently selected.""" - return "project" - - @classmethod - def parse_parameter(cls, parameter) -> dict: - """Take the given Parameter object and extract data for a single - row in the table dataframe - - If the parameter has uncertainty data, include this as well. - """ - row = {key: getattr(parameter, key, "") for key in cls.COLUMNS} - data = getattr(parameter, "data", {}) - row.update(cls.extract_uncertainty_data(data)) - row["parameter"] = parameter - row["comment"] = data.get("comment", "") - return row - - @classmethod - def columns(cls) -> list: - """Combine COLUMNS, UNCERTAINTY and add 'parameter'.""" - return cls.COLUMNS + cls.UNCERTAINTY + ["parameter"] - - @classmethod - def extract_uncertainty_data(cls, data: dict) -> dict: - """This helper function can be used to extract specific uncertainty - columns from the parameter data - - See: - https://2.docs.brightway.dev/intro.html#storing-uncertain-values - https://stats-arrays.readthedocs.io/en/latest/#mapping-parameter-array-columns-to-uncertainty-distributions - """ - row = {key: data.get(key) for key in cls.UNCERTAINTY} - return row - - @Slot(QModelIndex, name="editSingleParameter") - def edit_single_parameter(self, index: QModelIndex) -> None: - """Take the index and update the underlying brightway Parameter.""" - param = self.get_parameter(index) - field = self._dataframe.columns[index.column()] - - app.actions.ParameterModify.run(param, field, index.data()) - - - @Slot(QModelIndex, name="startRenameParameter") - def handle_parameter_rename(self, proxy: QModelIndex) -> None: - group = self.get_group(proxy) - param = self.get_parameter(proxy) - - app.actions.ParameterRename.run(param) - - def delete_parameter(self, proxy: QModelIndex) -> None: - param = self.get_parameter(proxy) - app.actions.ParameterDelete.run(param) - - @Slot(name="modifyParameterUncertainty") - def modify_uncertainty(self, proxy: QModelIndex) -> None: - param = self.get_parameter(proxy) - wizard = UncertaintyWizard(param, self.parent()) - wizard.show() - - @Slot(name="unsetParameterUncertainty") - def remove_uncertainty(self, proxy: QModelIndex) -> None: - param = self.get_parameter(proxy) - app.actions.ParameterUncertaintyRemove.run(param) - - def handle_double_click(self, proxy: QModelIndex) -> None: - column = proxy.column() - if self._dataframe.columns[column] in BaseParameterModel.UNCERTAINTY: - self.modify_uncertainty(proxy) - elif self._dataframe.columns[column] == "name": - self.handle_parameter_rename(proxy) - - -class ProjectParameterModel(BaseParameterModel): - COLUMNS = ["name", "amount", "formula", "comment"] - - def sync(self) -> None: - data = [self.parse_parameter(p) for p in ProjectParameter.select()] - self._dataframe = pd.DataFrame(data, columns=self.columns()) - self.param_col = self._dataframe.columns.get_loc("parameter") - self.comment_col = self._dataframe.columns.get_loc("comment") - self.updated.emit() - - @staticmethod - def get_usable_parameters() -> Iterable[list]: - return ([k, v, "project"] for k, v in ProjectParameter.static().items()) - - @staticmethod - def get_interpreter() -> Interpreter: - interpreter = Interpreter() - interpreter.symtable.update(ProjectParameter.static()) - return interpreter - - -class DatabaseParameterModel(BaseParameterModel): - COLUMNS = ["name", "amount", "formula", "database", "comment"] - - def __init__(self, parent=None): - super().__init__(parent) - self.db_col = 0 - - def sync(self) -> None: - data = [self.parse_parameter(p) for p in DatabaseParameter.select()] - self._dataframe = pd.DataFrame(data, columns=self.columns()) - self.db_col = self._dataframe.columns.get_loc("database") - self.param_col = self._dataframe.columns.get_loc("parameter") - self.comment_col = self._dataframe.columns.get_loc("comment") - self.updated.emit() - - def get_key(self, proxy: QModelIndex = None) -> tuple: - return self.get_database(proxy), "" - - def get_group(self, proxy: QModelIndex = None) -> str: - """Retrieve the group of the activity currently selected.""" - return self.get_database(proxy) - - @staticmethod - def get_usable_parameters(): - """Include the project parameters, and generate database parameters.""" - project = ProjectParameterModel.get_usable_parameters() - database = ( - [p.name, p.amount, "database ({})".format(p.database)] - for p in DatabaseParameter.select() - ) - return itertools.chain(project, database) - - def get_database(self, proxy: QModelIndex = None) -> str: - """Return the database name of the parameter currently selected.""" - idx = self.proxy_to_source(proxy or self.parent().currentIndex()) - return self._dataframe.iat[idx.row(), self.db_col] - - def get_interpreter(self) -> Interpreter: - """Take the interpreter from the ProjectParameterTable and add - (potentially overwriting) all database symbols for the selected index. - """ - interpreter = ProjectParameterModel.get_interpreter() - db_name = self.get_database() - interpreter.symtable.update(DatabaseParameter.static(db_name)) - return interpreter - - -class ActivityParameterModel(BaseParameterModel): - COLUMNS = [ - "name", - "amount", - "formula", - "product", - "activity", - "location", - "group", - "order", - "key", - "comment", - ] - - def __init__(self, parent=None): - super().__init__(parent) - self.group_col = 0 - self.key_col = 0 - self.order_col = 0 - - def sync(self) -> None: - """Build a dataframe using the ActivityParameters set in brightway""" - generate = ( - self.parse_parameter(p) - for p in ( - ActivityParameter.select(ActivityParameter, Group.order) - .join(Group, on=(ActivityParameter.group == Group.name)) - .namedtuples() - ) - ) - data = [x for x in generate if "key" in x] - self._dataframe = pd.DataFrame(data, columns=self.columns()) - # Convert the 'order' column from list into string - self._dataframe["order"] = self._dataframe["order"].apply(", ".join) - self.group_col = self._dataframe.columns.get_loc("group") - self.param_col = self._dataframe.columns.get_loc("parameter") - self.key_col = self._dataframe.columns.get_loc("key") - self.order_col = self._dataframe.columns.get_loc("order") - self.comment_col = self._dataframe.columns.get_loc("comment") - self.updated.emit() - - @classmethod - def parse_parameter(cls, parameter) -> dict: - """Override the base method to add more steps.""" - row = super().parse_parameter(parameter) - # Combine the 'database' and 'code' fields of the parameter into a 'key' - row["key"] = (parameter.database, parameter.code) - try: - act = bd.get_activity(row["key"]) - except: - # Can occur if an activity parameter exists for a removed activity. - logger.info( - "Activity {} no longer exists, removing parameter.".format(row["key"]) - ) - app.actions.ParameterClearBroken.run(parameter) - return {} - row["product"] = act.get("reference product") or act.get("name") - row["activity"] = act.get("name") - row["location"] = act.get("location", "unknown") - # Replace the namedtuple with the actual ActivityParameter - row["parameter"] = ActivityParameter.get_by_id(parameter.id) - return row - - def get_activity_groups(self, proxy, ignore_groups: list = None) -> Iterable[str]: - """Helper method to look into the Group and determine which if any - other groups the current activity can depend on - """ - db = self.get_key(proxy)[0] - ignore_groups = ignore_groups or [] - return ( - param.group - for param in ( - ActivityParameter.select(ActivityParameter.group) - .where(ActivityParameter.database == db) - .distinct() - ) - if param.group not in ignore_groups - ) - - @staticmethod - def get_usable_parameters(): - """Include all types of parameters. - - NOTE: This method does not take into account which formula is being - edited, and therefore does not restrict which database or activity - parameters are returned. - """ - database = DatabaseParameterModel.get_usable_parameters() - activity = ( - [p.name, p.amount, "activity ({})".format(p.group)] - for p in ActivityParameter.select() - ) - return itertools.chain(database, activity) - - def get_group(self, proxy: QModelIndex = None) -> str: - """Retrieve the group of the activity currently selected.""" - proxy = proxy or self.parent().currentIndex() - idx = self.proxy_to_source(proxy) - return self._dataframe.iat[idx.row(), self.group_col] - - def get_interpreter(self) -> Interpreter: - interpreter = Interpreter() - group = self.get_group(self.parent().currentIndex()) - interpreter.symtable.update(ActivityParameter.static(group, full=True)) - return interpreter - - def get_key(self, proxy: QModelIndex) -> tuple: - index = self.proxy_to_source(proxy) - return self._dataframe.iat[index.row(), self.key_col] - - -class ParameterItem(TreeItem): - @classmethod - def build_header(cls, header: str, parent: TreeItem) -> "ParameterItem": - item = cls([header, "", "", ""], parent) - parent.appendChild(item) - return item - - @classmethod - def build_item(cls, param, parent: TreeItem) -> "ParameterItem": - """Depending on the parameter type, the group is changed, defaults to - 'project'. - - For Activity parameters, use a 'header' item as parent, create one - if it does not exist. - """ - group = "project" - if hasattr(param, "code") and hasattr(param, "database"): - database = "database - {}".format(str(param.database)) - if database not in [x.data(0) for x in parent.children]: - cls.build_header(database, parent) - parent = next(x for x in parent.children if x.data(0) == database) - group = getattr(param, "group") - elif hasattr(param, "database"): - group = param.database - - item = cls( - [ - getattr(param, "name", ""), - group, - getattr( - param, "amount", 1.0 - ), # set to 1 instead of 0 as division by 0 causes problems - getattr(param, "formula", ""), - ], - parent, - ) - - # If the variable is found, we're working on an activity parameter - if "database" in locals(): - cls.build_exchanges(param, item) - - parent.appendChild(item) - return item - - @classmethod - def build_exchanges(cls, act_param, parent: TreeItem) -> None: - """Take the given activity parameter, retrieve the matching activity - and construct tree-items for each exchange with a `formula` field. - """ - act = bd.get_activity((act_param.database, act_param.code)) - - for exc in [exc for exc in act.exchanges() if "formula" in exc]: - try: - act_input = bd.get_activity(exc.input) - item = cls( - [ - act_input.get("name"), - parent.data(1), - exc.amount, - exc.get("formula"), - ], - parent, - ) - parent.appendChild(item) - except DoesNotExist as e: - # The exchange is coming from a deleted database, remove it - logger.warning(f"Broken exchange: {exc}, removing.") - app.actions.ExchangeDelete.run([exc]) - - -class ParameterTreeModel(BaseTreeModel): - """ - Ordering and foldouts as follows: - - Project parameters: - - All 'root' objects - - No children - - Database parameters: - - All 'root' objects - - No children - - Activity parameters: - - Never root objects. - - Placed under simple 'database' root objects - - Exchanges as children - - Exchange parameters: - - Never root objects - - Children of relevant activity parameter - - No children - """ - - HEADERS = ["Name", "Group", "Amount", "Formula"] - - def __init__(self, parent=None): - super().__init__(parent) - self.root = ParameterItem.build_root(self.HEADERS) - self.setup_model_data() - - def setup_model_data(self) -> None: - """First construct the root, then process the data.""" - for param in self._data.get("project", []): - ParameterItem.build_item(param, self.root) - for param in self._data.get("database", []): - ParameterItem.build_item(param, self.root) - for param in self._data.get("activity", []): - try: - _ = bd.get_activity((param.database, param.code)) - except: - continue - ParameterItem.build_item(param, self.root) - - def sync(self, *args, **kwargs) -> None: - self.beginResetModel() - self.root.clear() - self.endResetModel() - self._data.update( - { - "project": ProjectParameter.select().iterator(), - "database": DatabaseParameter.select().iterator(), - "activity": ActivityParameter.select().iterator(), - } - ) - self.setup_model_data() - self.updated.emit() - - -class ScenarioModel(PandasModel): - HEADERS = ["Name", "Group", "default"] - MATCH_COLS = ["Name", "Group"] - - def __init__(self, parent=None): - super().__init__(parent=parent) - signals.project.changed.connect(self.sync) - signals.parameter.changed.connect(self.rebuild_table) - - @Slot(name="doCleanSync") - def sync(self, df: pd.DataFrame = None, include_default: bool = True) -> None: - """Construct the dataframe from the existing parameters, if ``df`` - is given, perform a merge to possibly include additional columns. - """ - data = [p[:3] for p in Parameters.from_bw_parameters()] - if not isinstance(df, pd.DataFrame): - self._dataframe = pd.DataFrame(data, columns=self.HEADERS).set_index("Name") - else: - required = set(self.MATCH_COLS) - if not required.issubset(df.columns): - raise ValueError( - "The given dataframe does not contain required columns: {}".format( - required.difference(df.columns) - ) - ) - assert df.columns.get_loc("Group") == 1 - if isinstance(include_default, bool) and include_default: - new_df = pd.DataFrame(data, columns=self.HEADERS) - if "default" in df.columns: - df.drop(columns="default", inplace=True) - self._dataframe = self._perform_merge(new_df, df).set_index("Name") - else: - # Now we're gonna need to ensure that the dataframe is of - # the same size - assert ( - len(data) >= df.shape[0] - ), "Too many parameters found, not possible." - missing = len(data) - df.shape[0] - if missing != 0: - nan_data = pd.DataFrame( - index=pd.RangeIndex(missing), columns=df.columns - ) - df = pd.concat([df, nan_data], ignore_index=True) - self._dataframe = df.set_index("Name") - self.updated.emit() - - @classmethod - def _perform_merge(cls, left: pd.DataFrame, right: pd.DataFrame) -> pd.DataFrame: - """There are three kinds of actions that can occur: adding new columns, - updating values in matching columns, and a combination of the two. - - ``left`` dataframe always determines the row-size of the resulting - dataframe. - Any `NaN` values in the new columns in ``right`` will be replaced - with values from the `default` column from ``left``. - """ - right_columns = right.drop(columns=cls.MATCH_COLS).columns - matching = right_columns.intersection(left.columns) - if not matching.empty: - # Replace values and drop the matching columns - left[matching] = right[matching] - right.drop(columns=matching, inplace=True) - if right.drop(columns=cls.MATCH_COLS).columns.any(): - # Merge the remaining columns - df = left.merge(right, how="left", on=cls.MATCH_COLS) - else: - df = left - else: - df = left.merge(right, how="left", on=cls.MATCH_COLS) - # Now go over the non-standard columns and see if there are any - # missing values. - new_cols = df.drop(columns=cls.HEADERS).columns - missing = new_cols[df[new_cols].isna().any()] - if not missing.empty: - idx = missing.append(pd.Index(["default"])) - df[idx] = df[idx].apply(lambda x: x.fillna(x["default"]), axis=1) - return df - - @Slot(name="resetDataIndex") - def rebuild_table(self) -> None: - """Should be called when the `parameters_changed` signal is emitted. - Will call sync with a copy of the current dataframe to ensure no - user-imported data is lost. - - TODO: handle database parameter group changes correctly. Maybe a - separate signal like rename? - """ - self.sync(self._dataframe.reset_index()) - - @Slot(str, str, str, name="renameParameterIndex") - def update_param_name(self, old: str, group: str, new: str) -> None: - """Kind of a cheat, but directly edit the dataframe.index to update - the table whenever the user renames a parameter. - """ - new_idx = pd.Index( - np.where( - (self._dataframe.index == old) & (self._dataframe["Group"] == group), - new, - self._dataframe.index, - ), - name=self._dataframe.index.name, - ) - self._dataframe.index = new_idx - self.updated.emit() - - def iterate_scenarios(self) -> Iterable[Tuple[str, Iterable]]: - """Iterates through all of the non-description columns from left to right. - - Returns an iterator of tuples containing the scenario name and a dictionary - of the parameter names and new amounts. - - TODO: Fix this so it returns the least amount of required information. - """ - df = self._dataframe.reset_index() - df = df.set_index(["Group", "Name"]) - - - return ( - (scenario, df[scenario]) - for scenario in df.columns - ) diff --git a/activity_browser/app/pages/parameters/parameter_views.py b/activity_browser/app/pages/parameters/parameter_views.py deleted file mode 100644 index a94f1b7dc..000000000 --- a/activity_browser/app/pages/parameters/parameter_views.py +++ /dev/null @@ -1,284 +0,0 @@ -from asteval import Interpreter -from qtpy.QtCore import Slot -from qtpy.QtGui import QContextMenuEvent, QDragMoveEvent, QDropEvent -from qtpy.QtWidgets import QAction, QMenu - -import bw2data as bd -import bw_functional as bf - -from activity_browser import app, signals -from activity_browser.ui import icons, delegates - -from .parameter_models import ( - BaseParameterModel, - ProjectParameterModel, - DatabaseParameterModel, - ActivityParameterModel, - ParameterTreeModel, - ScenarioModel -) -from .base import ABDataFrameView, ABDictTreeView - - -class ScenarioTable(ABDataFrameView): - """Constructs an infinitely (horizontally) expandable table that is - used to set specific amount for user-defined parameters. - - The two required columns in the dataframe for the table are 'Name', - and 'Type'. all other columns are seen as scenarios containing N floats, - where N is the number of rows found in the Name column. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "scenario_table" - - self.horizontalHeader().setStretchLastSection(False) - self.verticalHeader().setVisible(True) - - self.model = ScenarioModel(self) - self.model.updated.connect(self.update_proxy_model) - signals.project.changed.connect(self.group_column) - - @Slot(bool, name="showGroupColumn") - def group_column(self, shown: bool = False) -> None: - self.setColumnHidden(0, not shown) - - def iterate_scenarios(self) -> list[tuple[str, list]]: - return self.model.iterate_scenarios() - - -class BaseParameterTable(ABDataFrameView): - MODEL = BaseParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.setSelectionMode(ABDataFrameView.SingleSelection) - - self.model = self.MODEL(self) - self.doubleClicked.connect( - lambda: self.model.handle_double_click(self.currentIndex()) - ) - self.delete_action = QAction(icons.qicons.delete, "Delete parameter", None) - self.delete_action.triggered.connect( - lambda: self.model.delete_parameter(self.currentIndex()) - ) - self.rename_action = QAction(icons.qicons.edit, "Rename parameter", None) - self.rename_action.triggered.connect( - lambda: self.model.handle_parameter_rename(self.currentIndex()) - ) - self.modify_uncertainty_action = QAction( - icons.qicons.edit, "Modify uncertainty", None - ) - self.modify_uncertainty_action.triggered.connect(self.modify_uncertainty) - self.remove_uncertainty_action = QAction( - icons.qicons.delete, "Remove uncertainty", None - ) - self.remove_uncertainty_action.triggered.connect(self.remove_uncertainty) - self.model.updated.connect(self.update_proxy_model) - - # hide raw parameter column - self.model.updated.connect( - lambda: self.setColumnHidden(self.model.param_col, True) - ) - self.model.updated.connect(lambda: self.resizeColumnToContents(0)) - - def contextMenuEvent(self, event: QContextMenuEvent) -> None: - """Have the parameter test to see if it can be deleted safely.""" - if self.indexAt(event.pos()).row() == -1: - return - menu = QMenu(self) - menu.addAction(self.rename_action) - menu.addAction(self.modify_uncertainty_action) - menu.addSeparator() - menu.addAction(self.delete_action) - menu.addAction(self.remove_uncertainty_action) - proxy = self.indexAt(event.pos()) - if proxy.isValid(): - param = self.get_parameter(proxy) - if param.is_deletable(): - self.delete_action.setEnabled(True) - else: - self.delete_action.setEnabled(False) - menu.exec_(event.globalPos()) - - def get_parameter(self, proxy): - return self.model.get_parameter(proxy) - - def get_key(self, *args) -> tuple: - return self.model.get_key() - - def delete_parameter(self, proxy) -> None: - self.model.delete_parameter(proxy) - - @Slot(name="modifyParameterUncertainty") - def modify_uncertainty(self) -> None: - proxy = next(p for p in self.selectedIndexes()) - self.model.modify_uncertainty(proxy) - - @Slot(name="unsetParameterUncertainty") - def remove_uncertainty(self) -> None: - proxy = next(p for p in self.selectedIndexes()) - self.model.remove_uncertainty(proxy) - - def comment_column(self, show: bool): - self.setColumnHidden(self.model.comment_col, not show) - self.resizeColumnsToContents() - self.resizeRowsToContents() - - -class ProjectParameterTable(BaseParameterTable): - MODEL = ProjectParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "project_parameter" - - # Set delegates for specific columns - self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(3, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(4, delegates.ViewOnlyUncertaintyDelegate(self)) - - def uncertainty_columns(self, show: bool): - for i in range(4, 10): - self.setColumnHidden(i, not show) - - @staticmethod - def get_usable_parameters(): - return ProjectParameterModel.get_usable_parameters() - - @staticmethod - def get_interpreter() -> Interpreter: - return ProjectParameterModel.get_interpreter() - - -class DataBaseParameterTable(BaseParameterTable): - MODEL = DatabaseParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "database_parameter" - - # Set delegates for specific columns - self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(3, delegates.DatabaseDelegate(self)) - self.setItemDelegateForColumn(4, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(5, delegates.ViewOnlyUncertaintyDelegate(self)) - - def uncertainty_columns(self, show: bool): - for i in range(5, 11): - self.setColumnHidden(i, not show) - - def get_key(self) -> tuple: - return self.model.get_key(self.currentIndex()) - - @staticmethod - def get_usable_parameters(): - return DatabaseParameterModel.get_usable_parameters() - - def get_interpreter(self) -> Interpreter: - """Take the interpreter from the ProjectParameterTable and add - (potentially overwriting) all database symbols for the selected index. - """ - return self.model.get_interpreter() - - -class ActivityParameterTable(BaseParameterTable): - MODEL = ActivityParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "activity_parameter" - - # Set delegates for specific columns - self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(6, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(7, delegates.ListDelegate(self)) - self.setItemDelegateForColumn(9, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(10, delegates.ViewOnlyUncertaintyDelegate(self)) - - # Set dropEnabled - self.setDragDropMode(ABDataFrameView.DragDropMode.DropOnly) - self.setAcceptDrops(True) - - def dragMoveEvent(self, event, /): - pass - - def dragEnterEvent(self, event: QDragMoveEvent) -> None: - """Check that the dragged row is from the databases table""" - if event.mimeData().hasFormat("application/bw-nodekeylist"): - event.accept() - - def dropEvent(self, event: QDropEvent) -> None: - """If the user drops an activity into the activity parameters table - read the relevant data from the database and generate a new row. - - Also, create a warning if the activity is from a read-only database - """ - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - processes = set() - - for key in keys: - act = bd.get_node(key=key) - if isinstance(act, bf.Product): - continue - processes.add(key) - event.accept() - app.actions.ParameterNewAutomatic.run(processes) - - def contextMenuEvent(self, event: QContextMenuEvent) -> None: - """Override and activate QTableView.contextMenuEvent() - - All possible menu events should be added and wired up here - """ - if self.indexAt(event.pos()).row() == -1: - return - menu = QMenu(self) - menu.addAction(icons.qicons.add, "Open activities", self.open_activity_tab) - menu.addAction(self.rename_action) - menu.addAction(self.delete_action) - menu.addAction(self.modify_uncertainty_action) - proxy = self.indexAt(event.pos()) - if proxy.isValid(): - param = self.get_parameter(proxy) - if param.is_deletable(): - self.delete_action.setEnabled(True) - else: - self.delete_action.setEnabled(False) - menu.exec_(event.globalPos()) - - @Slot() - def open_activity_tab(self): - """Triggers the activity tab to open one or more activities.""" - for proxy in self.selectedIndexes(): - key = self.get_key(proxy) - signals.safe_open_activity_tab.emit(key) - - def uncertainty_columns(self, show: bool): - for i in range(10, 16): - self.setColumnHidden(i, not show) - - def get_key(self, proxy=None) -> tuple: - proxy = proxy or self.currentIndex() - return self.model.get_key(proxy) - - def get_activity_groups(self, proxy, ignore_groups: list = None): - return self.model.get_activity_groups(proxy, ignore_groups) - - @staticmethod - def get_usable_parameters(): - return ActivityParameterModel.get_usable_parameters() - - def get_current_group(self, proxy=None) -> str: - """Retrieve the group of the activity currently selected.""" - return self.model.get_group(proxy or self.currentIndex()) - - def get_interpreter(self) -> Interpreter: - return self.model.get_interpreter() - - -class ExchangesTable(ABDictTreeView): - def __init__(self, parent=None): - super().__init__(parent) - self.model = ParameterTreeModel(parent=self) - self.setModel(self.model) diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py new file mode 100644 index 000000000..ef9ee22be --- /dev/null +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -0,0 +1,279 @@ +from loguru import logger + +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Qt + +import pandas as pd +import bw2data as bd +from bw2data.parameters import ParameterizedExchange +from bw2data.backends import ExchangeDataset + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import database_is_locked +from activity_browser.bwutils.utils import Parameter + + +class ParameterizedExchangesSection(QtWidgets.QWidget): + """ + A widget section that displays all parameterized exchanges in the current project. + + Attributes: + model (ParameterizedExchangesModel): The model containing the data for the exchanges. + view (ParameterizedExchangesView): The view displaying the exchanges. + """ + + def __init__(self, parent=None): + """ + Initializes the ParameterizedExchangesSection widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + # Parameterized exchanges table view + self.model = ParameterizedExchangesModel(parent=self) + self.view = ParameterizedExchangesView() + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.view) + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + app.signals.metadata.synced.connect(self.sync) + app.signals.parameter.changed.connect(self.sync) + app.signals.parameter.recalculated.connect(self.sync) + app.signals.parameter.deleted.connect(self.sync) + # app.signals.project.changed.connect(self.sync) + # app.signals.meta.databases_changed.connect(self.sync) + + def sync(self): + """ + Synchronizes the widget with the current state of parameterized exchanges. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_exchanges_df() + self.model.set_dataframe(df) + + def build_exchanges_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameterized exchanges in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameterized exchanges data. + """ + translated = [] + + # Get all parameterized exchanges + for param_exc in ParameterizedExchange.select(): + try: + exchange = bd.Edge(document=ExchangeDataset.get_by_id(param_exc.exchange)) + + # Get keys for input and output + input_key = exchange.get("input") + output_key = exchange.get("output") + + # Get metadata from metadata store + input_meta = app.metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] + output_meta = app.metadata.get_metadata([output_key], ["name"]).iloc[0] + + row = { + "amount": exchange.get("amount"), + "unit": input_meta.get("unit"), + "from": input_meta.get("product") or input_meta.get("name"), + "to": output_meta.get("name"), + "database": input_meta.get("database"), + "formula": exchange.get("formula"), + "comment": exchange.get("comment"), + "uncertainty": exchange.get("uncertainty type"), + "_exchange": exchange, + "_output_key": output_key, + "_input_key": input_key, + } + translated.append(row) + except Exception as e: + # Skip if exchange can't be loaded + continue + + columns = ["amount", "unit", "from", "to", "database", "formula", "comment", "uncertainty", "_exchange", "_output_key", "_input_key"] + return pd.DataFrame(translated, columns=columns) + + +class ParameterizedExchangesView(widgets.ABTreeView): + """ + A view that displays parameterized exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "unit": delegates.StringDelegate, + "product": delegates.StringDelegate, + "producer": delegates.StringDelegate, + "location": delegates.StringDelegate, + "database": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(widgets.ABMenu): + """ + A context menu for the ParameterizedExchangesView. + """ + def __init__(self, pos, view: "ParameterizedExchangesView"): + """ + Initializes the ContextMenu. + + Args: + pos: The position of the context menu. + view (ParameterizedExchangesView): The view displaying the exchanges. + """ + super().__init__(view) + + index = view.indexAt(pos) + if index.isValid() and not view.model().isBranchNode(index): + row = view.model().row(index) + if row is not None: + output_key = row.get("_output_key") + if output_key: + # Open activity action + open_action = app.actions.ActivityOpen.get_QAction([output_key]) + open_action.setText("Open activity") + self.addAction(open_action) + + +class ParameterizedExchangesModel(core.ABTreeModel): + """ + A model representing the data for parameterized exchanges. + """ + + def __init__(self, parent=None): + """ + Initializes the ParameterizedExchangesModel. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(df=pd.DataFrame(), parent=parent) + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + exchange = row.get("_exchange") + if exchange is None: + return False + + if column_name in ["amount", "formula", "comment"]: + if column_name == "formula" and not str(value).strip(): + # Remove formula if empty + app.actions.ExchangeFormulaRemove.run([exchange]) + return True + + app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name == "amount": + formula = self.get(index, "formula") + if pd.isna(formula) or formula is None or formula == "": + return icons.qicons.edit + return icons.qicons.parameterized + + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + # Check if database is locked + exchange = row.get("_exchange") + if exchange and database_is_locked(exchange.output["database"]): + return False + + # Allow editing for specific columns + if column_name in ["amount", "formula", "comment"]: + return True + + return False + + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: + """ + Returns the parameters in scope of the exchange at the given index. + + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + dict: The parameters in scope. + """ + from activity_browser.bwutils.commontasks import parameters_in_scope + + row = self.row(index) + if row is None: + return {} + + exchange = row.get("_exchange") + if exchange is None: + return {} + + return parameters_in_scope(node=exchange.output) + diff --git a/activity_browser/app/pages/parameters/parameters.py b/activity_browser/app/pages/parameters/parameters.py index c9ef3421b..72c953230 100644 --- a/activity_browser/app/pages/parameters/parameters.py +++ b/activity_browser/app/pages/parameters/parameters.py @@ -1,495 +1,65 @@ -from pathlib import Path - -from xlsxwriter.exceptions import FileCreateError - -import pandas as pd -import bw2data as bd - from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Qt - -from activity_browser import app, signals -from activity_browser.ui import icons, widgets -from activity_browser.bwutils import manager, superstructure -from .parameter_views import ActivityParameterTable, BaseParameterTable, DataBaseParameterTable, ExchangesTable, ProjectParameterTable, ScenarioTable +from activity_browser.ui import widgets +from .parameters_section import ParametersSection +from .parameterized_exchanges_section import ParameterizedExchangesSection -class ParametersPage(QtWidgets.QTabWidget): - """Parameters tab in which user can define project-, database- and - activity-level parameters for their system. - Changing projects will trigger a reload of all parameters +class ParametersPage(QtWidgets.QWidget): """ + A widget that displays all parameters and parameterized exchanges in the current project. - def __init__(self, parent=None): - super().__init__(parent) - self.setTabsClosable(False) - - # Initialize both parameter tabs - self.tabs = { - "Definitions": ParameterDefinitionTab(self), - "Exchanges": ParameterExchangesTab(self), - "Scenarios": ParameterScenariosTab(self), - } - for name, tab in self.tabs.items(): - self.addTab(tab, name) - - for tab in self.tabs.values(): - if hasattr(tab, "build_tables"): - tab.build_tables() - - self._connect_signals() - - def _connect_signals(self): - # signals.add_activity_parameter.connect(self.activity_parameter_added) - pass - - def activity_parameter_added(self) -> None: - """Selects the correct sub-tab to show and trigger a switch to - the Parameters tab. - """ - self.setCurrentIndex(self.indexOf(self.tabs["Definitions"])) - signals.show_tab.emit("Parameters") - - -class ABParameterTable(QtWidgets.QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.table = None - self.header = None - - def create_layout( - self, - title: str = None, - bttn: QtWidgets.QAbstractButton = None, - table: BaseParameterTable = None, - ): - headerLayout = QtWidgets.QHBoxLayout() - self.header = widgets.ABLabel.demiBold(title) - - headerLayout.addWidget(self.header) - headerLayout.addWidget(bttn) - headerLayout.addStretch(1) - - layout = QtWidgets.QVBoxLayout() - layout.addLayout(headerLayout) - layout.addWidget(table) - return layout - - def get_table(self): - return self.table - - -class ABProjectParameter(ABParameterTable): - def __init__(self, parent=None): - super().__init__(parent) - self.new_parameter_button = app.actions.ParameterNew.get_QButton(("", "")) - self.header = "Project:" - self.table = ProjectParameterTable(self) - - self.setLayout( - self.create_layout(self.header, self.new_parameter_button, self.table) - ) - - -class ABDatabaseParameter(ABParameterTable): - def __init__(self, parent=None): - super().__init__(parent) - self.header = "Database:" - - self.new_parameter_button = app.actions.ParameterNew.get_QButton(("db", "")) - - self.table = DataBaseParameterTable(self) - - self.setLayout( - self.create_layout(self.header, self.new_parameter_button, self.table) - ) - - def set_enabled(self, trigger): - if not list(bd.databases): - self.new_parameter_button.setEnabled(False) - else: - self.new_parameter_button.setEnabled(True) - - -class ABActivityParameter(ABParameterTable): - def __init__(self, parent=None): - super().__init__(parent) - self.header = "Activity:" - self.parameter = QtWidgets.QCheckBox("Show order column", self) - self.table = ActivityParameterTable(self) - - self.setLayout(self.create_layout(self.header, self.parameter, self.table)) - self._connect_signal() - - def _connect_signal(self): - self.parameter.stateChanged.connect(self.activity_order_column) - - def activity_order_column(self) -> None: - col = self.table.model.order_col - state = self.parameter.isChecked() - if not state: - self.table.setColumnHidden(col, True) - else: - self.table.setColumnHidden(col, False) - self.table.resizeColumnToContents(col) - - -class ParameterDefinitionTab(QtWidgets.QWidget): - """Parameter definitions tab. - - This tab shows three tables containing the project-, database- and - activity level parameters set for the project. - - The user can create new parameters at these three levels and save - new or edited parameters with a single button. - Pressing the save button will cause brightway to validate the changes - and a warning message will appear if an error occurs. - """ - - def __init__(self, parent=None): - super().__init__(parent) - - self.project_table = ABProjectParameter(self) - self.database_table = ABDatabaseParameter(self) - self.activity_table = ABActivityParameter(self) - self.tables = { - "project": self.project_table.get_table(), - "database": self.database_table.get_table(), - "activity": self.activity_table.get_table(), - } - for t in self.tables.values(): - t.model.sync() - - self.show_database_params = QtWidgets.QCheckBox("Database parameters", self) - self.show_database_params.setToolTip("Show/hide the database parameters") - self.show_database_params.setChecked(True) - - self.show_activity_params = QtWidgets.QCheckBox("Activity parameters", self) - self.show_activity_params.setToolTip("Show/hide the activity parameters") - self.show_activity_params.setChecked(True) - self.comment_column = QtWidgets.QCheckBox("Comments", self) - self.comment_column.setToolTip("Show/hide the comment column") - self.hide_comment_column() - self.uncertainty_columns = QtWidgets.QCheckBox("Uncertainty", self) - self.uncertainty_columns.setToolTip("Show/hide the uncertainty columns") - - self._construct_layout() - self._connect_signals() - - self.explain_text = """ -

This tab is the main tab for creating and modifying parameters.

-

The scope of parameters can be either a specific activity, a database, or an entire project -(meaning that an activity parameter can only be used within a specific activity, -while a project parameter can be used anywhere within a project and across all databases within that project).

- - - -

In general

-

All parameters must have a name and amount. A formula is optional.

-

The formula is stored as a string that is interpreted by brightway. Python builtin functions and Numpy functions -can be used within the formula!

-

Parameters can only be deleted if they are not used in formulas of other parameters.

-

Note that optionally uncertainties, can be specified for parameters.

- -

Activity parameters

-

New parameters are added either by drag-and-dropping activities from the database table or by adding - a formula to an activity exchange within the Activity tab.

-
    -
  • Only activities from editable databases can be parameterized.
  • -
  • Multiple parameters can be created for a single activity.
  • -
  • The parameter name must be unique within the group of parameters for an activity.
  • -
  • Note: activity parameters are also auto-generated when a project or database parameter is used in an activity that has previously not been parameterized.
  • -
- - - -

For more information on this topic see also the -Brightway2 documentation.

-""" - - def _connect_signals(self): - signals.project.changed.connect(self.build_tables) - signals.parameter.recalculated.connect(self.build_tables) - - self.show_database_params.toggled.connect(self.hide_database_parameter) - self.show_activity_params.toggled.connect(self.hide_activity_parameter) - self.comment_column.stateChanged.connect(self.hide_comment_column) - self.uncertainty_columns.stateChanged.connect(self.hide_uncertainty_columns) - - def _construct_layout(self): - """Construct the widget layout for the variable parameters tab""" - layout = QtWidgets.QVBoxLayout() - - self.uncertainty_columns.setChecked(False) - row = QtWidgets.QToolBar() - _header = widgets.ABLabel.demiBold("Parameters ") - _header.setToolTip("Left click on the question mark for help") - row.addWidget(_header) - row.addWidget(self.show_database_params) - row.addWidget(self.show_activity_params) - row.addWidget(self.comment_column) - row.addWidget(self.uncertainty_columns) - layout.addWidget(row) - layout.addWidget(widgets.ABHLine(self)) - - tables = QtWidgets.QSplitter(Qt.Vertical) - tables.addWidget(self.project_table) - tables.addWidget(self.database_table) - tables.addWidget(self.activity_table) - layout.addWidget(tables) - - self.setLayout(layout) - - def build_tables(self): - """Read parameters from brightway and build dataframe tables""" - self.hide_uncertainty_columns() - self.activity_order_column() - # Cannot create database parameters without databases - if not list(bd.databases): - self.database_table.set_enabled(False) - else: - self.database_table.set_enabled(True) - - def hide_uncertainty_columns(self): - show = self.uncertainty_columns.isChecked() - for table in self.tables.values(): - table.uncertainty_columns(show) - - def hide_comment_column(self): - show = self.comment_column.isChecked() - for table in self.tables.values(): - table.comment_column(show) - - def activity_order_column(self) -> None: - col = self.activity_table.get_table().model.order_col - state = self.activity_table.parameter.isChecked() - if not state: - self.activity_table.get_table().setColumnHidden(col, True) - else: - self.activity_table.get_table().setColumnHidden(col, False) - self.activity_table.get_table().resizeColumnToContents(col) - - def hide_database_parameter(self, toggled: bool) -> None: - self.database_table.header.setHidden(not toggled) - self.database_table.new_parameter_button.setHidden(not toggled) - self.database_table.table.setHidden(not toggled) - self.database_table.setHidden(not toggled) - - def hide_activity_parameter(self, toggled: bool) -> None: - self.activity_table.header.setHidden(not toggled) - self.activity_table.parameter.setHidden(not toggled) - self.activity_table.table.setHidden(not toggled) - self.activity_table.setHidden(not toggled) - - -class ParameterExchangesTab(QtWidgets.QWidget): - """Overview of exchanges - - This tab shows a foldable treeview table containing all of the - parameters set for the current project. - - Changes made to parameters in the `Definitions` tab will require - the user to press `Recalculate exchanges` to ensure the amounts in - the exchanges are properly updated. + This page shows: + - Parameters section: A tree view of parameters organized by scope + - Parameterized exchanges section: A table of exchanges with formulas """ def __init__(self, parent=None): - super().__init__(parent) - - self.table = ExchangesTable(self) - - self._construct_layout() - self._connect_signals() - - self.explain_text = """ -

This tab lists all exchanges within the selected project that are calculated via parameters.

-

The Project level parameters are shown above the database and activity parameters.

-

To see the different database and activity parameters in the Project click on the arrows to expand the trees

- -

For more information on this topic see also the -Brightway2 documentation.

-""" - - def _connect_signals(self): - signals.project.changed.connect(self.build_tables) - signals.parameter.recalculated.connect(self.build_tables) - - def _construct_layout(self): - """Construct the widget layout for the exchanges parameters tab""" - layout = QtWidgets.QVBoxLayout() - row = QtWidgets.QToolBar() - _header = widgets.ABLabel.demiBold("Overview of parameterized exchanges") - _header.setToolTip("Left click on the question mark for help") - row.addWidget(_header) - row.setIconSize(QtCore.QSize(24, 24)) - layout.addWidget(row) - layout.addWidget(widgets.ABHLine(self)) - layout.addWidget(self.table, 2) - self.setLayout(layout) - - def build_tables(self) -> None: - """Read parameters from brightway and build tree tables""" - self.table.model.sync() - + """ + Initializes the ParametersPage widget. -class ParameterScenariosTab(QtWidgets.QWidget): - def __init__(self, parent=None): + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ super().__init__(parent) - self.load_btn = QtWidgets.QPushButton(icons.qicons.add, "Import parameter-scenarios") - self.load_btn.setToolTip( - "Load prepared excel files with additional parameter scenarios." - ) - self.save_btn = QtWidgets.QPushButton( - self.style().standardIcon(QtWidgets.QStyle.SP_DialogSaveButton), - "Export parameter-scenarios", - ) - self.save_btn.setToolTip( - "Export the current parameter scenario table to excel." - ) - self.calculate_btn = QtWidgets.QPushButton(icons.qicons.calculate, "Export as flow-scenarios") - self.calculate_btn.setToolTip( - ( - "Process the current parameter scenario table into prepared flow" - " scenario data." - ) - ) - self.reset_btn = QtWidgets.QPushButton(icons.qicons.history, "Reset table") - self.reset_btn.setToolTip("Reset the scenario table, wiping any changes.") - self.hide_group = QtWidgets.QCheckBox("Show group column") - - self.tbl = ScenarioTable(self) - self.tbl.setToolTip( - "This table is not editable, use the export/import functionality" - ) + self.parameters_section = ParametersSection(self) + self.parameterized_exchanges_section = ParameterizedExchangesSection(self) - self._construct_layout() - self._connect_signals() + self.build_layout() - self.explain_text = """ -

This tab has 3 functions:

-

1. Export parameter-scenarios : this exports the table as shown below to an Excel file. You can modify it there and use - it in scenario LCAs (see Calculation Setup tab)

-

2. Import parameter-scenarios: imports a table like the one shown below from Excel. If parameters are missing in Excel, - the default values will be used. IMPORTANT NOTE: the ONLY function this button serves is to display the Excel file. - If you want to use the Excel file in scenario LCA, please import it in the Calculation Setup tab.

-

3. Export as flow-scenarios: This converts a "parameter-scenarios" file (alternative values for parameters) to a - "flow-scenarios" file (alternative values for the exchanges as used in LCA calculations).

- -

Suggested workflow to create scenarios for your parameters:

-

Export parameter-scenarios. This will generate an Excel file for you where you can add scenarios (columns). - You may want to delete rows that you intend to change or rows that are for dependent parameters (those that depend on other parameters) as these values will be overwritten by the formulas. - Finally, import the parameter-scenarios in the Calculation Setup (not here!) to perform scenario calculations (you need to select "Scenario LCA").

- -

For more information on this topic see also the - Brightway2 documentation.

- """ - - def _connect_signals(self): - self.load_btn.clicked.connect(self.select_read_file) - self.save_btn.clicked.connect(self.save_scenarios) - self.calculate_btn.clicked.connect(self.calculate_scenarios) - self.reset_btn.clicked.connect(self.tbl.model.sync) - self.hide_group.toggled.connect(self.tbl.group_column) - signals.parameter_scenario_sync.connect(self.process_scenarios) - - def _construct_layout(self): - layout = QtWidgets.QVBoxLayout() - - row = QtWidgets.QToolBar() - _header = widgets.ABLabel.demiBold("Parameter Scenarios") - _header.setToolTip("Click on the question mark for help") - row.addWidget(_header) - layout.addWidget(row) - layout.addWidget(widgets.ABHLine(self)) - - row = QtWidgets.QHBoxLayout() - # row.addWidget(self.reset_btn) - row.addWidget(self.save_btn) - # row.addWidget(self.load_btn) - row.addWidget(self.calculate_btn) - # row.addWidget(self.hide_group) - row.addStretch(1) - layout.addLayout(row) - layout.addWidget(self.tbl) - self.setLayout(layout) - - def process_scenarios( - self, table_idx: int, df: pd.DataFrame, default: bool - ) -> None: - """Use this method to discretely process a parameter scenario file - for the LCA setup. + def build_layout(self): """ - try: - self.tbl.model.sync(df=df, include_default=default) - scenarios = self.build_flow_scenarios() - signals.parameter_superstructure_built.emit(table_idx, scenarios) - except AssertionError as e: - QtWidgets.QMessageBox.critical( - self, "Cannot load parameters", str(e), QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok - ) - - def select_read_file(self): - path, _ = QtWidgets.QFileDialog.getOpenFileName( - self, caption="Select prepared scenario file", filter=self.tbl.EXCEL_FILTER - ) - if path: - df = pd.read_excel(path, engine="openpyxl") - self.tbl.model.sync(df=df) - - def save_scenarios(self): - try: - self.tbl.to_excel("Save current scenarios to Excel") - except FileCreateError as e: - QtWidgets.QMessageBox.warning( - self, - "File save error", - "Cannot save the file, please see if it is opened elsewhere or " - "if you are allowed to save files in that location:\n\n{}".format(e), - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - - def calculate_scenarios(self): - df = self.build_flow_scenarios() - self.store_flows_to_file(df) - - def build_flow_scenarios(self) -> pd.DataFrame: - """Calculate exchange changes for each parameter scenario and construct - a flow scenarios template file. + Builds the layout of the widget. """ - pm = manager.ParameterManager() - names, data = zip(*self.tbl.iterate_scenarios()) + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 3, 0, 0) + + # Add both sections in a splitter + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) + + # Parameters section + params_widget = QtWidgets.QWidget() + params_layout = QtWidgets.QVBoxLayout(params_widget) + params_layout.setContentsMargins(0, 0, 0, 0) + params_label = widgets.ABLabel.demiBold(" Parameters") + params_layout.addWidget(params_label) + params_layout.addWidget(widgets.ABHLine(self)) + params_layout.addWidget(self.parameters_section) + splitter.addWidget(params_widget) + + # Parameterized exchanges section + exchanges_widget = QtWidgets.QWidget() + exchanges_layout = QtWidgets.QVBoxLayout(exchanges_widget) + exchanges_layout.setContentsMargins(0, 0, 0, 0) + exchanges_label = widgets.ABLabel.demiBold(" Parameterized Exchanges") + exchanges_layout.addWidget(exchanges_label) + exchanges_layout.addWidget(widgets.ABHLine(self)) + exchanges_layout.addWidget(self.parameterized_exchanges_section) + splitter.addWidget(exchanges_widget) + + layout.addWidget(splitter) + self.setLayout(layout) - exchanges = pm.exchanges_from_scenarios(names, data) - df = superstructure.superstructure_from_scenario_exchanges(exchanges) - return df - def store_flows_to_file(self, df: pd.DataFrame) -> None: - filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self, - caption="Save calculated flow scenarios to Excel", - filter=self.tbl.EXCEL_FILTER, - ) - if filename: - try: - path = Path(filename) - path = ( - path - if path.suffix in {".xlsx", ".xls"} - else path.with_suffix(".xlsx") - ) - df.to_excel(excel_writer=path, index=False) - except FileCreateError as e: - QtWidgets.QMessageBox.warning( - self, - "File save error", - "Cannot save the file, please see if it is opened elsewhere or " - "if you are allowed to save files in that location:\n\n{}".format( - e - ), - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py deleted file mode 100644 index 636b0c2e0..000000000 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ /dev/null @@ -1,626 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt -import pandas as pd -import bw2data as bd -from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, ParameterizedExchange -from bw2data.backends import ExchangeDataset - -from activity_browser import app -from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import refresh_parameter, database_is_locked -from activity_browser.bwutils.utils import Parameter - - -class ParametersPage(QtWidgets.QWidget): - """ - A widget that displays all parameters in the current project. - - This page shows a tree view of parameters organized by scope: - - Project parameters - - Database parameters (grouped by database) - - Activity parameters (grouped by activity group) - - Attributes: - model (ProjectParametersModel): The model containing the data for the parameters. - view (ProjectParametersView): The view displaying the parameters. - """ - - def __init__(self, parent=None): - """ - Initializes the ParametersPage widget. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - - # Parameters tree view - self.model = ProjectParametersModel(parent=self) - self.view = ProjectParametersView() - self.view.setModel(self.model) - - # Parameterized exchanges table view - self.exchanges_model = ParameterizedExchangesModel(parent=self) - self.exchanges_view = ParameterizedExchangesView() - self.exchanges_view.setModel(self.exchanges_model) - - self.build_layout() - self.connect_signals() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - - # Header with title for parameters - header_layout = QtWidgets.QHBoxLayout() - header_label = widgets.ABLabel.demiBold("Parameters") - header_layout.addWidget(header_label) - header_layout.addStretch(1) - - layout.addLayout(header_layout) - layout.addWidget(widgets.ABHLine(self)) - - # Add both views in a splitter - splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) - - # Parameters tree - params_widget = QtWidgets.QWidget() - params_layout = QtWidgets.QVBoxLayout(params_widget) - params_layout.setContentsMargins(0, 0, 0, 0) - params_layout.addWidget(self.view) - splitter.addWidget(params_widget) - - # Parameterized exchanges - exchanges_widget = QtWidgets.QWidget() - exchanges_layout = QtWidgets.QVBoxLayout(exchanges_widget) - exchanges_layout.setContentsMargins(0, 0, 0, 0) - exchanges_label = widgets.ABLabel.demiBold("Parameterized Exchanges") - exchanges_layout.addWidget(exchanges_label) - exchanges_layout.addWidget(self.exchanges_view) - splitter.addWidget(exchanges_widget) - - layout.addWidget(splitter) - self.setLayout(layout) - - def connect_signals(self): - """ - Connects signals to their respective slots. - """ - app.signals.metadata.synced.connect(self.sync) - app.signals.parameter.changed.connect(self.sync) - app.signals.parameter.recalculated.connect(self.sync) - app.signals.parameter.deleted.connect(self.sync) - app.signals.project.changed.connect(self.sync) - app.signals.meta.databases_changed.connect(self.sync) - - def sync(self): - """ - Synchronizes the widget with the current state of parameters. - """ - df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) - self.model.group(["_scope"]) - self.view.expandAll() - - exchanges_df = self.build_exchanges_df() - exchanges_df.reset_index(drop=True, inplace=True) - self.exchanges_model.set_dataframe(exchanges_df) - - self.view.expandAll() - - self.view.resizeColumnToContents(1) - self.view.resizeColumnToContents(3) - self.view.resizeColumnToContents(4) - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from all parameters in the project. - - Returns: - pd.DataFrame: The DataFrame containing the parameters data. - """ - translated = [] - - # Project parameters - for param in ProjectParameter.select(): - row = self._parameter_to_row(param, "Current project", None) - translated.append(row) - - # Database parameters - for param in DatabaseParameter.select(): - row = self._parameter_to_row(param, f"Database: {param.database}", param.database) - translated.append(row) - - # Activity parameters - for param in ActivityParameter.select(): - row = self._parameter_to_row(param, f"Group: {param.group}", param.database) - translated.append(row) - - columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group"] - df = pd.DataFrame(translated, columns=columns) - df["_is_new"] = False - - # Add "New parameter..." placeholders - new_rows = [] - - # Add for project - new_rows.append({ - "name": "New parameter...", - "_scope": "Current project", - "_group": "project", - "_param_type": "project", - "_is_new": True, - }) - - # Add for each database - for db_name in sorted(bd.databases.list): - if not bd.databases[db_name].get("read_only", True): - new_rows.append({ - "name": "New parameter...", - "_scope": f"Database: {db_name}", - "_database": db_name, - "_group": db_name, - "_param_type": "database", - "_is_new": True, - }) - - # Add for each activity group - activity_params = df[df._scope.str.startswith("Group: ", na=False)] - groups = activity_params._group.unique() if len(activity_params) > 0 else [] - for group_name in sorted(groups): - group_data = activity_params[activity_params._group == group_name] - db_name = group_data.iloc[0]._database if len(group_data) > 0 else None - if db_name and db_name in bd.databases and not bd.databases[db_name].get("read_only", True): - new_rows.append({ - "name": "New parameter...", - "_scope": f"Group: {group_name}", - "_database": db_name, - "_group": group_name, - "_param_type": "activity", - "_is_new": True, - }) - - # Append new rows to dataframe - if new_rows: - new_df = pd.DataFrame(new_rows) - df = pd.concat([df, new_df], ignore_index=True) - - return df - - def _parameter_to_row(self, param, scope_label: str, database: str = None) -> dict: - """ - Converts a parameter to a row dictionary. - - Args: - param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). - scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). - database: The database name (None for project parameters). - - Returns: - dict: A dictionary representing the parameter row. - """ - data = param.dict - - # Create Parameter wrapper - if isinstance(param, ProjectParameter): - parameter = Parameter(param.name, "project", data.get("amount"), data, "project") - group = "project" - elif isinstance(param, DatabaseParameter): - parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") - group = param.database - elif isinstance(param, ActivityParameter): - parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") - group = param.group - else: - raise ValueError(f"Unknown parameter type: {type(param)}") - - row = { - "name": param.name, - "amount": data.get("amount"), - "uncertainty": data.get("uncertainty type"), - "formula": data.get("formula"), - "comment": data.get("comment"), - "_parameter": parameter, - "_scope": scope_label, - "_database": database, - "_group": group, - } - - return row - - def build_exchanges_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from all parameterized exchanges in the project. - - Returns: - pd.DataFrame: The DataFrame containing the parameterized exchanges data. - """ - translated = [] - - # Get all parameterized exchanges - for param_exc in ParameterizedExchange.select(): - try: - exchange = bd.Edge(document=ExchangeDataset.get_by_id(param_exc.exchange)) - - # Get keys for input and output - input_key = exchange.get("input") - output_key = exchange.get("output") - - # Get metadata from metadata store - input_meta = app.metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] - output_meta = app.metadata.get_metadata([output_key], ["name"]).iloc[0] - - row = { - "amount": exchange.get("amount"), - "unit": input_meta.get("unit"), - "from": input_meta.get("product") or input_meta.get("name"), - "to": output_meta.get("name"), - "database": input_meta.get("database"), - "formula": exchange.get("formula"), - "comment": exchange.get("comment"), - "uncertainty": exchange.get("uncertainty type"), - "_exchange": exchange, - "_output_key": output_key, - "_input_key": input_key, - } - translated.append(row) - except Exception as e: - # Skip if exchange can't be loaded - continue - - columns = ["amount", "unit", "from", "to", "database", "formula", "comment", "uncertainty", "_exchange", "_output_key", "_input_key"] - return pd.DataFrame(translated, columns=columns) - - -class ProjectParametersView(widgets.ABNewTreeView): - """ - A view that displays the project parameters in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "name": delegates.StringDelegate, - "formula": delegates.NewFormulaDelegate, - "comment": delegates.StringDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(widgets.ABMenu): - """ - A context menu for the ProjectParametersView. - """ - - def __init__(self, pos, view: "ProjectParametersView"): - """ - Initializes the ContextMenu. - - Args: - pos: The position of the context menu. - view (ProjectParametersView): The view displaying the parameters. - """ - super().__init__(view) - - index = view.indexAt(pos) - if index.isValid() and not view.model().isBranchNode(index): - row = view.model().row(index) - if row is not None and not row.get("_is_new"): - parameter = row.get("_parameter") - if parameter: - param = refresh_parameter(parameter).to_peewee_model() - self.del_param_action = app.actions.ParameterDelete().get_QAction(param) - if not param.is_deletable() or param.name == "dummy_parameter": - self.del_param_action.setEnabled(False) - self.addAction(self.del_param_action) - - -class ProjectParametersModel(core.ABTreeModel): - """ - A model representing the data for all project parameters. - """ - - def __init__(self, parent=None): - """ - Initializes the ProjectParametersModel. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(df=pd.DataFrame(), parent=parent) - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - # Handle "New parameter..." rows - if row.get("_is_new"): - if column_name != "name" or value == "": - return False - - parameter = Parameter( - name=value, - group=row.get("_group"), - param_type=row.get("_param_type") - ) - - app.actions.ParameterNewFromParameter.run(parameter) - return True - - # Handle regular parameter edits - parameter = row.get("_parameter") - if parameter is None: - return False - - if column_name in ["amount", "formula", "name", "comment"]: - parameter = refresh_parameter(parameter) - app.actions.ParameterModify.run(parameter, column_name, value) - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - - if column_name == "amount": - return icons.qicons.empty if pd.isna(self.get(index, "formula")) else icons.qicons.parameterized - - return None - - def fontData(self, index: QtCore.QModelIndex) -> any: - """ - Provides font data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide font data. - - Returns: - QtGui.QFont: The font data for the index. - """ - if self.get(index, "_is_new"): - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.ExtraLight) - return font - - return None - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - - # Check if database is locked - database = self.get(index, "_database") - if not pd.isna(database) and database_is_locked(database): - return False - - # Allow editing for specific columns - if column_name in ["formula", "uncertainty", "name", "comment"]: - return True - - if column_name == "amount" and not self.get(index, "formula"): - return True - - return False - - def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: - """ - Returns the parameters in scope of the parameter at the given index. - - Args: - index (QtCore.QModelIndex): The index to get scoped parameters for. - - Returns: - dict: The parameters in scope. - """ - from activity_browser.bwutils.commontasks import parameters_in_scope - - row = self.row(index) - if row is None: - return {} - - parameter = row.get("_parameter") - if parameter is None: - return {} - - return parameters_in_scope(parameter=parameter) - - -class ParameterizedExchangesView(widgets.ABNewTreeView): - """ - A view that displays parameterized exchanges in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "unit": delegates.StringDelegate, - "product": delegates.StringDelegate, - "producer": delegates.StringDelegate, - "location": delegates.StringDelegate, - "database": delegates.StringDelegate, - "formula": delegates.NewFormulaDelegate, - "comment": delegates.StringDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(widgets.ABMenu): - """ - A context menu for the ParameterizedExchangesView. - """ - def __init__(self, pos, view: "ParameterizedExchangesView"): - """ - Initializes the ContextMenu. - - Args: - pos: The position of the context menu. - view (ParameterizedExchangesView): The view displaying the exchanges. - """ - super().__init__(view) - - index = view.indexAt(pos) - if index.isValid() and not view.model().isBranchNode(index): - row = view.model().row(index) - if row is not None: - output_key = row.get("_output_key") - if output_key: - # Open activity action - open_action = app.actions.ActivityOpen.get_QAction([output_key]) - open_action.setText("Open activity") - self.addAction(open_action) - - -class ParameterizedExchangesModel(core.ABTreeModel): - """ - A model representing the data for parameterized exchanges. - """ - - def __init__(self, parent=None): - """ - Initializes the ParameterizedExchangesModel. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(df=pd.DataFrame(), parent=parent) - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - exchange = row.get("_exchange") - if exchange is None: - return False - - if column_name in ["amount", "formula", "comment"]: - if column_name == "formula" and not str(value).strip(): - # Remove formula if empty - app.actions.ExchangeFormulaRemove.run([exchange]) - return True - - app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - - if column_name == "amount": - formula = self.get(index, "formula") - if pd.isna(formula) or formula is None or formula == "": - return icons.qicons.edit - return icons.qicons.parameterized - - return None - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - # Check if database is locked - exchange = row.get("_exchange") - if exchange and database_is_locked(exchange.output["database"]): - return False - - # Allow editing for specific columns - if column_name in ["amount", "formula", "comment"]: - return True - - return False - - def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: - """ - Returns the parameters in scope of the exchange at the given index. - - Args: - index (QtCore.QModelIndex): The index to get scoped parameters for. - - Returns: - dict: The parameters in scope. - """ - from activity_browser.bwutils.commontasks import parameters_in_scope - - row = self.row(index) - if row is None: - return {} - - exchange = row.get("_exchange") - if exchange is None: - return {} - - return parameters_in_scope(node=exchange.output) diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py new file mode 100644 index 000000000..8eff88c50 --- /dev/null +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -0,0 +1,422 @@ +from loguru import logger + +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +import pandas as pd +import bw2data as bd +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import refresh_parameter, database_is_locked +from activity_browser.bwutils.utils import Parameter + + +class ParametersSection(QtWidgets.QWidget): + """ + A widget section that displays all parameters in the current project. + + This section shows a tree view of parameters organized by scope: + - Project parameters + - Database parameters (grouped by database) + - Activity parameters (grouped by activity group) + + Attributes: + model (ProjectParametersModel): The model containing the data for the parameters. + view (ProjectParametersView): The view displaying the parameters. + """ + + def __init__(self, parent=None): + """ + Initializes the ParametersSection widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + # Parameters tree view + self.model = ProjectParametersModel(parent=self) + self.view = ProjectParametersView() + self.view.setModel(self.model) + self.view.setUniformRowHeights(True) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.view) + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + app.signals.metadata.synced.connect(self.sync) + + app.signals.parameter.changed.connect(self.sync) + app.signals.parameter.recalculated.connect(self.sync) + app.signals.parameter.deleted.connect(self.sync) + + def sync(self): + """ + Synchronizes the widget with the current state of parameters. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_df() + self.model.set_dataframe(df, group=["_param_type", "_scope"]) + self.view.expandAll() + + self.view.resizeColumnToContents(1) + self.view.resizeColumnToContents(3) + self.view.resizeColumnToContents(4) + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameters in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameters data. + """ + translated = [] + + # Project parameters + for param in ProjectParameter.select(): + row = self._parameter_to_row(param) + translated.append(row) + + translated.append({ + "name": "New parameter...", + "_group": "project", + "_param_type": "project", + "_class": "new", + }) + + # Database parameters + db_params = DatabaseParameter.select() + for db_name in bd.databases.list: + + for param in db_params.where(DatabaseParameter.database == db_name): + row = self._parameter_to_row(param, db_name, db_name) + translated.append(row) + + if not database_is_locked(db_name): + translated.append({ + "name": "New parameter...", + "_scope": db_name, + "_database": db_name, + "_group": db_name, + "_param_type": "database", + "_class": "new", + }) + + # Activity parameters + act_params = ActivityParameter.select() + groups = Group.select() + non_act = ["project"] + bd.databases.list + + for group_name in [group.name for group in groups if group.name not in non_act]: + param = None + + for param in act_params.where(ActivityParameter.group == group_name): + row = self._parameter_to_row(param, f"Group: {group_name}", param.database) + translated.append(row) + + if param is None: + # No parameters in this group: broken group + translated.append({ + "name": "Broken parameter group", + "_scope": f"Group: {group_name}", + "_database": None, + "_group": group_name, + "_param_type": "activity", + "_class": "broken", + }) + continue + + if not database_is_locked(param.database): + translated.append({ + "name": "New parameter...", + "_scope": f"Group: {group_name}", + "_database": param.database, + "_group": group_name, + "_param_type": "activity", + "_class": "new", + }) + + columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", "_param_type", "_class"] + df = pd.DataFrame(translated, columns=columns) + return df + + def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: + """ + Converts a parameter to a row dictionary. + + Args: + param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). + scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). + database: The database name (None for project parameters). + + Returns: + dict: A dictionary representing the parameter row. + """ + data = param.dict + + # Create Parameter wrapper + if isinstance(param, ProjectParameter): + parameter = Parameter(param.name, "project", data.get("amount"), data, "project") + group = "project" + param_type = "project" + elif isinstance(param, DatabaseParameter): + parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") + group = param.database + param_type = "database" + elif isinstance(param, ActivityParameter): + parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") + group = param.group + param_type = "activity" + else: + raise ValueError(f"Unknown parameter type: {type(param)}") + + row = { + "name": parameter.name, + "amount": parameter.amount, + "uncertainty": parameter.uncertainty, + "formula": data.get("formula"), + "comment": data.get("comment"), + "_param_type": param_type, + "_parameter": parameter, + "_scope": scope_label, + "_database": database, + "_group": group, + "_class": "instantiated", + } + + return row + + +class ProjectParametersView(widgets.ABTreeView): + """ + A view that displays the project parameters in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "name": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(widgets.ABMenu): + """ + A context menu for the ProjectParametersView. + """ + menuSetup = [ + lambda m, p: m.add(app.actions.ParameterDelete, p.selected_parameters(), + text="Delete parameter(s)", + enable=(all([p.deletable for p in p.selected_parameters()]) + and len(p.selected_parameters()) > 0) + and all([not database_is_locked(p.database) + for p in p.selected_parameters() + if p.param_type != "project" + ]) + ), + lambda m, p: m.add(app.actions.ParameterGroupDelete, p.selected_groups(), + text="Delete parameter group(s)", + enable=(len(p.selected_groups()) > 0 + and all([not database_is_locked(p.database) + for p in p.selected_parameters() + if p.param_type != "project" + ]) + ) + ), + ] + + def selected_parameters(self): + """ + Returns a list of selected parameters in the view. + + Returns: + list: A list of selected Parameter objects. + """ + selected = [] + for index in self.selectedIndexes(): + parameter = self.model().get(index, "_parameter") + if parameter is not None and not pd.isna(parameter) and parameter not in selected: + selected.append(parameter) + + return selected + + def selected_groups(self): + """ + Returns a list of selected parameter groups in the view. + + Returns: + list: A list of selected parameter group names. + """ + selected = set() + for index in self.selectedIndexes(): + group = self.model().get(index, "_group") + group and selected.add(group) + + return list(selected) + +class ProjectParametersModel(core.ABTreeModel): + """ + A model representing the data for all project parameters. + """ + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + # Handle "New parameter..." rows + if row.get("_class") == "new": + if column_name != "name" or value == "": + return False + + parameter = Parameter( + name=value, + group=row.get("_group"), + param_type=row.get("_param_type") + ) + + app.actions.ParameterNewFromParameter.run(parameter) + return True + + # Handle regular parameter edits + parameter = row.get("_parameter") + if parameter is None: + return False + + if column_name in ["amount", "formula", "name", "comment"]: + parameter = refresh_parameter(parameter) + app.actions.ParameterModify.run(parameter, column_name, value) + + if column_name == "uncertainty": + parameter = refresh_parameter(parameter) + app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) + + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name == "amount": + return icons.qicons.empty if pd.isna(self.get(index, "formula")) else icons.qicons.parameterized + + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: + """ + Provides font data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + param_class = self.get(index, "_class") + if param_class == "new": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.ExtraLight) + return font + + if param_class == "broken": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.Bold) + return font + + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + + # Check if database is locked + database = self.get(index, "_database") + if not pd.isna(database) and database_is_locked(database): + return False + + # Prevent editing broken parameters + if self.get(index, "_class") == "broken": + return False + + # Allow editing for specific columns + if column_name in ["formula", "uncertainty", "name", "comment"]: + return True + + if column_name == "amount" and not self.get(index, "formula"): + return True + + return False + + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: + """ + Returns the parameters in scope of the parameter at the given index. + + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + dict: The parameters in scope. + """ + from activity_browser.bwutils.commontasks import parameters_in_scope + + row = self.row(index) + if row is None: + return {} + + parameter = row.get("_parameter") + if parameter is None: + return {} + + return parameters_in_scope(parameter=parameter) + diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index 7856f6ee0..925550825 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from loguru import logger import pandas as pd @@ -22,8 +21,8 @@ def __init__(self, parent=None): self.tabs = QtWidgets.QTabWidget(self) - self.project_model = ProjectModel(parent=self) - self.template_model = TemplateModel(parent=self) + self.project_model = ProjectModel(parent=self, enable_sorting=True) + self.template_model = TemplateModel(parent=self, enable_sorting=True) self.project_view = ProjectView(self) self.project_view.setModel(self.project_model) @@ -36,7 +35,6 @@ def __init__(self, parent=None): self.build_layout() self.connect_signals() - self.reset() def build_layout(self): """Build the chapter layout.""" @@ -47,18 +45,17 @@ def build_layout(self): def connect_signals(self): """Connect signals and slots.""" - app.signals.project.changed.connect(self.sync) app.signals.project.deleted.connect(self.sync) def sync(self): """Sync project and template data.""" + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + df = self.build_project_df() - df.reset_index(drop=True, inplace=True) self.project_model.set_dataframe(df) self.project_view.resizeColumnToContents(1) df = self.build_template_df() - df.reset_index(drop=True, inplace=True) self.template_model.set_dataframe(df) self.template_view.resizeColumnToContents(1) @@ -117,7 +114,7 @@ def build_template_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class ProjectView(widgets.ABNewTreeView): +class ProjectView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ @@ -202,7 +199,7 @@ def decorationData(self, index): return None -class TemplateView(widgets.ABNewTreeView): +class TemplateView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [] diff --git a/activity_browser/app/panes/README.md b/activity_browser/app/panes/README.md new file mode 100644 index 000000000..35c959916 --- /dev/null +++ b/activity_browser/app/panes/README.md @@ -0,0 +1,121 @@ +# panes + +Dock-able side panels that can be arranged around the main content area. + +## Overview + +This directory contains pane widgets that can be docked to the edges of the main window or floated as separate windows. Panes provide quick access to navigation, information, and tools while working with the main content pages. + +## Purpose + +Panes offer: +- **Quick navigation** - Browse databases, activities, methods +- **Contextual information** - Show details about selected items +- **Tool access** - Quick access to common tools and operations +- **Workspace customization** - Users can arrange panes to suit their workflow + +## Pane Architecture + +Panes inherit from `AbstractPane` (in `ui/widgets/abstract_pane.py`) which provides: +- Dock widget functionality +- Consistent styling +- Signal connections +- State persistence (dock position, visibility) + +## Common Pane Types + +### Navigation Panes +- **Database browser** - Tree view of available databases +- **Activity browser** - Search and browse activities +- **Method browser** - Browse impact assessment methods +- **Project browser** - List of Brightway projects + +### Information Panes +- **Details panel** - Show details of selected items +- **Properties** - Display item properties and metadata +- **History** - Recent actions or visited items + +### Tool Panes +- **Quick calculations** - Run simple calculations +- **Search** - Global search interface +- **Console** - Python console for advanced users + +## Pane Features + +### Docking Behavior +Panes can be: +- Docked to window edges (left, right, top, bottom) +- Stacked with other panes (tabbed) +- Floated as separate windows +- Resized by dragging dividers +- Hidden/shown via View menu + +### State Persistence +Pane positions and visibility are saved between sessions: +- Dock area and position +- Floating window geometry +- Visibility state +- Tab order when stacked + +## Usage Pattern + +```python +from activity_browser.ui.widgets import AbstractPane + +class MyPane(AbstractPane): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + # Build pane content + pass +``` + +## Integration with Main Window + +Panes are added to the main window as dock widgets: + +```python +from activity_browser import app + +pane = MyPane() +app.main_window.addDockWidget(Qt.LeftDockWidgetArea, pane) +``` + +## Signal Communication + +Panes communicate with other components via signals: + +```python +from activity_browser import app + +class MyPane(AbstractPane): + def on_item_selected(self, item): + # Emit signal for other components + app.signals.item_selected.emit(item) +``` + +## Development Guidelines + +When creating new panes: + +1. **Inherit from AbstractPane** - Use the base class for consistency +2. **Set pane title** - Provide a clear, descriptive title +3. **Keep focused** - Each pane should have a single, clear purpose +4. **Connect signals** - Listen for and emit relevant signals +5. **Handle updates** - Refresh when underlying data changes +6. **Support search/filter** - Allow users to find items quickly +7. **Provide context menus** - Right-click actions for items +8. **Make it closeable** - Users should be able to hide panes +9. **Support keyboard navigation** - Enable keyboard shortcuts + +## Visibility Control + +Panes can be shown/hidden via: +- View menu (one menu item per pane) +- Toolbar buttons +- Keyboard shortcuts +- Context menu on title bar + +The main window tracks pane visibility and provides a centralized way to manage them. diff --git a/activity_browser/app/panes/__init__.py b/activity_browser/app/panes/__init__.py index a1eaf0974..e6b2aab5a 100644 --- a/activity_browser/app/panes/__init__.py +++ b/activity_browser/app/panes/__init__.py @@ -1,4 +1,3 @@ -from .database_explorer import DatabaseExplorerPane from .database_products import DatabaseProductsPane from .databases import DatabasesPane from .impact_categories import ImpactCategoriesPane diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index f4b9fe8b3..c5efdf014 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -1,9 +1,10 @@ from qtpy import QtWidgets, QtGui +from loguru import logger import bw2data as bd import pandas as pd -from activity_browser import app, app +from activity_browser import app from activity_browser.ui import widgets, delegates, core @@ -37,7 +38,6 @@ def connect_signals(self): Connects the signals to the appropriate slots. """ app.signals.meta.calculation_setups_changed.connect(self.sync) - app.signals.project.changed.connect(self.sync) def build_layout(self): """ @@ -52,8 +52,8 @@ def sync(self): """ Synchronizes the model with the current state of the calculation setups. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") df = self.build_df() - df.reset_index(drop=True, inplace=True) self.model.set_dataframe(df) self.view.resizeColumnToContents(0) @@ -79,7 +79,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class CalculationSetupsView(widgets.ABNewTreeView): +class CalculationSetupsView(widgets.ABTreeView): """ A view that displays the calculation setups in a tree structure. diff --git a/activity_browser/app/panes/database_explorer.py b/activity_browser/app/panes/database_explorer.py deleted file mode 100644 index ea7e3ae3d..000000000 --- a/activity_browser/app/panes/database_explorer.py +++ /dev/null @@ -1,182 +0,0 @@ -from loguru import logger - -import pandas as pd -from qtpy import QtWidgets, QtCore, QtGui - -import bw2data as bd - -from activity_browser.ui import widgets -from activity_browser.app import application, signals, metadata - - - -COLUMNS = ["name", "type", "exchanges", "database", "code"] -DETAILS_COLUMNS = ["input", "output", "type", "amount"] - - -DEFAULT_STATE = { - "columns": ["Activity", "Product", "Type", "Unit", "Location"], - "visible_columns": ["Activity", "Product", "Type", "Unit", "Location"], -} - - -NODETYPES = { - "all_nodes": [], - "processes": ["process", "multifunctional", "processwithreferenceproduct", "nonfunctional"], - "products": ["product", "processwithreferenceproduct", "waste"], - "biosphere": ["natural resource", "emission", "inventory indicator", "economic", "social"], -} - - -class DatabaseExplorerPane(widgets.ABAbstractPane): - - def __init__(self, db_name: str, parent=None): - super().__init__(parent, QtCore.Qt.WindowType.Window) - self.title = "Database Explorer - " + db_name - self.database = bd.Database(db_name) - self.model = NodeModel(self) - - # Create the QTableView and set the model - self.table_view = NodeView(self) - self.table_view.setModel(self.model) - self.model.setDataFrame(self.build_df()) - - self.search = QtWidgets.QLineEdit(self) - self.search.setMaximumHeight(30) - self.search.setPlaceholderText("Quick Search") - - self.search.textChanged.connect(self.table_view.setAllFilter) - - self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) - self.splitter.setChildrenCollapsible(False) - self.splitter.addWidget(self.table_view) - - self.setLayout(QtWidgets.QVBoxLayout()) - self.layout().addWidget(self.search) - self.layout().addWidget(self.splitter) - - # connect signals - signals.database.deleted.connect(self.deleteLater) - signals.project.changed.connect(self.deleteLater) - signals.metadata.synced.connect(self.sync) - self.table_view.filtered.connect(self.search_error) - - def sync(self): - self.model.setDataFrame(self.build_df()) - - def build_df(self) -> pd.DataFrame: - import sqlite3 - from bw2data.backends import sqlite3_lci_db - - full_df = metadata.get_database_metadata(self.database.name) - - con = sqlite3.connect(sqlite3_lci_db._filepath) - sql = f"SELECT output_code FROM exchangedataset WHERE output_database == '{self.database.name}'" - excs = pd.read_sql(sql, con) - con.close() - - count = excs.groupby(excs.columns.tolist()).size() - count.name = "exchanges" - full_df = full_df.join(count, "code") - - return full_df - - def search_error(self, reset=False): - if reset: - self.search.setPalette(application.palette()) - return - - palette = self.search.palette() - palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(255, 128, 128)) - self.search.setPalette(palette) - - -class NodeView(widgets.ABTreeView): - - def __init__(self, above: QtWidgets.QWidget=None, parent=None): - super().__init__(parent) - self.setSortingEnabled(True) - self.setDragEnabled(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragOnly) - self.setSelectionBehavior(widgets.ABTreeView.SelectionBehavior.SelectItems) - self.setSelectionMode(widgets.ABTreeView.SelectionMode.ExtendedSelection) - - self.above = above - self.below: QtWidgets.QWidget = QtWidgets.QWidget(self) - - def deleteLater(self): - super().deleteLater() - self.below.deleteLater() - - def mouseReleaseEvent(self, event): - self.below.deleteLater() - self.below = QtWidgets.QWidget(self) - - if not self.selectedIndexes(): - return - - idx = self.selectedIndexes()[0] - col_name = self.model().columns()[idx.column()] - item = idx.internalPointer() - data = item[col_name] - - if col_name == "exchanges": - act = bd.get_node(database=item["database"], code=item["code"]) - model = NodeModel() - model.setDataFrame(pd.DataFrame(act.exchanges())) - - self.below = NodeView(self) - self.below.setModel(model) - - self.parent().addWidget(self.below) - - elif isinstance(data, (dict, list, tuple)): - if isinstance(data, dict): - df = pd.DataFrame.from_dict(data, orient="index") - df.reset_index(inplace=True) - else: - df = pd.DataFrame(data) - model = NodeModel(dataframe=df) - - self.below = NodeView(self) - self.below.setModel(model) - - self.parent().addWidget(self.below) - - elif isinstance(data, (str, float, int)): - - if isinstance(data, float) and pd.isna(data): - return - - self.below = QtWidgets.QPlainTextEdit(str(data), self) - self.parent().addWidget(self.below) - - -class NodeItem(widgets.ABDataItem): - - def displayData(self, col: int, key: str): - data = self[key] - - if data is None: - return None - - if isinstance(data, (str, float, int)): - if key == "exchanges": - return f"Exchanges: {data}" if not pd.isna(data) else "Exchanges: 0" - - - rep = str(data).replace("\n", " ") - if len(rep) > 200: - return rep[:200] + "..." - return rep - - elif hasattr(data, "__len__"): - return f"{type(data).__name__.capitalize()}: {len(data)}" - - else: - return str(type(data)) - - -class NodeModel(widgets.ABItemModel): - dataItemClass = NodeItem - diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 547294a7f..e8a2fe6c1 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -1,16 +1,18 @@ +import threading + from loguru import logger from time import time +from threading import Thread import pandas as pd from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt +from qtpy.QtCore import Qt, QModelIndex import bw2data as bd -from activity_browser import app, ui, app -from activity_browser.settings import project_settings +from activity_browser import ui, app from activity_browser.ui import core, widgets, delegates, icons -from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy +from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy, is_node_biosphere, nodes_to_excel NODETYPES = { @@ -42,17 +44,23 @@ def __init__(self, parent, db_name: str): self.name = "database_products_pane_" + db_name super().__init__(parent) + self.database = bd.Database(db_name) self.title = db_name - self.model = ProductModel(parent=self, chunk_size=100) + self.simple = True + + # initialize the model + self.model = ProductModel(parent=self, chunk_size=20, enable_sorting=True) # Create the QTableView and set the model self.table_view = ProductView(self, db_name=db_name) + self.table_view.setUniformRowHeights(True) self.table_view.setModel(self.model) - self.search = widgets.ABLineEdit(self) - self.search.setMaximumHeight(30) - self.search.setPlaceholderText("Quick Search") + self.search_bar = widgets.MetaDataAutoCompleteTextEdit(self) + self.search_bar.database_name = db_name + self.search_bar.setMaximumHeight(30) + self.search_bar.setPlaceholderText("Quick Search") # Create loading indicator with spinner self.loading_spinner = QtWidgets.QProgressBar() @@ -68,6 +76,11 @@ def __init__(self, parent, db_name: str): self.loading_label.setFont(font) self.loading_label.setStyleSheet("color: gray; padding: 10px;") + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.build_layout() self.connect_signals() self.update_loading_state() @@ -94,8 +107,13 @@ def build_layout(self): table_layout.addWidget(self.table_view) self.stacked_layout.addWidget(table_widget) + # Create top bar with search and toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addWidget(self.search_bar) + top_bar.addWidget(self.view_toggle) + layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.search) + layout.addLayout(top_bar) layout.addLayout(self.stacked_layout) # Set the table view as the central widget of the window @@ -105,8 +123,8 @@ def connect_signals(self): app.signals.metadata.synced.connect(self.on_metadata_changed) app.signals.database.deleted.connect(self.on_database_deleted) - self.table_view.filtered.connect(self.search_error) - self.search.textChangedDebounce.connect(self.table_view.setAllFilter) + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + self.search_bar.textChangedDebounce.connect(self.sync) def on_metadata_changed(self, added, updated, deleted): # Check if primary data has finished loading @@ -131,18 +149,47 @@ def sync(self): """ Synchronizes the widget with the current state of the database. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + t = time() df = self.build_df() - df.reset_index(drop=True, inplace=True) + + if self.search_bar.toPlainText().strip(): + # Reset sorting when searching + self.model.sorted_column = None + self.model.sort_order = Qt.SortOrder.AscendingOrder + self.model.set_dataframe(df) - for col in df.columns: - index = self.model.columns().index(col) - if df[col].isna().all(): + + self.update_table_style() + self.update_column_visibility() + + logger.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") + + def update_table_style(self): + self.table_view.header().setHidden(self.simple) + self.table_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.table_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + def update_column_visibility(self): + columns = self.model.columns() + df = self.model.df + + for index, col in enumerate(columns): + if col == "index": + continue + if col == "node": + self.table_view.setColumnHidden(index, not self.simple) + continue + + if df[col].isna().all() or self.simple: self.table_view.hideColumn(index) else: self.table_view.showColumn(index) - logger.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") + self.table_view.reset() def build_df(self) -> pd.DataFrame: """ @@ -153,10 +200,16 @@ def build_df(self) -> pd.DataFrame: """ t = time() cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] - df = app.metadata.get_database_metadata(self.database.name, cols) + + query = self.search_bar.toPlainText().strip() + if query: + df = app.metadata.search_database(query, self.database.name, cols) + else: + df = app.metadata.get_database_metadata(self.database.name, cols) processors = set(df["processor"].dropna().unique()) df = df.drop(processors, errors="ignore") + df.rename(columns={"id": "_id"}, inplace=True) if not df.properties.isna().all(): props_df = df[df.properties.notna()] @@ -170,12 +223,15 @@ def build_df(self) -> pd.DataFrame: how="left", ) - cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type",] + df["node"] = None + + cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type", "node"] cols += [col for col in df.columns if col.startswith("property")] + cols += ["_id"] logger.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") - return df[cols] + return df[cols].reset_index(drop=True) def on_database_deleted(self, db_name: str): """ @@ -187,23 +243,19 @@ def on_database_deleted(self, db_name: str): if db_name == self.database.name: self.deleteLater() - def search_error(self, reset=False): + def on_mode_switch(self, check: Qt.CheckState): """ - Handles the search error by changing the search bar color. + Handles the mode switch between simple and detailed view. Args: - reset (bool, optional): Whether to reset the search bar color. Defaults to False. + check (Qt.CheckState): The check state of the toggle. """ - if reset: - self.search.setPalette(app.application.palette()) - return + self.simple = check == Qt.CheckState.Unchecked + self.update_table_style() + self.update_column_visibility() - palette = self.search.palette() - palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(255, 128, 128)) - self.search.setPalette(palette) - -class ProductView(ui.widgets.ABNewTreeView): +class ProductView(ui.widgets.ABTreeView): """ A view that displays the products in a tree structure. @@ -214,6 +266,7 @@ class ProductView(ui.widgets.ABNewTreeView): "categories": delegates.ListDelegate, "key": delegates.StringDelegate, "processor": delegates.StringDelegate, + "node": delegates.CardDelegate, } class ContextMenu(ui.widgets.ABMenu): @@ -274,13 +327,16 @@ def __init__(self, parent: DatabaseProductsPane, db_name: str): super().__init__(parent) self.setSortingEnabled(True) self.setDragEnabled(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragOnly) + self.setAcceptDrops(True) + self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragDrop) self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) self.db_name = db_name + self.pane = parent self.propertyDelegate = delegates.PropertyDelegate(self) + self.overlay = None def setDefaultColumnDelegates(self): """ @@ -305,6 +361,107 @@ def mouseDoubleClickEvent(self, event) -> None: if self.selected_activities: app.actions.ActivityOpen.run(self.selected_activities) + def keyPressEvent(self, event) -> None: + """ + Handles key press events. Specifically handles Ctrl+C to copy selected data. + + Args: + event: The key press event. + """ + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + if event.key() == Qt.Key.Key_C: # Copy + self.copy_selection_to_clipboard() + return + if event.key() == Qt.Key.Key_V: + self.copy_from_clipboard() + if event.key() == Qt.Key.Key_A: # Select All + self.selectAll() + return + if event.key() == Qt.Key.Key_F: # Find + self.pane.search_bar.setFocus() + return + if event.key() == Qt.Key.Key_Delete: + if database_is_locked(self.db_name): + return + if self.selected_products: + app.actions.ActivityDelete.run(self.selected_products) + return + + super().keyPressEvent(event) + + def copy_selection_to_clipboard(self): + selection = self.selectedIndexes() + mime_data = self.model().mimeData(selection) + + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setMimeData(mime_data) + + def copy_from_clipboard(self): + if database_is_locked(self.db_name): + return + + clipboard = QtWidgets.QApplication.clipboard() + mime_data = clipboard.mimeData() + + if mime_data.hasFormat("application/bw-nodekeylist"): + keys: list = mime_data.retrievePickleData("application/bw-nodekeylist") + keys = list(set(keys)) + + app.actions.ActivityDuplicateToDB.run(keys, self.db_name) + + def dragEnterEvent(self, event): + """ + Handles the drag enter event. + + Args: + event: The drag enter event. + """ + if event.source() == self: + return + + if database_is_locked(self.db_name): + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + + if any(is_node_biosphere(key) for key in keys): + return + + self.overlay = widgets.ABDropOverlay(self, text="Drop here to duplicate to this database") + self.overlay.show() + event.accept() + + def dragMoveEvent(self, event): + pass + + def dragLeaveEvent(self, event): + """ + Handles the drag leave event. + + Args: + event: The drag leave event. + """ + if self.overlay: + self.overlay.deleteLater() + + def dropEvent(self, event): + """ + Handles the drop event. + + Args: + event: The drop event. + """ + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + # Reset the palette on drop + if self.overlay: + self.overlay.deleteLater() + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + keys = list(set(keys)) + + app.actions.ActivityDuplicateToDB.run(keys, self.db_name) + @property def selected_products(self) -> list[tuple]: """ @@ -336,23 +493,73 @@ class ProductModel(ui.core.ABTreeModel): #-- flag overrides --- def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: return True - - #-- data overrides --- + + # -- data overrides --- + def displayData(self, index: QModelIndex) -> any: + column_name = self.column_name(index) + if column_name != "node": + return super().displayData(index) + + row = self.row(index) + + if row is None: + return None + + # Get the product or name for title + title = row.get("product") or row.get("name") + + # Build subtitle with name (if product exists) or type + subtitle_parts = [] + if row.get("product") and row.get("name"): + # If there's both product and name, show name as subtitle + subtitle_parts.append(row.get("name")) + elif row.get("type"): + # Otherwise show type + subtitle_parts.append(row.get("type").capitalize()) + + subtitle = " | ".join(subtitle_parts) if subtitle_parts else None + + # Build categories list from unit, location, database + categories = [] + if row.get("unit"): + categories.append(str(row.get("unit"))) + if row.get("location"): + categories.append(str(row.get("location"))) + if row.get("key") and isinstance(row.get("key"), tuple): + categories.append(str(row.get("key")[0])) # database name + + # Add actual categories if they exist + node_categories = row.get("categories") + if node_categories and isinstance(node_categories, (list, tuple)): + categories.extend([str(cat) for cat in node_categories if str(cat).strip()]) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + } + def decorationData(self, index: QtCore.QModelIndex) -> any: column_name = self.column_name(index) node_type = self.get(index, "type") - if column_name not in ["name", "product"]: + if column_name not in ["name", "product", "node"]: return None - if column_name == "product" and node_type in ["product", "processwithreferenceproduct"]: - return icons.qicons.product - if column_name == "product" and node_type == "waste": - return icons.qicons.waste - if node_type == "processwithreferenceproduct": + + if column_name == "name" and node_type in ["product", "waste"]: + return icons.qicons.process + if column_name in ["name", "node"] and node_type == "processwithreferenceproduct": return icons.qicons.processproduct - if node_type in NODETYPES["biosphere"]: + if column_name in ["name", "node"] and node_type in NODETYPES["biosphere"]: return icons.qicons.biosphere - return icons.qicons.process + if column_name == "name": + return icons.qicons.empty + + if column_name in ["product", "node"] and node_type in ["product", "processwithreferenceproduct"]: + return icons.qicons.product + if column_name in ["product", "node"] and node_type == "waste": + return icons.qicons.waste + return icons.qicons.empty def toolTipData(self, index: QtCore.QModelIndex) -> str: column_name = self.column_name(index) @@ -385,4 +592,17 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): keys.update(self.values_from_indices("processor", indices)) keys = {key for key in keys if isinstance(key, tuple)} data.setPickleData("application/bw-nodekeylist", list(keys)) + + # Add HTML data for Excel with bold formatting + thread = threading.Thread(target=self.set_excel_nodes_threaded, args=(data, keys)) + thread.start() + return data + + @staticmethod + def set_excel_nodes_threaded(data, keys): + excel_string = nodes_to_excel(list(keys)) + try: + data.setHtml(excel_string) + except RuntimeError: + pass diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 1dd29e450..20a379450 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -1,4 +1,5 @@ import datetime +from loguru import logger from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt @@ -6,7 +7,7 @@ import bw2data as bd import pandas as pd -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import count_database_records from activity_browser.ui import widgets, icons, delegates, core from activity_browser.app.menu_bar import ImportDatabaseMenu @@ -46,7 +47,7 @@ def connect_signals(self): Connects the signals to the appropriate slots. """ app.signals.meta.databases_changed.connect(self.sync) - app.signals.project.changed.connect(self.sync) + app.signals.metadata.synced.connect(self.sync) app.signals.database.deleted.connect(self.sync) app.signals.database_read_only_changed.connect(self.sync) @@ -59,12 +60,14 @@ def build_layout(self): layout.setContentsMargins(5, 0, 5, 5) self.setLayout(layout) + @QtCore.Slot() def sync(self): """ Synchronizes the model with the current state of the databases. """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + df = self.build_df() - df.reset_index(drop=True, inplace=True) self.model.set_dataframe(df) self.view.resizeColumnToContents(1) self.view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) @@ -100,7 +103,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class DatabasesView(widgets.ABNewTreeView): +class DatabasesView(widgets.ABTreeView): """ A view that displays the databases in a tree structure. @@ -136,6 +139,7 @@ class ContextMenu(widgets.ABMenu): ), lambda m, p: m.add(app.actions.DatabaseDuplicate, p.selected_databases[0] if p.selected_databases else None, enable=len(p.selected_databases) == 1), + lambda m, p: m.add(app.actions.DatabaseRelink, p.selected_databases[0] if p.selected_databases else None), lambda m, p: m.add(app.actions.DatabaseProcess, p.selected_databases[0] if p.selected_databases else None, enable=len(p.selected_databases) == 1), lambda m: m.addSeparator(), diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 5089f0721..f909a8581 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -1,5 +1,5 @@ -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt +from qtpy import QtWidgets, QtCore +from loguru import logger import bw2data as bd import pandas as pd @@ -31,7 +31,6 @@ def __init__(self, parent=None): self.build_layout() self.connect_signals() - self.load() def build_layout(self): layout = QtWidgets.QVBoxLayout() @@ -43,22 +42,13 @@ def build_layout(self): def connect_signals(self): app.signals.meta.methods_changed.connect(self.sync) - app.signals.project.changed.connect(self.sync) app.signals.database_read_only_changed.connect(self.sync) - def load(self): - df = self.build_df() - self.model.set_dataframe(df) - self.model.group(["_method_name"]) - # self.view.setColumnHidden(1, True) - # self.view.setColumnHidden(2, True) - # self.view.setColumnHidden(3, True) - # self.view.sortByColumn(1, Qt.SortOrder.AscendingOrder) - def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + df = self.build_df() - self.model.set_dataframe(df) - self.model.group(["_method_name"]) + self.model.set_dataframe(df, group=["_method_name"]) def build_df(self): df = pd.DataFrame(bd.methods.values()) @@ -74,7 +64,7 @@ def build_df(self): return df[cols] -class ImpactCategoriesView(widgets.ABNewTreeView): +class ImpactCategoriesView(widgets.ABTreeView): defaultColumnDelegates = { "groups": delegates.ListDelegate, } diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py index 4e8aeadac..9ead8c0ac 100644 --- a/activity_browser/app/signalling.py +++ b/activity_browser/app/signalling.py @@ -1,7 +1,7 @@ from loguru import logger from time import time -from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer +from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer, QEvent from blinker import signal as blinker_signal @@ -64,15 +64,13 @@ def __init__(self, parent=None): self._flusher.start() def _flush_metadata(self): - if not (self._metadata._added or self._metadata._updated or self._metadata._deleted): + added, updated, deleted = self._metadata.flush_mutations() + + if not (added or updated or deleted): return t = time() - self.synced.emit(self._metadata._added, self._metadata._updated, self._metadata._deleted) - - self._metadata._added.clear() - self._metadata._updated.clear() - self._metadata._deleted.clear() + self.synced.emit(added, updated, deleted) logger.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") diff --git a/activity_browser/bwutils/README.md b/activity_browser/bwutils/README.md new file mode 100644 index 000000000..6410b6955 --- /dev/null +++ b/activity_browser/bwutils/README.md @@ -0,0 +1,59 @@ +# bwutils + +Utility functions and helpers that extend and build upon Brightway2 functionality. + +## Overview + +This module provides a collection of generic methods and utilities that wrap and extend Brightway2 operations. These utilities are used throughout the Activity Browser to avoid code duplication and provide consistent interfaces to Brightway2 functionality. + +## Directory Structure + +- **`ecoinvent_biosphere_versions/`** - Ecoinvent biosphere database version mappings +- **`io/`** - Import/export operations for data interchange +- **`metadata/`** - Metadata management for activities and databases +- **`searchengine/`** - Search functionality for activities and exchanges +- **`superstructure/`** - Superstructure scenario analysis tools + +## Key Files + +- **`commontasks.py`** - Common Brightway2 operations (database management, activity operations) +- **`errors.py`** - Custom exception classes for Brightway2 operations +- **`exporters.py`** - Export functionality for databases and activities +- **`importers.py`** - Import functionality for various LCA data formats +- **`filesystem.py`** - File system operations for Brightway2 data directories +- **`manager.py`** - High-level management of Brightway2 projects and databases +- **`montecarlo.py`** - Monte Carlo simulation helpers +- **`multilca.py`** - Multi-functional LCA calculation utilities +- **`pedigree.py`** - Pedigree matrix uncertainty handling +- **`sensitivity_analysis.py`** - Global sensitivity analysis tools +- **`settings.py`** - Settings specific to bwutils operations +- **`strategies.py`** - Import strategies and data transformation functions +- **`uncertainty.py`** - Uncertainty analysis utilities +- **`utils.py`** - General utility functions + +## Purpose + +The bwutils module serves as an abstraction layer between the Activity Browser UI and Brightway2, providing: + +1. **Consistency** - Standardized interfaces for common operations +2. **Error Handling** - Graceful handling of Brightway2 exceptions +3. **Extensions** - Additional functionality not provided by Brightway2 +4. **Integration** - Bridging between Qt UI and Brightway2 data structures + +## Usage Pattern + +Import utilities as needed throughout the application: + +```python +from activity_browser.bwutils import commontasks +from activity_browser.bwutils.errors import ABError +from activity_browser.bwutils.manager import ABManager +``` + +## Design Principle + +Keep utilities generic and reusable. These functions should: +- Work with Brightway2 data structures +- Be independent of UI components +- Be testable without requiring a GUI +- Emit signals when state changes (via `activity_browser.app.signals`) diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 0252e16c7..1440070f7 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -527,4 +527,27 @@ def get_templates() -> dict: if file.endswith(".tar.gz"): collection[file[:-7]] = os.path.join(template_dir, file) - return collection \ No newline at end of file + return collection + +def nodes_to_excel(nodes: list[tuple | int | bd.Node]) -> str: + """Convert a list of nodes to an HTML table suitable for Excel.""" + from .exporters import ABCSVFormatter + nodes = [refresh_node(n) for n in nodes] + databases = set(n["database"] for n in nodes) + if len(databases) > 1: + raise ValueError("All nodes must be from the same database") + db_name = databases.pop() + formatter = ABCSVFormatter(db_name, nodes) + data = formatter.get_formatted_data(sections=["activities", "exchanges"]) + + html_rows = [] + for row in data: + if isinstance(row, list): + # Bold formatting for lists with nowrap + cells = "".join(f'{str(i)}' for i in row) + else: + # Regular formatting for tuples with nowrap + cells = "".join(f'{str(i)}' for i in row) + html_rows.append(f"{cells}") + + return f"{''.join(html_rows)}
" diff --git a/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md b/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md new file mode 100644 index 000000000..0370ec8b7 --- /dev/null +++ b/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md @@ -0,0 +1,138 @@ +# ecoinvent_biosphere_versions + +Ecoinvent biosphere database version mappings and compatibility information. + +## Overview + +This directory manages compatibility between different versions of ecoinvent databases and their corresponding biosphere flows. It ensures that biosphere flows are correctly linked when importing ecoinvent databases. + +## Key Files + +- **`compatible_ei_versions.txt`** - List of compatible ecoinvent versions +- **`ecospold2biosphereimporter.py`** - Custom importer for ecospold2 biosphere flows +- **`legacy_biosphere/`** - Legacy biosphere flow definitions for older ecoinvent versions + +## Purpose + +Ecoinvent databases come in different versions (e.g., 3.6, 3.7, 3.8, 3.9), and each version may have: +- Different biosphere flow definitions +- Updated flow names or properties +- New or deprecated flows +- Different CAS numbers or UUIDs + +This module ensures: +- **Correct linking** - Activities link to the right biosphere flows +- **Version compatibility** - Handle differences between ecoinvent versions +- **Migration support** - Update flows when upgrading ecoinvent versions +- **Legacy support** - Work with older databases + +## Compatible Versions + +The `compatible_ei_versions.txt` file lists ecoinvent versions that Activity Browser supports. Typically includes: +- ecoinvent 3.5 +- ecoinvent 3.6 +- ecoinvent 3.7 +- ecoinvent 3.8 +- ecoinvent 3.9 +- ecoinvent 3.10 + +## Biosphere Flow Linking + +When importing an ecoinvent database: + +1. **Detect version** - Identify ecoinvent version from metadata +2. **Load biosphere** - Use appropriate biosphere flow set +3. **Link flows** - Match elementary flows to biosphere database +4. **Handle mismatches** - Resolve or report linking issues + +## ecospold2biosphereimporter.py + +Custom importer that: +- Extends brightway2-io's ecospold2 importer +- Handles version-specific biosphere flows +- Applies migration strategies +- Fixes known issues in ecoinvent data + +## Legacy Biosphere + +The `legacy_biosphere/` directory contains: +- Flow definitions from older ecoinvent versions +- Migration mappings between versions +- Deprecated flow information +- Compatibility patches + +## Usage Pattern + +Typically used automatically during import: + +```python +from activity_browser.bwutils.importers import import_ecoinvent + +# Import will automatically handle biosphere version +import_ecoinvent( + filepath="ecoinvent_38_cutoff.ecospold", + database_name="ecoinvent 3.8" +) +``` + +## Version Detection + +Ecoinvent version is detected from: +- File metadata in ecospold files +- Database description field +- Version field in activity metadata +- Directory/file naming patterns + +## Handling Version Mismatches + +When biosphere versions don't match: + +1. **Automatic migration** - Update flow references +2. **Manual linking** - User selects correct flows +3. **Warning messages** - Inform user of issues +4. **Fallback matching** - Use fuzzy matching as last resort + +## Development Guidelines + +When adding support for new ecoinvent versions: + +1. **Update compatible versions** - Add to `compatible_ei_versions.txt` +2. **Test import** - Verify all flows link correctly +3. **Document changes** - Note any flow changes from previous version +4. **Add migrations** - If flows changed, add migration strategies +5. **Update tests** - Add test for new version + +## Common Issues + +### Unlinked Flows +If flows don't link: +- Check ecoinvent version detection +- Verify biosphere database version +- Review flow names for changes +- Check for typos or encoding issues + +### Wrong Flow Versions +If using wrong flow set: +- Verify version detection logic +- Check metadata parsing +- Update version mapping + +### Missing Flows +If flows are missing: +- Check if flows were added in newer ecoinvent version +- Verify biosphere database is up-to-date +- Add manual definitions if needed + +## Maintenance + +Keep up-to-date with: +- New ecoinvent releases +- Biosphere flow changes +- Brightway2 updates +- User-reported issues + +## Resources + +- [ecoinvent website](https://ecoinvent.org/) +- [ecoinvent version history](https://ecoinvent.org/the-ecoinvent-database/data-releases/) +- [brightway2-io documentation](https://docs.brightway.dev/projects/brightway2-io/) diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py index 30370872a..6fec07a8e 100644 --- a/activity_browser/bwutils/filesystem.py +++ b/activity_browser/bwutils/filesystem.py @@ -5,7 +5,7 @@ def get_package_path() -> Path: - path = Path(__file__).resolve().parents[2] + path = Path(__file__).resolve().parents[1] path.mkdir(parents=True, exist_ok=True) return path @@ -15,11 +15,11 @@ def get_appdata_path() -> Path: return path def get_project_path() -> Path: - path = bd.projects._base_data_dir + path = bd.projects.dir path.mkdir(parents=True, exist_ok=True) return path def get_project_ab_path() -> Path: - path = Path(bd.projects._base_data_dir) / "activity_browser" + path = Path(bd.projects.dir) / "activity_browser" path.mkdir(parents=True, exist_ok=True) return path diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index 7eb428773..69dbb49ea 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -25,7 +25,7 @@ from .strategies import (alter_database_name, csv_rewrite_product_key, hash_parameter_group, link_exchanges_without_db, relink_exchanges_bw2package, relink_exchanges_with_db, - rename_db_bw2package, parse_JSON_fields) + rename_db_bw2package, parse_JSON_fields, metadatastore_link, alter_exchange_database_name) class ABExcelImporter(ExcelImporter): @@ -126,6 +126,7 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: excs = [exc for exc in self.unlinked][:10] databases = {exc.get("database", "(name missing)") for exc in self.unlinked} raise StrategyError(excs, databases) + if self.project_parameters: self.write_project_parameters(delete_existing=False) db = self.write_database(delete_existing=True, activate_parameters=True) @@ -133,6 +134,41 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: bd.parameters.recalculate() return [db] + def apply_basic_strategies(self): + self.apply_strategies([ + csv_restore_tuples, + csv_restore_booleans, + csv_numerize, + csv_drop_unknown, + csv_add_missing_exchanges_section, + csv_rewrite_product_key, + normalize_units, + normalize_biosphere_categories, + normalize_biosphere_names, + strip_biosphere_exc_locations, + set_code_by_activity_hash, + drop_falsey_uncertainty_fields_but_keep_zeros, + convert_uncertainty_types_to_integers, + hash_parameter_group, + convert_activity_parameters_to_list, + parse_JSON_fields, + ]) + + def apply_db_name(self, db_name: str): + """Apply a database name change strategy.""" + self.apply_strategy( + functools.partial(alter_database_name, old=self.db_name, new=db_name) + ) + self.db_name = db_name + + def apply_linking(self, relink: dict): + self.apply_strategies([ + link_technosphere_by_activity_hash, # internal linking + functools.partial(alter_exchange_database_name, linking_dict=relink), # change db names + metadatastore_link, # link using metadatastore + ]) + + def apply_strategies(self, strategies=None, verbose=False): strategies = strategies or self.strategies for strategy in tqdm.tqdm(strategies, desc="Applying strategies", total=len(strategies)): diff --git a/activity_browser/bwutils/io/README.md b/activity_browser/bwutils/io/README.md new file mode 100644 index 000000000..75814e1e0 --- /dev/null +++ b/activity_browser/bwutils/io/README.md @@ -0,0 +1,116 @@ +# io + +Import and export operations for LCA data interchange. + +## Overview + +This directory handles import and export operations for various LCA data formats, enabling data exchange between Activity Browser, Brightway2, and other LCA tools. + +## Purpose + +The io module provides: +- **Import** - Bring data from external sources into Brightway2 +- **Export** - Save Brightway2 data to various formats +- **Conversion** - Transform between different LCA data formats +- **Validation** - Check data integrity during import/export + +## Supported Formats + +### Import Formats +- **ecospold1/2** - Ecoinvent XML formats +- **SimaPro CSV** - SimaPro export format +- **Excel** - Custom Excel templates +- **JSON-LD** - Linked data format +- **ILCD** - International Reference Life Cycle Data System +- **Brightway2 packages** - BW2Package format + +### Export Formats +- **Excel** - Various Excel export templates +- **CSV** - Comma-separated values +- **Brightway2 packages** - For backup and sharing +- **SimaPro CSV** - For use in SimaPro + +## Architecture + +Import/export operations typically follow this pattern: + +1. **Selection** - User selects file(s) to import or export location +2. **Configuration** - Set options (database name, linking strategies, etc.) +3. **Processing** - Parse/transform data (often in a worker thread) +4. **Validation** - Check for errors or warnings +5. **Completion** - Write to database or save to file +6. **Feedback** - Report success, errors, or warnings to user + +## Threading + +Import/export operations use worker threads to avoid blocking the UI: + +```python +from activity_browser.ui.core.threading import ABThread + +worker = ABThread(import_function, args) +worker.finished.connect(on_complete) +worker.start() +``` + +## Error Handling + +Robust error handling is critical: +- Validate data before processing +- Provide clear error messages +- Allow partial success when possible +- Log errors for debugging +- Don't lose user data on failure + +## Usage Pattern + +```python +from activity_browser.bwutils.io import import_ecospold2 + +# Import with progress tracking +result = import_ecospold2( + filepath="data.ecospold", + database_name="my_database", + progress_callback=update_progress +) +``` + +## Integration with Actions + +Import/export is typically triggered via actions: + +```python +from activity_browser.app.actions.base import ABAction + +class ImportEcospold(ABAction): + @staticmethod + def run(): + # File selection dialog + filepath = get_file_path() + # Import in background thread + import_data(filepath) +``` + +## Development Guidelines + +When adding new import/export functionality: + +1. **Use worker threads** - Don't block the UI +2. **Provide progress updates** - Keep user informed +3. **Validate data** - Check before committing +4. **Handle errors gracefully** - Give helpful error messages +5. **Support cancellation** - Allow user to abort long operations +6. **Log operations** - Help with debugging +7. **Test with real data** - Use actual LCA databases for testing +8. **Document format specifics** - Note any format peculiarities or limitations + +## Strategies + +Import operations often use strategies to link exchanges: +- Match by name and location +- Match by code/UUID +- Match by CAS number +- Fuzzy matching +- Manual linking fallback + +See `bwutils/strategies.py` for strategy implementations. diff --git a/activity_browser/bwutils/metadata/README.md b/activity_browser/bwutils/metadata/README.md new file mode 100644 index 000000000..ea3d2e2b2 --- /dev/null +++ b/activity_browser/bwutils/metadata/README.md @@ -0,0 +1,141 @@ +# metadata + +Metadata management for activities, databases, and methods. + +## Overview + +This directory handles storage, retrieval, and management of metadata associated with LCA data in Activity Browser. Metadata provides additional context and information beyond what Brightway2 stores natively. + +## Purpose + +Metadata management provides: +- **Extended information** - Additional fields beyond Brightway2 schema +- **User annotations** - Comments, tags, custom fields +- **Workflow tracking** - Modification history, authorship +- **Search enhancement** - Additional searchable attributes +- **Classification** - Custom categorization schemes + +## Metadata Types + +### Activity Metadata +- Custom descriptions +- Data quality assessments +- Pedigree matrices +- User comments +- Modification timestamps +- Authorship information + +### Database Metadata +- Database descriptions +- Source information +- Version tracking +- Import history +- Licensing information + +### Method Metadata +- Method descriptions +- Methodological choices +- References and sources +- Uncertainty information + +## Storage + +Metadata is stored separately from Brightway2's native storage: +- JSON files in user data directory +- Keyed by activity/database/method identifiers +- Persisted across sessions +- Backed up with projects + +## MetaDataStore + +The `MetaDataStore` class (see `bwutils/metadata/`) provides centralized metadata access: + +```python +from activity_browser import app + +# Access metadata store +metadata = app.metadata + +# Get activity metadata +meta = metadata.get_activity_metadata(activity_key) + +# Update metadata +metadata.update_activity_metadata(activity_key, {"comment": "..."}) +``` + +## Usage Pattern + +### Reading Metadata +```python +meta = metadata.get_metadata(item_key) +comment = meta.get("comment", "") +``` + +### Writing Metadata +```python +metadata.update_metadata(item_key, { + "comment": "Updated description", + "modified": datetime.now().isoformat(), + "author": "user@example.com" +}) +``` + +### Searching Metadata +```python +results = metadata.search(query="renewable energy") +``` + +## Signal Integration + +Metadata changes emit signals: + +```python +from activity_browser import app + +app.signals.metadata_changed.emit(item_key) +``` + +Other components can listen and update their displays accordingly. + +## Development Guidelines + +When working with metadata: + +1. **Use the MetaDataStore** - Don't create separate storage +2. **Emit signals** - Notify when metadata changes +3. **Validate schemas** - Ensure metadata structure is consistent +4. **Handle missing data** - Provide sensible defaults +5. **Consider performance** - Cache frequently accessed metadata +6. **Backup regularly** - Metadata is user-created content +7. **Version metadata format** - Support migration if schema changes + +## Data Structure + +Typical metadata structure: + +```json +{ + "comment": "User-provided description", + "tags": ["renewable", "electricity"], + "data_quality": { + "reliability": 3, + "completeness": 4, + "temporal_correlation": 2 + }, + "modified": "2025-12-10T10:30:00", + "author": "user@example.com", + "custom_fields": { + "project_code": "ABC123" + } +} +``` + +## Integration with UI + +Metadata is displayed and edited through: +- Activity details page +- Database properties dialog +- Method information panel +- Custom metadata editor dialogs + +Users can add, edit, and delete metadata through these interfaces. diff --git a/activity_browser/bwutils/metadata/__init__.py b/activity_browser/bwutils/metadata/__init__.py index 6138067fb..a49f85c5e 100644 --- a/activity_browser/bwutils/metadata/__init__.py +++ b/activity_browser/bwutils/metadata/__init__.py @@ -1 +1,3 @@ -from .metadata import MetaDataStore \ No newline at end of file +from .metadata import MetaDataStore + +from . import fields diff --git a/activity_browser/bwutils/metadata/_sub_loader.py b/activity_browser/bwutils/metadata/_sub_loader.py deleted file mode 100644 index c3c71876a..000000000 --- a/activity_browser/bwutils/metadata/_sub_loader.py +++ /dev/null @@ -1,27 +0,0 @@ -import sqlite3 -import pandas as pd -import pickle -import sys - -def load(fp: str, database_name: str, fields: list[str]): - con = sqlite3.connect(fp) - sql = f"SELECT data FROM activitydataset WHERE database = '{database_name}'" - raw_df = pd.read_sql(sql, con) - con.close() - - df = pd.DataFrame([pickle.loads(x) for x in raw_df["data"]]) - if df.empty: - return df - - df["key"] = list(zip(df["database"], df["code"])) - df.index = pd.MultiIndex.from_tuples(df["key"], names=["database", "code"]) - df = df.reindex(columns=fields)[fields] - return df - -if __name__ == '__main__': - filepath = sys.argv[1] - database_name = sys.argv[2] - columns = sys.argv[3:] - df = load(filepath, database_name, columns) - - sys.stdout.buffer.write(pickle.dumps(df)) diff --git a/activity_browser/bwutils/metadata/fields.py b/activity_browser/bwutils/metadata/fields.py index 59b885928..8a7d6458a 100644 --- a/activity_browser/bwutils/metadata/fields.py +++ b/activity_browser/bwutils/metadata/fields.py @@ -1,25 +1,32 @@ primary_types = { "key": object, - "id": "int64", + "id": "Int64", "code": str, - "database": "category", - "location": "category", + "database": object, + "location": object, "name": str, "product": object, - "type": "category", + "type": object, } secondary_types = { "synonyms": object, - "unit": "category", - "CAS number": "category", + "unit": object, + "CAS number": object, "categories": object, "processor": object, - "allocation": "category", + "allocation": object, "allocation_factor": float, "properties": object, } + +search_engine_whitelist = [ + "id", "name", "synonyms", "unit", "key", "database", # generic + "CAS number", "categories", # biosphere specific + "product", "reference product", "classifications", "location", "properties" # activity specific + ] + all_types = {**primary_types, **secondary_types} primary = list(primary_types.keys()) secondary = list(secondary_types.keys()) -all = primary + secondary +all_fields = primary + secondary diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index cf1ae0490..7992aeef4 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,22 +1,26 @@ import sqlite3 import pickle -import threading +import os from multiprocessing import Pool from loguru import logger -from typing import Literal, Callable - +from typing import Literal import pandas as pd +from qtpy.QtCore import QObject, QThread, Signal, SignalInstance + from .metadata import MetaDataStore -from .fields import secondary_types, primary, secondary +from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields -class MDSLoader(): +class MDSLoader(QObject): primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" def __init__(self, mds: MetaDataStore): + super().__init__(parent=mds) + self.mds = mds + self.thread: QThread | None = None self.connect_signals() def connect_signals(self): @@ -32,21 +36,54 @@ def on_project_changed(self, sender): def load_project(self): import bw2data as bd from bw2data.backends import sqlite3_lci_db + # set statuses self.primary_status = "loading" self.secondary_status = "loading" + # check for valid cache and load from it if available + if self._has_cache(): + self.cache_load_project() + return + # start loading thread for secondary metadata - thread = SecondaryLoadThread( + self.thread = SecondaryLoadThread( databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath), - callback=self.secondary_load_project + parent=self, ) - thread.start() + self.thread.result.connect(self.secondary_load_project) + self.thread.start() # load primary metadata in the main thread self.primary_load_project() + def cache_load_project(self): + from activity_browser.bwutils import filesystem + + logger.debug("Loading metadata from cache") + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + cached_df = pd.read_pickle(cache_path) + + # quick sanity checks + if not self._cache_check(cached_df): + logger.info("Cache file is invalid or outdated, loading from database instead") + cache_path.unlink() + self.load_project() + return + + self.mds.dataframe = cached_df + + for idx in self.mds.dataframe.index: + self.mds.register_mutation(idx, "add") + + self.primary_status = "done" + self.secondary_status = "done" + + searcher_thread = InitSearcherThread(self.mds, parent=self) + searcher_thread.start() + def primary_load_project(self): from bw2data.backends import sqlite3_lci_db @@ -66,30 +103,43 @@ def primary_load_project(self): self.primary_status = "done" def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): + logger.debug("secondary_load_project") from bw2data.backends import sqlite3_lci_db if sqlite_db != str(sqlite3_lci_db._filepath): return - assert all(secondary_df.index.isin(self.mds.dataframe.index)) + assert all(secondary_df.index.isin(self.mds.keys)) logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") - self.mds.dataframe = pd.concat([self.mds.dataframe[primary], secondary_df], axis=1) + left = self.mds.get_metadata(columns=primary) + + self.mds.dataframe = pd.concat([left, secondary_df], axis=1) for idx in secondary_df.index: self.mds.register_mutation(idx, "update") self.secondary_status = "done" + searcher_thread = InitSearcherThread(self.mds, parent=self) + searcher_thread.start() + def load_database(self, database_name: str): from bw2data.backends import sqlite3_lci_db + self.primary_status = "loading" + self.secondary_status = "loading" + + if self.thread is not None and self.thread.isRunning(): + logger.debug("Waiting for previous loading thread to finish") + self.thread.wait() # start loading thread for secondary metadata - thread = SecondaryLoadThread( + self.thread = SecondaryLoadThread( databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath), - callback=self.secondary_load_database + parent=self, ) - thread.start() + self.thread.result.connect(self.secondary_load_database) + self.thread.start() # load primary metadata in the main thread self.primary_load_database(database_name) @@ -110,70 +160,165 @@ def primary_load_database(self, database_name: str): for idx in primary_df.index: self.mds.register_mutation(idx, "add") + self.primary_status = "done" + def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): from bw2data.backends import sqlite3_lci_db + logger.debug("Starting secondary metadata load database callback") if secondary_df.empty or sqlite_db != str(sqlite3_lci_db._filepath): + self.secondary_status = "done" return database = secondary_df.index[0][0] - indices = self.mds.dataframe.loc[[database]].index + indices = self.mds.get_database_metadata(database, []).index if not all(secondary_df.index.isin(indices)): logger.debug("Secondary database metadata dropping rows") secondary_df = secondary_df[secondary_df.index.isin(indices)] - logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to metadatastore {id(self.mds)}") - self._fix_categories(secondary_df) - self.mds.dataframe.update(secondary_df) + df = self.mds.dataframe + self._fix_categories(secondary_df, df) + df = secondary_df.combine_first(df) + self.mds.dataframe = df for idx in secondary_df.index: self.mds.register_mutation(idx, "update") + if self.mds.searcher is not None: + search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) + df = self.mds.get_database_metadata(database, search_engine_cols) + for col in df.select_dtypes(include=['category']).columns: + df[col] = df[col].astype(object) + self.mds.searcher.add_identifier(df) + + self.secondary_status = "done" + # utility functions - def _fix_categories(self, df: pd.DataFrame): + @staticmethod + def _fix_categories(df: pd.DataFrame, mds_df: pd.DataFrame): category_columns = [k for k, v in secondary_types.items() if v == "category"] for col in category_columns: categories = df[col].dropna().unique() - categories = [c for c in categories if c not in self.mds.dataframe[col].cat.categories] + categories = [c for c in categories if c not in mds_df[col].cat.categories] # add new category to column - self.mds.dataframe[col] = self.mds.dataframe[col].cat.add_categories(categories) + mds_df[col] = mds_df[col].cat.add_categories(categories) + + def _has_cache(self) -> bool: + from activity_browser.bwutils import filesystem + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + lci_path = filesystem.get_project_path() / "lci" / "databases.db" -class SecondaryLoadThread(threading.Thread): + if not cache_path.exists() or not lci_path.exists(): + return False + + cache_mtime = cache_path.stat().st_mtime + lci_mtime = lci_path.stat().st_mtime + + return cache_mtime >= lci_mtime + + def _cache_check(self, cached_df: pd.DataFrame) -> bool: + import bw2data as bd + from bw2data.backends import sqlite3_lci_db + + if not all(db in bd.databases for db in cached_df["database"].unique()): + logger.warning("Cache file contains databases not in the current Brightway project") + return False + + if not len(cached_df) == len(cached_df["id"].unique()): + logger.warning("Cache file contains duplicate IDs") + return False + + if cached_df.empty: + logger.warning("Cache file is empty") + return False + + with sqlite3.connect(sqlite3_lci_db._filepath) as con: + cursor = con.cursor() + cursor.execute("SELECT COUNT(*) FROM activitydataset") + count = cursor.fetchone()[0] + + if count != len(cached_df): + logger.warning("Cache file row count does not match database row count") + return False + + return True + + + +class InitSearcherThread(QThread): + """Thread for initializing the searcher.""" + + def __init__(self, mds: MetaDataStore, parent): + super().__init__(parent=parent) + self.mds = mds + + def run(self): + """Execute the searcher initialization in a background thread.""" + from .searcher import MDSSearcher + + if os.environ.get("AB_NO_SEARCHER"): + logger.debug("Skipping searcher initialization due to AB_NO_SEARCHER environment variable") + return + + if self.mds.searcher is not None: + old_searcher = self.mds.searcher + self.mds.searcher = None + + # Clear large data structures + if hasattr(old_searcher, 'df'): + del old_searcher.df + if hasattr(old_searcher, 'identifier_to_word'): + del old_searcher.identifier_to_word + if hasattr(old_searcher, 'word_to_identifier'): + del old_searcher.word_to_identifier + if hasattr(old_searcher, 'word_to_q_grams'): + del old_searcher.word_to_q_grams + if hasattr(old_searcher, 'q_gram_to_word'): + del old_searcher.q_gram_to_word + + del old_searcher + + self.mds.searcher = MDSSearcher(self.mds) + + +class SecondaryLoadThread(QThread): """Thread for loading secondary metadata using multiprocessing Pool.""" + result: SignalInstance = Signal(pd.DataFrame, str) - def __init__(self, databases: list[str], sqlite_db: str, callback: Callable): - super().__init__(daemon=True) + def __init__(self, databases: list[str], sqlite_db: str, parent): + super().__init__(parent=parent) self.databases = databases self.sqlite_db = sqlite_db - self.callback = callback - self.result_df = None def run(self): """Execute the loading in a background thread.""" try: - with Pool() as pool: - args = [(self.sqlite_db, db, secondary) for db in self.databases] - results = pool.starmap(load, args) + if len(self.databases) > 1: + logger.debug(f"Loading metadata from {len(self.databases)} databases using multiprocessing Pool") + with Pool() as pool: + args = [(self.sqlite_db, db, secondary) for db in self.databases] + results = pool.starmap(load, args) + else: + logger.debug("Loading metadata from a single database without multiprocessing") + results = [load(self.sqlite_db, db, secondary) for db in self.databases] full_df = pd.DataFrame() for df in results: if df is None or df.empty: continue full_df = pd.concat([full_df, df]) - - # Store result and call callback - self.result_df = full_df - self.callback(full_df, self.sqlite_db) except Exception as e: - logger.error(f"Error loading secondary metadata: {e}") - # Call callback with empty dataframe on error - self.callback(pd.DataFrame(), self.sqlite_db) + logger.error(f"Error loading secondary metadata: {e}", exc_info=True) + full_df = pd.DataFrame() + + self.result.emit(full_df, self.sqlite_db) def load(fp: str, database_name: str, fields: list[str]): diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index f3ecf0f75..1d8b92233 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,27 +1,32 @@ -from time import time +from typing import Literal, Optional from loguru import logger -from typing import Literal + +from qtpy.QtCore import QObject import pandas as pd -from .fields import all, all_types +from .fields import all_fields, all_types -class MetaDataStore(): +class MetaDataStore(QObject): + """Singleton class to manage metadata storage, loading, updating, and searching.""" _instance = None - def __new__(cls): + def __new__(cls, *args, **kwargs): if cls._instance is None: - cls._instance = super().__new__(cls) + cls._instance = super().__new__(cls, *args, **kwargs) cls._instance._initialized = False return cls._instance - def __init__(self): + def __init__(self, parent=None): from .loader import MDSLoader from .updater import MDSUpdater + from .searcher import MDSSearcher if self._initialized: return + self._initialized = True + super().__init__(parent=parent) self._dataframe = pd.DataFrame() @@ -31,8 +36,7 @@ def __init__(self): self.loader = MDSLoader(self) self.updater = MDSUpdater(self) - - self._initialized = True + self.searcher: MDSSearcher | None = None # initialized by the loader @property def dataframe(self) -> pd.DataFrame: @@ -40,15 +44,29 @@ def dataframe(self) -> pd.DataFrame: @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: - # Ensure all expected columns are present, in the correct order, and with the correct types - df = df.reindex(columns=all)[all].astype(all_types) + # Ensure all expected columns are present, in the correct order + df = df.reindex(columns=all_fields)[all_fields] + + # Apply types carefully - avoid in-place modifications + for col, col_type in all_types.items(): + if col in df.columns: + df[col] = df[col].astype(col_type) + + # No NaN values in object columns, use None instead + for col, col_type in all_types.items(): + if col_type != object or col not in df.columns: + continue + df[col] = df[col].where(df[col].notnull(), None) - # Set the internal dataframe self._dataframe = df @property def databases(self): - return set(self.dataframe.get("database", [])) + return set(self._dataframe.index.get_level_values(0).unique().tolist()) + + @property + def keys(self): + return set(self._dataframe.index.tolist()) def register_mutation(self, key: tuple[str, str], action: Literal["add", "update", "delete"]): if action == "add": @@ -69,30 +87,114 @@ def register_mutation(self, key: tuple[str, str], action: Literal["add", "update else: raise ValueError(f"Unknown action: {action}") + def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], set[tuple[str, str]]]: + from activity_browser.bwutils import filesystem + + if not (self._added or self._updated or self._deleted): + return set(), set(), set() + + added = self._added.copy() + updated = self._updated.copy() + deleted = self._deleted.copy() + + self._added.clear() + self._updated.clear() + self._deleted.clear() + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + self._dataframe.to_pickle(cache_path) + + return added, updated, deleted + def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. """ - df = self.dataframe.query( - " and ".join( - [ - f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" - for key, value in kwargs.items() - ]) - ) + with self._df_lock: + df = self._dataframe.query( + " and ".join( + [ + f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" + for key, value in kwargs.items() + ]) + ) return df - def get_metadata(self, keys: list, columns: list) -> pd.DataFrame: + def get_metadata(self, keys: list = None, columns: list = None) -> pd.DataFrame: """Return a slice of the dataframe matching row and column identifiers. NOTE: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#deprecate-loc-reindex-listlike From pandas version 1.0 and onwards, attempting to select a column with all NaN values will fail with a KeyError. """ - df = self.dataframe.loc[pd.IndexSlice[keys], :] + keys = keys if keys is not None else self._dataframe.index.tolist() + columns = columns if columns is not None else all_fields + + df = self._dataframe.loc[pd.IndexSlice[keys], :] return df.reindex(columns, axis="columns") def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: + columns = columns if columns is not None else all_fields + if db_name not in self.databases: - return pd.DataFrame(columns=all) - return self.dataframe.loc[[db_name], columns or all] + return pd.DataFrame(columns=columns or all_fields) + + df = self._dataframe.loc[[db_name], columns] + return df.reindex(columns, axis="columns") + + def search(self, query: str, columns: list = None) -> pd.DataFrame: + if not self.searcher: + logger.warning(f"Attempted to search metadata before searcher was initialized.") + return pd.DataFrame(columns=columns or all_fields) + + params, query = get_query_parameters(query) + result = self.searcher.search(query) + return self._meta_from_result(params, result, columns) + + def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: + if not self.searcher: + logger.warning(f"Attempted to search metadata before searcher was initialized.") + return pd.DataFrame(columns=columns or all_fields) + + params, query = get_query_parameters(query) + result = self.searcher.fuzzy_search(query, database=database) + return self._meta_from_result(params, result, columns) + + def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: + df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] + df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) + + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + + return df + + def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): + if not self.searcher: + logger.warning(f"Attempted to search metadata before searcher was initialized.") + return [] + + word = self.searcher.clean_text(word) + completions = self.searcher.auto_complete(word, context=context, database=database) + return completions + + +def get_query_parameters(query: str) -> tuple[dict[str, str], str]: + """Extract key-value pairs from a query string of the form 'key1:value1 key2:value2'.""" + params = {} + tokens = query.split() + clean_query = [] + for token in tokens: + if ':' in token: + key, value = token.split(':', 1) + params[key] = value + else: + clean_query.append(token) + return params, ' '.join(clean_query) diff --git a/activity_browser/bwutils/metadata/searcher.py b/activity_browser/bwutils/metadata/searcher.py new file mode 100644 index 000000000..0f3481849 --- /dev/null +++ b/activity_browser/bwutils/metadata/searcher.py @@ -0,0 +1,486 @@ +from itertools import permutations +from collections import Counter, OrderedDict +from logging import getLogger +from time import time +from typing import Optional + +import pandas as pd + +from activity_browser.bwutils.searchengine import SearchEngine + +from .metadata import MetaDataStore +from .fields import all_fields + +log = getLogger(__name__) + + +class MDSSearcher(SearchEngine): + + def __init__(self, mds: MetaDataStore): + self.mds = mds + super().__init__(self.mds.dataframe, "id", all_fields) + + # caching for faster operation + def database_id_manager(self, database): + if not hasattr(self, "all_database_ids"): + self.all_database_ids = {} + + if database_ids := self.all_database_ids.get(database): + self.database_ids = database_ids + self.current_database = database + elif database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + self.all_database_ids[database] = self.database_ids + self.current_database = database + else: + # When database is None, search across all databases + if all_ids := self.all_database_ids.get(None): + self.database_ids = all_ids + else: + self.database_ids = set(self.df.index.to_list()) + self.all_database_ids[None] = self.database_ids + self.current_database = None + return self.database_ids + + def reset_database_id_manager(self): + if hasattr(self, "all_database_ids"): + del self.all_database_ids + if hasattr(self, "database_ids"): + del self.database_ids + + def database_word_manager(self, database): + if not hasattr(self, "all_database_words"): + self.all_database_words = {} + + if database_words := self.all_database_words.get(database): + self.database_words = database_words + elif database is not None: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[database] = self.database_words + else: + # When database is None, search across all databases + if all_words := self.all_database_words.get(None): + self.database_words = all_words + else: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[None] = self.database_words + return self.database_words + + def reset_database_word_manager(self, database): + if hasattr(self, "all_database_words") and self.all_database_words.get(database): + del self.all_database_words[database] + if hasattr(self, "database_words"): + del self.database_words + + def database_search_cache(self, database, query, result=None): + if not hasattr(self, "search_cache"): + self.search_cache = {} + + if result: + if self.search_cache.get(database): + self.search_cache[database][query] = result + else: + self.search_cache[database] = {query: result} + return + if db_cache := self.search_cache.get(database): + if cached_result := db_cache.get(query): + return cached_result + return + + def reset_search_cache(self, database): + if hasattr(self, "search_cache") and self.search_cache.get(database): + del self.search_cache[database] + + def reset_all_caches(self, databases): + self.reset_database_id_manager() + for database in databases: + self.reset_database_word_manager(database) + self.reset_search_cache(database) + + def add_identifier(self, data: pd.DataFrame) -> None: + super().add_identifier(data) + self.reset_all_caches(data["database"].unique()) + + def remove_identifiers(self, identifiers, logging=True) -> None: + t = time() + + identifiers = set(identifiers) + current_identifiers = set(self.df.index.to_list()) + identifiers = identifiers | current_identifiers # only remove identifiers currently in the data + databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning + if len(identifiers) == 0: + return + + for identifier in identifiers: + super().remove_identifier(identifier, logging=False) + + if logging: + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") + self.reset_all_caches(databases) + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + super().change_identifier(identifier, data) + self.reset_all_caches(data["database"].unique()) + + def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: + """Based on spellchecker, make more useful for autocompletions + """ + + def word_to_identifier_to_word(check_word): + if len(context) == 0: + return 1 + multiplier = 1 + for identifier in self.word_to_identifier[check_word]: + for context_word in context: + for spell_checked_context_word in spell_checked_context[context_word]: + if spell_checked_context_word in self.identifier_to_word[identifier]: + multiplier += 1 + if context_word not in self.word_to_identifier.keys(): + continue + if context_word in self.identifier_to_word[identifier]: + multiplier += 4 + return multiplier + + # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 + count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 + + if len(word) <= 1: + return [] + + self.database_id_manager(database) + + if len(context) > 0: + spell_checked_context = {} + for context_word in context: + spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] + + matches_min = 2 # ideally we have at least this many alternatives + matches_max = 4 # ideally don't much more than this many matches + never_accept_this = 4 # values this edit distance or over always rejected + # or max 2/3 of len(word) if less than never_accept_this + never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) + + first_matches = Counter() + other_matches = {} + probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list + + # now, refine with edit distance + for row in possible_matches.itertuples(): + if word == row[1]: + continue + # find edit distance of same size strings + edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) + if len(row[1]) == 32 and edit_distance <= 1: + probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence + elif edit_distance == 0: + first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + elif edit_distance < never_accept_this and len(first_matches) < matches_min: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + else: + continue + + # add matches in correct order: + matches = [match for match, _ in first_matches.most_common()] + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(1, never_accept_this): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + matches = matches + [match for match, _ in probably_keys.most_common()] + return matches + + def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: + """Overwritten for extra database specific reduction of results. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + if isinstance(self.database_ids, set): + # DATABASE SPECIFIC now filter on whether word is in the database + in_db = False + for _id in self.word_to_identifier[word]: + if _id in self.database_ids: + in_db = True + break + else: + in_db = True + if in_db: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + if not return_all: + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + else: + min_q = 0 + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + t2 = time() + # add each word in search index if query_word in word + for word in self.database_words.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + if result := self.database_search_cache(self.current_database, word): + matched_identifiers[word] = result + continue + id_counter = matched_identifiers.get(word, Counter()) + for matched_word in matching_words: + weight = self.base_weight + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + self.database_search_cache(self.current_database, word, matched_identifiers[word]) + + return matched_identifiers + + def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, + logging: bool = True) -> list: + """Overwritten for extra database specific reduction of results. + + Args: + text: Search query string + database: Database name to search within. If None, searches across all databases. + return_counter: If True, return a Counter instead of a list + logging: If True, log search timing information + + Returns: + List of identifiers (or Counter if return_counter=True) matching the search. + """ + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + if database: + return self.df.loc[self.df["database"] == database].index.to_list() + return self.df.index.to_list() + + # DATABASE SPECIFIC get the set of ids that is in this database + self.database_id_manager(database) + self.database_word_manager(database) + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # DATABASE SPECIFIC ensure all identifiers are in the database + if isinstance(self.database_ids, set): + new_q2i = {} + for word, _ids in query_to_identifier.items(): + keep = set.intersection(set(_ids.keys()), self.database_ids) + new_id_counter = Counter() + for _id in keep: + new_id_counter[_id] = _ids[_id] + if len(new_id_counter) > 0: + new_q2i[word] = new_id_counter + query_to_identifier = new_q2i + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + query_perm_str = " ".join(query_perm) + if result := self.database_search_cache(self.current_database, query_perm_str): + new_ids = result + else: + mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + self.database_search_cache(self.current_database, query_perm_str, new_ids) + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + if return_counter: + return_this = all_identifiers + else: + # now sort on highest weights and make list type + return_this = [identifier[0] for identifier in all_identifiers.most_common()] + if logging: + log.debug( + f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return return_this + + def search(self, text, database: Optional[str] = None) -> list: + """Search the dataframe on this text, return a sorted list of identifiers. + + Args: + text: Search query string + database: Database name to search within. If None, searches across all databases. + + Returns: + List of identifiers matching the search, sorted by relevance. + """ + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # get the set of ids that is in this database + self.database_id_manager(database) + + fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) + if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in literal_id_set] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 907e25579..a0fff878f 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -3,14 +3,16 @@ import pandas as pd import numpy as np -from .metadata import MetaDataStore -from .fields import primary, secondary, all_types +from qtpy.QtCore import QObject +from .metadata import MetaDataStore +from .fields import primary, secondary, all_types, search_engine_whitelist +class MDSUpdater(QObject): -class MDSUpdater(): def __init__(self, mds: MetaDataStore): + super().__init__(parent=mds) self.mds = mds self.connect_signals() @@ -40,7 +42,7 @@ def on_signaleddataset_save(self, sender, old, new): if new.key in self.mds.dataframe.index and not all(node_data.dropna().eq(self.mds.dataframe.loc[new.key].dropna())): self.modify_node(node_data) - else: + elif new.key not in self.mds.dataframe.index: self.add_node(node_data) def on_signaleddataset_delete(self, sender, old): @@ -53,7 +55,7 @@ def on_signaleddataset_delete(self, sender, old): try: # Create a Series with the key to match the delete_node signature - ds = pd.Series({"key": old.key}, name=old.key) + ds = pd.Series({"key": old.key, "id": old.id}, name=old.key) self.delete_node(ds) except KeyError: pass @@ -77,19 +79,48 @@ def on_database_changed(self) -> None: # node methods def modify_node(self, ds: pd.Series): - self._fix_categories(ds) - self.mds.dataframe.loc[ds.key] = ds + df = self.mds.dataframe + self._fix_categories(ds, df) + df.loc[ds.key] = ds + + self.mds.dataframe = df self.mds.register_mutation(ds.key, "update") + if not hasattr(self.mds, "searcher") or self.mds.searcher is None: + return + + search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.change_identifier(identifier=ds["id"], data=data) + def add_node(self, ds: pd.Series): - self._fix_categories(ds) - self.mds.dataframe.loc[ds.key, :] = ds + + df = self.mds.dataframe + self._fix_categories(ds, df) + df.loc[ds.key, :] = ds + + self.mds.dataframe = df self.mds.register_mutation(ds.key, "add") + if self.mds.searcher is None: + return + + search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.add_identifier(data=data) + def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") + if self.mds.searcher is None: + return + + node_id = ds["id"] + + self.mds.searcher.remove_identifier(identifier=node_id) + self.mds.searcher.reset_all_caches(ds["database"]) + # database methods def add_database(self, db_name: str): self.mds.loader.load_database(db_name) @@ -101,10 +132,20 @@ def delete_database(self, db_name: str): for code in self.mds.dataframe.loc[db_name].index: self.mds.register_mutation((db_name, code), "delete") + ids = self.mds.get_database_metadata(db_name, ["id"])["id"].tolist() + self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) + if self.mds.searcher is None: + return + + for node_id in ids: + self.mds.searcher.remove_identifier(identifier=node_id) + self.mds.searcher.reset_all_caches(db_name) + # utility functions - def _fix_categories(self, ds: pd.Series): + @staticmethod + def _fix_categories(ds: pd.Series, mds_df: pd.DataFrame): for category_col in [k for k, v in all_types.items() if k in ds and v == "category"]: category = ds[category_col] @@ -112,12 +153,12 @@ def _fix_categories(self, ds: pd.Series): # cannot add NaN as a category continue - if category in self.mds.dataframe[category_col].cat.categories: + if category in mds_df[category_col].cat.categories: # category already exists continue # add new category to column - self.mds.dataframe[category_col] = self.mds.dataframe[category_col].cat.add_categories([category]) + mds_df[category_col] = mds_df[category_col].cat.add_categories([category]) diff --git a/activity_browser/bwutils/searchengine/README.md b/activity_browser/bwutils/searchengine/README.md new file mode 100644 index 000000000..b2c8accf5 --- /dev/null +++ b/activity_browser/bwutils/searchengine/README.md @@ -0,0 +1,179 @@ +# searchengine + +Search functionality for activities, exchanges, and other LCA data. + +## Overview + +This directory implements the search engine that enables users to quickly find activities, databases, methods, and other items across their LCA data. + +## Features + +### Full-Text Search +- Search across activity names +- Search in comments and descriptions +- Search in product/flow names +- Search in metadata fields + +### Filtered Search +- Filter by database +- Filter by location +- Filter by unit +- Filter by activity type + +### Advanced Search +- Boolean operators (AND, OR, NOT) +- Wildcard matching +- Regular expressions +- Field-specific queries + +### Fast Indexing +- Incremental index updates +- Background indexing +- Efficient data structures +- Cached results + +## Architecture + +The search engine consists of: + +1. **Indexer** - Builds searchable index from Brightway2 data +2. **Query Parser** - Parses user search queries +3. **Search Engine** - Performs actual search operations +4. **Result Ranker** - Orders results by relevance +5. **Cache** - Stores recent search results + +## Usage Pattern + +```python +from activity_browser.bwutils.searchengine import SearchEngine + +engine = SearchEngine() + +# Simple search +results = engine.search("electricity") + +# Filtered search +results = engine.search( + query="electricity", + database="ecoinvent", + location="CH" +) + +# Advanced search +results = engine.search("(wind OR solar) AND electricity") +``` + +## Index Management + +The search index is automatically maintained: +- Built on first use +- Updated when databases change +- Rebuilt when necessary +- Stored in user data directory + +### Triggering Updates +```python +from activity_browser import app + +# Index automatically updates on these signals: +app.signals.database_changed.emit() +app.signals.activity_modified.emit() +``` + +## Search Results + +Results include: +- Activity key (database, code) +- Activity name +- Product name +- Location +- Unit +- Relevance score +- Highlighted matches + +```python +for result in results: + print(f"{result['name']} ({result['location']})") + print(f"Score: {result['score']}") +``` + +## Performance Considerations + +### Optimization Strategies +- Index only relevant fields +- Use appropriate data structures (tries, inverted indexes) +- Cache frequent queries +- Limit result set size +- Lazy loading of full activity data + +### Threading +Search operations run in background threads: +```python +from activity_browser.ui.core.threading import ABThread + +worker = ABThread(engine.search, query) +worker.finished.connect(display_results) +worker.start() +``` + +## Search Syntax + +### Basic Search +``` +electricity +``` + +### Phrase Search +``` +"wind power" +``` + +### Boolean Operators +``` +wind AND electricity +solar OR wind +electricity NOT coal +``` + +### Field-Specific +``` +name:electricity location:CH unit:kWh +``` + +### Wildcards +``` +electr* # Prefix matching +*city # Suffix matching +el*city # Both +``` + +## Integration with UI + +Search is accessible via: +- Global search bar in toolbar +- Database browser filter +- Activity browser search +- Quick search dialogs +- Context menu search + +## Development Guidelines + +When working with search: + +1. **Index incrementally** - Update index, don't rebuild +2. **Run in background** - Don't block UI +3. **Limit results** - Provide pagination for large result sets +4. **Highlight matches** - Show why result matched +5. **Sort by relevance** - Put best matches first +6. **Support fuzzy matching** - Handle typos gracefully +7. **Cache wisely** - Balance memory vs. speed +8. **Profile performance** - Ensure searches complete quickly + +## Testing + +Test search with: +- Small and large databases +- Various query types +- Edge cases (special characters, unicode) +- Performance benchmarks +- Index rebuild scenarios diff --git a/activity_browser/bwutils/searchengine/__init__.py b/activity_browser/bwutils/searchengine/__init__.py new file mode 100644 index 000000000..a3ed1d8e1 --- /dev/null +++ b/activity_browser/bwutils/searchengine/__init__.py @@ -0,0 +1,2 @@ +from .base import SearchEngine +from .metadata_search import MetaDataSearchEngine diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py new file mode 100644 index 000000000..0145e428e --- /dev/null +++ b/activity_browser/bwutils/searchengine/base.py @@ -0,0 +1,779 @@ +import itertools +import functools +import re +from collections import Counter, OrderedDict, defaultdict +from typing import Iterable, Optional +from time import time + +from loguru import logger + +import pandas as pd +import numpy as np + + +class SearchEngine: + """ + A Search Engine class, takes a dataframe and makes it searchable. + + A search requires a string, and will return a list of unique identifiers in the dataframe. + There are three options for search: + SearchEngine.literal_search(): searches for exact matches of the search query + SearchEngine.fuzzy_search(): searches for approximate matches of search query, sorted by relevance + SearchEngine.search(): combines both of the above, literal matches are returned first, next all fuzzy results, + but subsets sorted by relevance. + It is recommended to always use searchEngine.search(), but the other options are there. + + Initialization takes: + df: Dataframe that needs to be searchable. + identifier_name: values in this column will be returned as search results, all values in this column need to be unique. + searchable_columns: these columns need to be searchable, if none are given, all columns will be made searchable. + + Updating data is possible as well: + add_identifier(): adds this identifier to the searchable data + remove_identifier(): removes this identifier from the searchable data + change_identifier(): changes this identifier (wrapper for remove_identifier and add_identifier) + + """ + + def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: list = []): + t = time() + logger.debug(f"SearchEngine initializing for {len(df)} items") + + # compile regex patterns for cleaning + self.SUB_END_PATTERN = re.compile(r"[,.\"'`)\[\]}\\/\-−_:;+…]+(?=\s|$)") # remove these from end of word + self.SUB_START_PATTERN = re.compile(r"(?:^|\s)[,.\"'`(\[{\\/\-−_:;+]+") # remove these from start of word + self.ONE_SPACE_PATTERN = re.compile(r"\s+") # remove these multiple whitespaces + + self.q = 2 # character length of q grams + self.base_weight = 10 # base weighting for sorting results + + if identifier_name not in df.columns: # make sure identifier col exist + raise NameError(f"Identifier column {identifier_name} not found in dataframe. Use an existing column name.") + if df[identifier_name].nunique() != df.shape[0]: # make sure identifiers are all unique + raise KeyError( + f"Identifier column {identifier_name} must only contain unique values. Found {df[identifier_name].nunique()} unique values for length {df.shape[0]}") + + self.identifier_name = identifier_name + + # ensure columns given actually exist + # always ensure "identifier" is present + if searchable_columns == []: + # if no list is given, assume all columns are searchable + self.columns = list(df.columns) + else: + # create subset of columns to be searchable, discard rest + self.columns = [col for col in searchable_columns if col in df.columns] + if self.identifier_name not in self.columns: # keep identifier col + self.columns.append(self.identifier_name) + df = df[self.columns] + # set the identifier column as index + df = df.set_index(self.identifier_name, drop=False) + + # convert all data to str + df = df.astype(str) + + # find the self.identifier_name column index and store as int + self.identifier_column = self.columns.index(self.identifier_name) + + # store all searchable column indices except the identifier + self.searchable_columns = [i for i in range(len(self.columns)) if i != self.identifier_column] + + # initialize search index dicts and update df + self.identifier_to_word = {} + self.word_to_identifier = {} + self.word_to_q_grams = {} + self.q_gram_to_word = {} + self.df = pd.DataFrame() + + self.update_index(df) + + logger.debug(f"SearchEngine Initialized in {time() - t:.2f} seconds") + + # +++ Utility functions + + def update_index(self, update_df: pd.DataFrame) -> None: + """Update search index dicts and the df.""" + + def update_dict(update_me: dict, new: dict) -> dict: + """Update a dict of counters with new dict of counters.""" + # set to empty set if we know update_me is empty, otherwise, find set intersection + update_keys = set() if len(update_me) == 0 else new.keys() & update_me.keys() + if len(update_keys) == 0: + new_data = new + else: + for update_key in update_keys: + update_me[update_key].update(new[update_key]) + new_data = {key: value for key, value in new.items() if key not in update_keys} + # finally add any completely new data + # update_me.update(new_data) + update_me = update_me | new_data + return update_me + + if len(update_df) == 0: + return + + t = time() + size_old = len(self.df) + # identifier to word and df + i2w, update_df = self.words_in_df(update_df) + self.identifier_to_word = update_dict(self.identifier_to_word, i2w) + self.df = pd.concat([self.df, update_df]) + # word to identifier + w2i = self.reverse_dict_many_to_one(i2w) + self.word_to_identifier = update_dict(self.word_to_identifier, w2i) + # word to q-gram + w2q = self.list_to_q_grams(w2i.keys()) + self.word_to_q_grams = update_dict(self.word_to_q_grams, w2q) + # q-gram to word + q2w = self.reverse_dict_many_to_one(w2q) + self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) + size_new = len(self.df) + size_dif = size_new - size_old + logger.debug(f"Search index updated in {time() - t:.2f} seconds.") + + def clean_text(self, text: str): + """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" + text = text.lower() + text = self.SUB_END_PATTERN.sub("", text) + text = self.SUB_START_PATTERN.sub(" ", text) + + text = self.ONE_SPACE_PATTERN.sub(" ", text).strip() + return text + + def text_to_positional_q_gram(self, text: str) -> list: + """Return a positional list of q-grams for the given string. + + q-grams are n-grams on character level. + q-grams at q=2 of "word" would be "wo", "or" and "rd" + https://en.wikipedia.org/wiki/N-gram + + Note: these are technically _positional_ q-grams, but we don't use their positions currently. + """ + q = self.q + n = len(text) + # just return a single-item list if the text is equal or shorter than q + # else, generate q-grams + if n <= q: + return [text] + return list(text[i:i + q] for i in range(n - q + 1)) + + def df_clean(self, df): + """Clean the text in query_col. + + apply multi-processing when the computer is able and its relevant + """ + df["query_col"] = df["query_col"].apply(self.clean_text) + return df + + def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: + """Return a dict of {identifier: word} for df.""" + + df = df if df is not None else self.df.copy() + df = df.fillna("") # avoid nan + # assemble query_col + df["query_col"] = df.iloc[:, self.searchable_columns].astype(str).agg(" | ".join, axis=1) + # clean all text at once using vectorized operations + df["query_col"] = self.df_clean(df.loc[:, ["query_col"]]) + # build the identifier_word_dict dictionary - filter out empty strings + identifier_word_dict = df["query_col"].apply( + lambda text: Counter(word for word in text.split(" ") if word) + ).to_dict() + return identifier_word_dict, df + + def reverse_dict_many_to_one(self, dictionary: dict) -> dict: + """Reverse a dictionary of Counter objects.""" + reverse = defaultdict(Counter) + for identifier, counter_object in dictionary.items(): + if not isinstance(counter_object, Counter): + logger.warning(f"Skipping non-Counter object for {identifier}: {type(counter_object)}") + continue + for countable, count in counter_object.items(): + if countable: # skip empty strings + reverse[countable][identifier] += count + return dict(reverse) + + def list_to_q_grams(self, word_list: Iterable) -> dict: + """Convert a list of unique words to a dict with Counter objects. + + Number will be the occurrences of that q-gram in that word. + + return = { + "word": Counter( + "wo": 1 + "or": 1 + "rd": 1 + ), + ... + } + """ + text_to_q_gram = self.text_to_positional_q_gram + return { + word: Counter(text_to_q_gram(word)) + for word in word_list + } + + def word_in_index(self, word: str) -> bool: + """Convenience function to check if a single word is in the search index.""" + if " " in word: + raise Exception( + f"Given word '{word}' must not contain spaces.") + return word in self.word_to_identifier.keys() + + # +++ Changes to searchable data + + def add_identifier(self, data: pd.DataFrame) -> None: + """Add this data to the search index. + + identifier column is REQUIRED to be present + ALL data in the given dataframe will be added, if columns should not be added, they should be removed before + calling this function + """ + + # ensure we have identifier column + if self.identifier_name not in data.columns: + raise Exception( + f"Identifier column '{self.identifier_name}' not in new data, impossible to add data without identifier") + + # make sure we the new identifiers do not yet exist + existing_ids = set(self.df.index.to_list()) + for identifier in data[self.identifier_name]: + if identifier in existing_ids: + raise Exception( + f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") + + # make sure all new identifiers given are unique + if data[self.identifier_name].nunique() != data.shape[0]: + raise KeyError( + f"Identifier column {self.identifier_name} must only contain unique values. Found {data[self.identifier_name].nunique()} unique values for length {data.shape[0]}") + + df_cols = self.columns + # add cols to new data that are missing + for col in df_cols: + if col not in data.columns: + data.loc[:, col] = [""] * len(data) + # re-order cols, first existing, then new + df_col_set = set(df_cols) + new_cols = [col for col in data.columns if col not in self.columns if col not in df_col_set] + data_cols = df_cols + new_cols + data = data[data_cols] # re-order new data to be in correct order + + # add cols from new data to correct places + self.columns.extend(new_cols) + self.searchable_columns.extend([i for i, col in enumerate(data_cols) if col in new_cols]) + + # convert df + data = data.set_index(self.identifier_name, drop=False) + data = data.astype(object).fillna("") + data = data.astype(str) + + # update the search index data + self.update_index(data) + + def remove_identifier(self, identifier, logging=True) -> None: + """Remove this identifier from self.df and the search index. + """ + if logging: + t = time() + + # make sure the identifier exists + if identifier not in self.df.index.to_list(): + logger.warning( + f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist." + ) + return + + self.df = self.df.drop(identifier) + + # find words that may need to be removed + words = self.identifier_to_word[identifier] + for word in words: + if len(self.word_to_identifier[word]) == 1: + # this word is only found in this identifier, + # remove the word and check for q grams + del self.word_to_identifier[word] + + q_grams = self.word_to_q_grams[word] + for q_gram in q_grams: + if len(self.q_gram_to_word[q_gram]) == 1: + # this q_gram is only used in this word, + # remove it + del self.q_gram_to_word[q_gram] + elif len(self.q_gram_to_word[q_gram]) > 1: + # this q_gram is used in multiple words, only remove the word from the q_gram + del self.q_gram_to_word[q_gram][word] + + del self.word_to_q_grams[word] + else: + # this word is found in multiple identifiers + # word_to_q_gram and q_gram_to_word do not need to be changed, the word still exists + # remove the identifier the word in word_to_identifier + del self.word_to_identifier[word][identifier] + # finally, remove the identifier + del self.identifier_to_word[identifier] + + if logging: + logger.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for 1 removed item ({len(self.df)}.") + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + """Change this identifier. + + identifier must be an identifier that is in use + data must be a dataframe of 1 row with all change data + data is overwritten with the new data in 'data', columns not given remain unchanged + """ + + # make sure only 1 change item is given + if len(data) > 1 or len(data) < 1: + raise Exception( + f"change data must be for exactly 1 identifier, but {len(data)} items were given.") + # make sure correct use of identifier + if identifier not in self.df.index.to_list(): + raise Exception( + f"Identifier '{identifier}' does not exist in the search data, use an existing identifier or use the add_identifier function.") + if self.identifier_name in data.columns and data[self.identifier_name].to_list() != [identifier]: + raise Exception( + "Identifier field cannot be changed, first remove item and then add new identifier") + if "query_col" in data.keys(): + logger.debug( + f"Field 'query_col' is a protected field for search engine and will be ignored for changing {identifier}") + + + # overwrite new data where relevant + update_data = self.df.loc[[identifier], self.columns] + data = data.reset_index(drop=True) + for col in data.columns: + value = data.loc[0, col] + update_data[col] = [value] + + # remove the entry + self.remove_identifier(identifier, logging=False) + # add entry with updated data + self.add_identifier(update_data) + + # +++ Search + + def filter_dataframe(self, df: pd.DataFrame, pattern: str, search_columns: Optional[list] = None) -> pd.Series: + """Filter the search columns of a dataframe on a pattern. + + Returns a mask (true/false) pd.Series with matching items.""" + + search_columns = search_columns if search_columns else self.columns + mask = functools.reduce( + np.logical_or, + [ + df[col].apply(lambda x: pattern in x.lower()) + for col in search_columns + ], + ) + return mask + + def literal_search(self, text, df: Optional[pd.DataFrame] = None) -> list: + """Do literal search of the text in all original columns that were given.""" + + if df is None: + df = self.df.copy() + + identifiers = self.filter_dataframe(df, text) + df = df.loc[identifiers] + identifiers = df.index.to_list() + return identifiers + + def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: int = 1000) -> int: + """Calculate the Optimal String Alignment (OSA) edit distance between two strings, return edit distance. + + Has additional cutoff variable, if cutoff is higher than 0 and if the words have + a larger edit distance, return a large number (note: cutoff <= edit_dist, not cutoff < edit_dist) + + OSA is a restricted form of the Damerau–Levenshtein distance. + https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance + + The edit distance is how many operations (insert, delete, substitute or transpose a character) need to happen to convert one string to another. + insert and delete are obvious operations, but substitute and transpose are explained: + substitute: replace one character with another: e.g. word1='cat' word2='cab', 't'->'b' substitution is 1 operation + transpose: swap the places of two adjacent characters with each other: e.g. word1='coal' word2='cola' 'al' -> 'la' transposition is 1 operation + + The minimum amount of edit operations (OSA edit distance) is returned. + """ + if word1 == word2: + # if the strings are the same, immediately return 0 + return 0 + + len1, len2 = len(word1), len(word2) + + if 0 < cutoff <= abs(len1 - len2): + # if the length difference between 2 words is over the cutoff, + # just return instead of calculating the edit distance + return cutoff_return + + if len1 == 0 or len2 == 0: + # in case (at least) one of the strings is empty, + # return the length of the longest string + return max(len1, len2) + + if len1 < len2 and cutoff > 0: + # make sure word1 is always the longest (required for early stopping with cutoff) + word1, word2 = word2, word1 + len1, len2 = len2, len1 + + # Initialize matrix + distance = [[0] * len2 for _ in range(len1)] + + # calculate shortest edit distance + for i in range(len1): + for j in range(len2): + cost = 0 if word1[i] == word2[j] else 1 + + # Compute distances for insertion, deletion and substitution + insertion = distance[i][j - 1] + 1 if j > 0 else i + 1 + deletion = distance[i - 1][j] + 1 if i > 0 else j + 1 + substitution = distance[i - 1][j - 1] + cost if i > 0 and j > 0 else max(i, j) + cost + + distance[i][j] = min(deletion, insertion, substitution) + + # Compute transposition when relevant + if i > 0 and j > 0 and word1[i] == word2[j - 1] and word1[i - 1] == word2[j]: + transposition = distance[i - 2][j - 2] + 1 if i > 1 and j > 1 else max(i, j) - 1 + distance[i][j] = min(distance[i][j], transposition) + + # stop early if we surpass cutoff + if 0 < cutoff <= min(distance[i]): + return cutoff_return + return distance[i][j] + + def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: + """Find which of the given q_grams exist in self.q_gram_to_word, + return a sorted dataframe of best matching words. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def spell_check(self, text: str, skip_len=1) -> OrderedDict: + """Create an OrderedDict of each word in the text (space separated) + with as values possible alternatives. + + Alternatives are first found with q-grams, then refined with string edit distance + + We rank alternative words based on 1) edit distance 2) how often a word is used in an entry + If too many results are found, we only keep edit distance 1, + if we want more results, we keep with longer edit distance up to `never_accept_this` + + word_results = OrderedDict( + "word": [work] + ) + + NOTE: only ALTERNATIVES are ever returned, this function returns empty list for item BOTH when + 1) the exact word is in the data + 2) when there are no suitable alternatives + """ + count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word + + word_results = OrderedDict() + + matches_min = 3 # ideally we have at least this many alternatives + matches_max = 10 # ideally don't much more than this many matches + always_accept_this = 1 # values of this edit distance or lower always accepted + never_accept_this = 4 # values this edit distance or over always rejected + + # make list of unique words + text = self.clean_text(text) + words = OrderedDict() + for word in text.split(" "): + if len(word) != 0: + words[word] = False + words = words.keys() + + for word in words: + if len(word) <= skip_len: # dont look for alternatives for text this short + word_results[word] = [] + continue + + # reduce acceptable edit distance with short words + dont_accept = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams)) + + first_matches = Counter() + other_matches = {} + + # now, refine with edit distance + for row in possible_matches.itertuples(): + + edit_distance = self.osa_distance(word, row[1], cutoff=dont_accept) + + if edit_distance == 0: + continue # we are looking for alternatives only, not the exact word + elif edit_distance <= always_accept_this: + first_matches[row[1]] = count_occurence(row[1]) + elif edit_distance < dont_accept: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurence(row[1]) + else: + continue + + # add matches in correct order: + matches = [match for match, _ in first_matches.most_common()] + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(always_accept_this + 1, dont_accept): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + word_results[word] = matches + return word_results + + def build_queries(self, query_text) -> list: + """Make all possible subsets of words in the query, including alternative words.""" + query_text = self.spell_check(query_text) + + # find all combinations of the query words as given + queries = list(query_text.keys()) + subsets = list(itertools.chain.from_iterable( + (itertools.combinations( + queries, r) for r in range(1, len(queries) + 1)))) + all_queries = [] + + for combination in subsets: + # add the 'default' option + all_queries.append(combination) + # now add all options with all alternatives + for i, word in enumerate(combination): + for alternative in query_text.get(word, []): + alternative_combination = list(combination) + alternative_combination[i] = alternative + all_queries.append(alternative_combination) + + return all_queries + + def weigh_identifiers(self, identifiers: Counter, weight: int, weighted_ids: Counter) -> Counter: + """Add weights to identifier counter for these identifiers times how often it occurs in identifier.""" + for identifier, occurrences in identifiers.items(): + weighted_ids[identifier] += (weight * occurrences) + return weighted_ids + + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + # add each word in search index if query_word in word + for word in self.word_to_identifier.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + for matched_word in matching_words: + weight = self.base_weight + id_counter = matched_identifiers.get(word, Counter()) + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.word_to_identifier[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + + return matched_identifiers + + def fuzzy_search(self, text: str, return_counter: bool = False) -> list: + """Search the dataframe, finding approximate matches and return a list of identifiers, + ranked by how well each identifier matches the search text. + + 1. First, identifiers matching single words (and spell-checked alternatives) are found and weighted. + 2. If the search term consisted of multiple words, combinations of those words are checked next. + 2.1 Increasing in size (first two words, then three etc.), we look for identifiers that contain that set of + words, these are also weighted, based on the sum of all one-word weights (from first step) and the length + of the sequence. + 2.2 Next, we also look specifically for combinations occurring next to each other. And add more weight like + the step above (2.1). + We multiply the weighting of step 2 by the sequence length, based on the assumption that finding more search + words will be a more relevant result than just finding a single word, and again if they are in the + correct order. + + Finally, all found identifiers are sorted on their weight and returned. + """ + text = text.strip() + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in itertools.permutations(query): + mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + if return_counter: + return all_identifiers + # now sort on highest weights and make list type + sorted_identifiers = [identifier for identifier, _ in all_identifiers.most_common()] + return sorted_identifiers + + def search(self, text) -> list: + """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() + text = text.strip() + + if len(text) == 0: + logger.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + fuzzy_identifiers = self.fuzzy_search(text) + if len(fuzzy_identifiers) == 0: + logger.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + logger.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in literal_id_set] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + logger.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py new file mode 100644 index 000000000..1814a3e8a --- /dev/null +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -0,0 +1,447 @@ +from itertools import permutations +from collections import Counter, OrderedDict +from logging import getLogger +from time import time +from typing import Optional +import pandas as pd + +from activity_browser.bwutils.searchengine import SearchEngine + + +log = getLogger(__name__) + + +class MetaDataSearchEngine(SearchEngine): + + # caching for faster operation + def database_id_manager(self, database): + if not hasattr(self, "all_database_ids"): + self.all_database_ids = {} + + if database_ids := self.all_database_ids.get(database): + self.database_ids = database_ids + self.current_database = database + elif database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + self.all_database_ids[database] = self.database_ids + self.current_database = database + else: + self.database_ids = None + self.current_database = "_@@NO_DB_" + return self.database_ids + + def reset_database_id_manager(self): + if hasattr(self, "all_database_ids"): + del self.all_database_ids + if hasattr(self, "database_ids"): + del self.database_ids + + def database_word_manager(self, database): + if not hasattr(self, "all_database_words"): + self.all_database_words = {} + + if database_words := self.all_database_words.get(database): + self.database_words = database_words + elif database is not None: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[database] = self.database_words + else: + self.database_words = None + return self.database_words + + def reset_database_word_manager(self, database): + if hasattr(self, "all_database_words") and self.all_database_words.get(database): + del self.all_database_words[database] + if hasattr(self, "database_words"): + del self.database_words + + def database_search_cache(self, database, query, result = None): + if not hasattr(self, "search_cache"): + self.search_cache = {} + + if result: + if self.search_cache.get(database): + self.search_cache[database][query] = result + else: + self.search_cache[database] = {query: result} + return + if db_cache := self.search_cache.get(database): + if cached_result := db_cache.get(query): + return cached_result + return + + def reset_search_cache(self, database): + if hasattr(self, "search_cache") and self.search_cache.get(database): + del self.search_cache[database] + + def reset_all_caches(self, databases): + self.reset_database_id_manager() + for database in databases: + self.reset_database_word_manager(database) + self.reset_search_cache(database) + + def add_identifier(self, data: pd.DataFrame) -> None: + super().add_identifier(data) + self.reset_all_caches(data["database"].unique()) + + def remove_identifiers(self, identifiers, logging=True) -> None: + t = time() + + identifiers = set(identifiers) + current_identifiers = set(self.df.index.to_list()) + identifiers = identifiers | current_identifiers # only remove identifiers currently in the data + databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning + if len(identifiers) == 0: + return + + for identifier in identifiers: + super().remove_identifier(identifier, logging=False) + + if logging: + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") + self.reset_all_caches(databases) + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + super().change_identifier(identifier, data) + self.reset_all_caches(data["database"].unique()) + + def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: + """Based on spellchecker, make more useful for autocompletions + """ + def word_to_identifier_to_word(check_word): + if len(context) == 0: + return 1 + multiplier = 1 + for identifier in self.word_to_identifier[check_word]: + for context_word in context: + for spell_checked_context_word in spell_checked_context[context_word]: + if spell_checked_context_word in self.identifier_to_word[identifier]: + multiplier += 1 + if context_word not in self.word_to_identifier.keys(): + continue + if context_word in self.identifier_to_word[identifier]: + multiplier += 4 + return multiplier + + # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 + count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 + + if len(word) <= 1: + return [] + + self.database_id_manager(database) + + if len(context) > 0: + spell_checked_context = {} + for context_word in context: + spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] + + matches_min = 2 # ideally we have at least this many alternatives + matches_max = 4 # ideally don't much more than this many matches + never_accept_this = 4 # values this edit distance or over always rejected + # or max 2/3 of len(word) if less than never_accept_this + never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) + + first_matches = Counter() + other_matches = {} + probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list + + # now, refine with edit distance + for row in possible_matches.itertuples(): + if word == row[1]: + continue + # find edit distance of same size strings + edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) + if len(row[1]) == 32 and edit_distance <= 1: + probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence + elif edit_distance == 0: + first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + elif edit_distance < never_accept_this and len(first_matches) < matches_min: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + else: + continue + + # add matches in correct order: + matches = [match for match, _ in first_matches.most_common()] + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(1, never_accept_this): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + matches = matches + [match for match, _ in probably_keys.most_common()] + return matches + + def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: + """Overwritten for extra database specific reduction of results. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + if isinstance(self.database_ids, set): + # DATABASE SPECIFIC now filter on whether word is in the database + in_db = False + for _id in self.word_to_identifier[word]: + if _id in self.database_ids: + in_db = True + break + else: + in_db = True + if in_db: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + if not return_all: + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + else: + min_q = 0 + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + t2 = time() + # add each word in search index if query_word in word + for word in self.database_words.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + if result := self.database_search_cache(self.current_database, word): + matched_identifiers[word] = result + continue + id_counter = matched_identifiers.get(word, Counter()) + for matched_word in matching_words: + weight = self.base_weight + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + self.database_search_cache(self.current_database, word, matched_identifiers[word]) + + return matched_identifiers + + def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True) -> list: + """Overwritten for extra database specific reduction of results. + """ + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # DATABASE SPECIFIC get the set of ids that is in this database + self.database_id_manager(database) + self.database_word_manager(database) + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # DATABASE SPECIFIC ensure all identifiers are in the database + if isinstance(self.database_ids, set): + new_q2i = {} + for word, _ids in query_to_identifier.items(): + keep = set.intersection(set(_ids.keys()), self.database_ids) + new_id_counter = Counter() + for _id in keep: + new_id_counter[_id] = _ids[_id] + if len(new_id_counter) > 0: + new_q2i[word] = new_id_counter + query_to_identifier = new_q2i + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + query_perm_str = " ".join(query_perm) + if result := self.database_search_cache(self.current_database, query_perm_str): + new_ids = result + else: + mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + self.database_search_cache(self.current_database, query_perm_str, new_ids) + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + if return_counter: + return_this = all_identifiers + else: + # now sort on highest weights and make list type + return_this = [identifier[0] for identifier in all_identifiers.most_common()] + if logging: + log.debug( + f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return return_this + + def search(self, text, database: Optional[str] = None) -> list: + """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # get the set of ids that is in this database + self.database_id_manager(database) + + fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) + if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in literal_id_set] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index 21b7d03de..f23967fb7 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -29,6 +29,26 @@ "location", ) +def metadatastore_link(data: list) -> list: + from .metadata import MetaDataStore + mds = MetaDataStore() + + for act in data: + for exc in act.get("exchanges", []): + match = mds.match( + name=exc.get("name"), + database=exc.get("database"), + categories=exc.get("categories"), + unit=exc.get("unit"), + product=exc.get("reference product"), + location=exc.get("location"), + ) + if len(match) == 1: + exc["input"] = match.index[0] + + return data + + def relink_exchanges_dbs(data: Collection, relink: dict) -> Collection: """Use this to relink exchanges during an actual import.""" @@ -253,14 +273,25 @@ def alter_database_name(data: list, old: str, new: str) -> list: # Note: this will only alter database if the field exists in the exchange. if exc.get("database") == old: exc["database"] = new - for p, d in ds.get("parameters", {}).items(): + for p in ds.get("parameters", []): # Any parameters found here are activity parameters and we can # overwrite the database without issue. - d["database"] = new + p["database"] = new if ds.get("processor", (None, None))[0] == old: ds["processor"] = (new, ds["processor"][1]) return data +def alter_exchange_database_name(data: list, linking_dict: dict[str, str]) -> list: + """For ABExcelImporter, go through data and replace all instances + of the `old` database name with `new` in exchanges only. + """ + for ds in data: + for exc in ds.get("exchanges", []): + # Note: this will only alter database if the field exists in the exchange. + if exc.get("database") in linking_dict: + exc["database"] = linking_dict[exc["database"]] + return data + def hash_parameter_group(data: list) -> list: """For ABExcelImporter, go through `data` and change all the activity parameter diff --git a/activity_browser/bwutils/superstructure/README.md b/activity_browser/bwutils/superstructure/README.md new file mode 100644 index 000000000..efb2ddae2 --- /dev/null +++ b/activity_browser/bwutils/superstructure/README.md @@ -0,0 +1,179 @@ +# superstructure + +Superstructure scenario analysis tools for Activity Browser. + +## Overview + +This directory implements superstructure functionality, which enables scenario-based LCA analysis. Superstructures allow users to model multiple scenarios within a single database by using parameters to switch between alternative technologies, processes, or supply chains. + +## What is a Superstructure? + +A superstructure is an LCA model that contains multiple possible configurations: +- Alternative technologies (e.g., different energy sources) +- Multiple scenarios (e.g., current vs. future) +- Prospective databases (e.g., from [premise](https://premise.readthedocs.io/)) +- Switchable pathways (e.g., different material choices) + +Parameters control which alternatives are "active" in each scenario. + +## Key Concepts + +### Scenarios +Named configurations that define parameter values: +```python +scenarios = { + "baseline": {"electricity_grid_mix": 0.7, "renewable_share": 0.3}, + "high_renewable": {"electricity_grid_mix": 0.2, "renewable_share": 0.8} +} +``` + +### Parameters +Variables that control exchange amounts or activity selection: +- **Amount parameters** - Control exchange quantities +- **Switch parameters** - Enable/disable exchanges (0 or 1) +- **Share parameters** - Allocate between alternatives (sum to 1) + +### Alternative Processes +Multiple activities representing different technology choices: +- Linked via parameterized exchanges +- Only one "active" per scenario +- Controlled by parameter values + +## Features + +### Scenario Management +- Create, edit, delete scenarios +- Copy scenarios for variations +- Compare scenarios side-by-side +- Switch between scenarios + +### Parameter Configuration +- Define parameter ranges +- Set scenario-specific values +- Link parameters to exchanges +- Validate parameter consistency + +### Scenario Calculations +- Run LCA for multiple scenarios +- Compare results across scenarios +- Visualize scenario differences +- Export scenario results + +## Usage Pattern + +### Creating a Superstructure +```python +from activity_browser.bwutils.superstructure import Superstructure + +# Create superstructure +ss = Superstructure(name="Energy scenarios") + +# Add scenarios +ss.add_scenario("baseline", parameters={...}) +ss.add_scenario("high_renewable", parameters={...}) +``` + +### Running Scenario Analysis +```python +# Calculate all scenarios +results = ss.calculate_scenarios() + +# Compare results +comparison = ss.compare_scenarios(["baseline", "high_renewable"]) +``` + +## Integration with Parameters + +Superstructures leverage Activity Browser's parameter system: +- Project parameters define scenarios +- Database parameters set alternative values +- Activity parameters control exchanges +- Formulas link parameters together + +See `app/pages/parameters/` for parameter management UI. + +## Integration with Premise + +Activity Browser supports prospective databases from [premise](https://premise.readthedocs.io/): +- Import premise scenarios +- Map to Activity Browser scenarios +- Run temporal LCA analyses +- Visualize future pathways + +## Visualization + +Superstructure results can be visualized as: +- **Bar charts** - Compare impacts across scenarios +- **Radar charts** - Multi-dimensional scenario comparison +- **Heatmaps** - Parameter sensitivity across scenarios +- **Sankey diagrams** - Flow differences between scenarios + +## File Format + +Superstructures can be saved/loaded: +```json +{ + "name": "Energy scenarios", + "scenarios": { + "baseline": { + "parameters": {...}, + "description": "Current situation" + }, + "future": { + "parameters": {...}, + "description": "2050 scenario" + } + }, + "reference_flow": {...}, + "methods": [...] +} +``` + +## Development Guidelines + +When working with superstructures: + +1. **Validate parameters** - Ensure consistency across scenarios +2. **Check constraints** - Share parameters should sum to 1 +3. **Handle errors** - Gracefully handle missing or invalid parameters +4. **Use threading** - Scenario calculations can be slow +5. **Cache results** - Avoid recalculating unchanged scenarios +6. **Emit signals** - Notify when scenarios change +7. **Support undo** - Allow reverting parameter changes + +## Advanced Features + +### Sensitivity Analysis +Test parameter importance: +```python +sensitivity = ss.sensitivity_analysis( + parameter="renewable_share", + range=(0, 1), + steps=10 +) +``` + +### Optimization +Find best parameter values: +```python +optimal = ss.optimize( + objective="minimize_impact", + constraints={...} +) +``` + +### Monte Carlo with Scenarios +Combine uncertainty and scenarios: +```python +results = ss.monte_carlo( + scenario="future", + iterations=1000 +) +``` + +## Related Modules + +- `app/pages/parameters/` - Parameter management UI +- `bwutils/multilca.py` - Multi-functional LCA calculations +- `bwutils/sensitivity_analysis.py` - Sensitivity analysis tools +- `bwutils/montecarlo.py` - Monte Carlo simulation diff --git a/activity_browser/bwutils/utils.py b/activity_browser/bwutils/utils.py index 61bf76791..3b082a81f 100644 --- a/activity_browser/bwutils/utils.py +++ b/activity_browser/bwutils/utils.py @@ -33,6 +33,19 @@ def deletable(self): except pw.DoesNotExist: return False + @property + def uncertainty(self): + uncertainty_keys = { + "uncertainty type", + "loc", + "scale", + "shape", + "minimum", + "maximum", + "negative", + } + return {k: v for k, v in self.data.items() if k in uncertainty_keys} + def as_gsa_tuple(self) -> tuple: """Return the parameter data formatted as follows: - Parameter name diff --git a/activity_browser/docs/wiki/Activities.md b/activity_browser/docs/wiki/Activities.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Activities.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Databases.md b/activity_browser/docs/wiki/Databases.md deleted file mode 100644 index 1b76cda72..000000000 --- a/activity_browser/docs/wiki/Databases.md +++ /dev/null @@ -1,99 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -DatabasesPane are the main way in which Brightway manages and stores [Activities](Activities). -Use databases to organize your data in a meaningful way, for example by separating foreground and background systems. - -[Read more about data organization in Brightway...](Getting-Started#organization-of-data-in-brightway-and-activity-browser) - -Brightway databases consist of two parts: - -1. **Backend:** this is where the actual activity data lives. - Most users will be using the SQLite backend, which stores data in the _databases.db_ found in the project folder. -2. **Metadata:** this is where database specific metadata is stored, such as dependent databases, number of activities, - and time of last edit. - -DatabasesPane that are installed in a project may be found in the `DatabasesPane` section, part of the `Project` panel. -This section shows a table that displays a selection of the metadata for all the databases in the project. - -> [!NOTE] -> This panel is not yet visible when no databases have been installed into the project yet. -> Instead, a button to set up your project will be shown. -> -> [Read more about setting up a project...](Getting-Started#setting-up-a-project) - -## Basic functions - -### Opening a database -You can open a database by double-clicking its entry within the `DatabasesPane` table. -This will open a tab at the bottom of the `Project` panel that contains a table showing all [activities](Activities) -that the database contains. - -### Creating a new database -You can create a new database by clicking the `New database...` button in the `DatabasesPane` table. -This will prompt you to enter a unique name for the database, after which the newly created database will open and you -can start adding activities as desired. - -### Deleting a database -You can delete a database by right-clicking on its entry withing the `DatabasesPane` table and selecting `Delete database`, -this will prompt you for a confirmation. - -> [!WARNING] -> Deleting a database can not be undone and any exchanges between activities in the database and any other database will -> be deleted, all activities in the database that were used in a calculation setup will also be removed from the setup. -> -> Make sure you anticipate the consequences of deleting a database before doing so! - -### Duplicating a database -You can duplicate a database by right-clicking on its entry withing the `DatabasesPane` table. -This will prompt to enter a unique name for the new database, after which the newly duplicated database will open. - -### Relinking a database -DatabasesPane are often connected to other databases by exchanges. -Sometimes, you may want to replace the connections from a database to another, as an example: - -You have 2 databases, database _A_ and _B_, _B_ uses activities that are in _A_. -You duplicated a database _A_ to make and test some changes to _A_copy_, and now want to change the links in _B_ to _A_copy_. - -To relink a database, you can right-click on its entry in the `DatabasesPane` table and choose `Relink the database`. -In the pop-up, you can choose a new link for every database your database depends on. - -Relinking will only work if exact matches are found for the `name`, `reference product` adn `unit` for the activities. -Any activities not relinked will remain linked to the old database. - -> [!NOTE] -> Relinking can be a slow process, as it needs to check every exchange in every activity in the database. - -[//]: # (# Importing) - -[//]: # (Importing databases is an important aspect of project management. However, there are a myriad of different file formats ) - -[//]: # (and standards around for LCA data. Activity Browser covers importing for the following formats:) - -[//]: # (- Ecospold) - -[//]: # (- .bw2data packages) - -[//]: # (- Excels in the Brightway2 format) - -[//]: # () -[//]: # () -[//]: # (## Database import wizard) - -[//]: # () -[//]: # () -[//]: # (# Exporting) - -[//]: # () -[//]: # (## Database export wizard) - -[//]: # () -[//]: # () -[//]: # (# Specific tooling) - -[//]: # () -[//]: # (# Database relinking) - diff --git a/activity_browser/docs/wiki/Flow-Scenarios.md b/activity_browser/docs/wiki/Flow-Scenarios.md deleted file mode 100644 index 888267044..000000000 --- a/activity_browser/docs/wiki/Flow-Scenarios.md +++ /dev/null @@ -1,35 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - - -## Combining Scenario Files -You can work with multiple scenario files for which there are with two options: - -### Product combinations -The option `Combine scenarios` will calculate every combination between scenarios, it adds more scenarios. -This yields all possible scenario combinations, e.g. file 1: A, B and file 2: X, Y yields A-X, A-Y, -B-X and B-Y, as shown in the figure below. - -![SDF product combination](./assets/sdf_product_combination.png) - -### Extend combinations -The option `Extend scenarios` will combine scenarios with the same name into a larger single scenario, -it makes the existing scenarios larger. -Scenarios from file 2 extend scenarios of file 1, e.g. file 1: A, B and file 2: A, B yields A-B, -as shown in the figure below. - -> [!IMPORTANT] -> This is only possible if scenario names are **identical** in all files, e.g. everywhere A, B). - -![SDF extend combination](./assets/sdf_addition_combination.png) - -## Video overview of modelling and calculating scenarios - -[![Projects and DatabasesPane](https://img.youtube.com/vi/3LPcpV1G_jg/hqdefault.jpg)](https://www.youtube.com/watch?v=3LPcpV1G_jg) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - diff --git a/activity_browser/docs/wiki/Getting-Started.md b/activity_browser/docs/wiki/Getting-Started.md deleted file mode 100644 index e2eaca941..000000000 --- a/activity_browser/docs/wiki/Getting-Started.md +++ /dev/null @@ -1,167 +0,0 @@ -[Learn how to install Activity Browser...](Installation-Guide) - -## Starting Activity Browser -First activate the environment where the activity browser is installed: - -```bash -conda activate ab -``` - -Then simply run `activity-browser` and the application will open. - -## Understanding Activity Browser terms -Activity Browser uses [Brightway](https://docs.brightway.dev/en/latest/) for its data management and calculations. -Brightway has its own 'accent' of LCA terms, -you can compare LCA terms from Brightway, [ISO 14044 (2006)](https://www.iso.org/standard/38498.html) and others in the -[Brightway Glossary](https://docs.brightway.dev/en/latest/content/overview/glossary.html). - -## Organization of data in Brightway and Activity Browser -Data in Brightway is organized into projects -- Projects contain databases, impact categories, calculation setups and more - - DatabasesPane contain activities (biosphere and technosphere) - - Activities are the building blocks of your LCA model -- Impact categories are used to score your LCA models against -- Calculation setups are the combinations of reference flows and impact categories that you can calculate -- Projects also contain other data, such as parameters and plugin settings. - -![brightway organizational structure](./assets/brightway_org-scheme.png) - -_Image copied from the -[Brightway documentation](https://docs.brightway.dev/en/latest/content/theory/structure.html#brightway-objects)._ - - -Read more about how data is organized in the -[Brightway documentation](https://docs.brightway.dev/en/latest/content/theory/structure.html#brightway-objects). - -## User interface -Activity Browser is organized in two panels, which themselves have tabs and a menu bar. -The left panel has a `Project` tab and an `Impact Categories` tab. -The right panel has the `Welcome` screen, `LCA setup` tab, `Parameters` tab and -if used- an `LCA Results` tab. - -The [`Project`](Projects) tab shows your current project, the databases in that project and the contents of a database if it is open. -The [`Impact Categories`](Impact-Categories) tab shows all impact categories that are installed in the current project. -The [`LCA Setup`](LCA-Calculation-Setups) tab allows you to define reference flows, impact categories and scenarios for calculations. -The [`Parameters`](Parameters) tab allows you to manage your parameters. -The [`LCA Results`](LCA-Results) tab shows the results of the calculations you do. -Finally, the menu bar at the top allows you to manage Activity Browser, Plugins and Project settings. - -## Setting up a project - -### Video overview of project setup - -[![Projects and DatabasesPane](https://img.youtube.com/vi/qWzaQjAf8ZU/hqdefault.jpg)](https://www.youtube.com/watch?v=qWzaQjAf8ZU) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -### Installing a biosphere and impact categories -In the `Project` tab there is initially a button called `Set up your project with default data`. -Click this button to add the default data. -This adds a `biosphere` database which contains a number of standardized biosphere flows -and compatible impact categories. - -![project setup - choose type](./assets/project_setup_dialog_choose_type.png) - -#### Setting up with Biosphere3 data -You can choose a biosphere version, which will install a biosphere database and compatible impact categories. - -> [!IMPORTANT] -> In case you want to install ecoinvent later, choosing a biosphere version will make your project compatible with -> **only** the version of biosphere you install. -> e.g. installing biosphere `3.6` will make your project only compatible with ecoinvent `3.6` databases. -> -> Setting the biosphere version is **permanent** for a project, you cannot change this version later. -> -> If you do not plan on using ecoinvent in this project, don't worry about this and choose the highest version. - -![project setup - choose biosphere version](./assets/project_setup_dialog_bio_vsn.png) - -#### Setting up with ecoinvent data -If you have a valid ecoinvent license and login information, you can immediately set up ecoinvent in your project with all -relevant and compatible data. -You can then choose the database version and system model. - -![project setup - ecoinvent login](./assets/project_setup_dialog_ei_login.png) -![project setup - ecoinvent version and system model](./assets/project_setup_dialog_ei_vsn_and_model.png) - -[Read more about projects...](Projects) - -## LCI databases -After adding the default data, you can create or import a database with the `New` and `Import Database` buttons. - -![project tab until databases](./assets/project_tab_until_databases.png) - -### New databases -With `New` you can create a completely empty database with any given name and -enter your own activity data. - -[Read more about activities...](Activities) - -### Importing databases -Clicking 'Import' will open a new dialog that will allow you to select how you want to import data into brightway -(and by extension, the Activity Browser). -There are two main options: 'remote data' and 'local data': - -
Remote database import - -We currently support 2 remote databases, Ecoinvent and Forwast: - -#### Importing Ecoinvent -[**Ecoinvent**](https://ecoinvent.org/) is a paid database you can install directly in Activity Browser if you have a -valid ecoinvent license and login information. - -#### Importing Forwast -[**Forwast**](http://forwast.brgm.fr/) is a free database you can install directly in Activity Browser. -___ -
- -
Local database import - -We support various local import methods -- Local 7z-archive of ecospold2 files -- Local directory of ecospold2 files -- Local Excel file -- Local Brightway database file -___ -
- -[Read more about databases...](Databases) - -### Video overview of working with Activities in DatabasesPane - -[![Projects and DatabasesPane](https://img.youtube.com/vi/2rmydYdscJY/hqdefault.jpg)](https://www.youtube.com/watch?v=2rmydYdscJY) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -[Read more about activities...](Activities) - -## Running an LCA calculation -To run an LCA, you must first create a calculation setup, add at least one reference flow and one impact category -to be able to calculate results. - -### Video overview of calculating LCA results - -[![LCA results](https://img.youtube.com/vi/J94UehVQM-Q/hqdefault.jpg)](https://www.youtube.com/watch?v=J94UehVQM-Q) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -[Read more about LCA calculation setups...](LCA-Calculation-Setups) - -[Read more about LCA results...](LCA-Results) - -[Follow a tutorial to do your first LCA...](Tutorials#your-first-lca) - -## Additional Resources -- [Youtube tutorials](https://www.youtube.com/channel/UCsyySKrzEMsRFsWW1Oz-6aA/) -- [Introduction video by ETH Zurich](https://www.youtube.com/watch?v=j3uLptvsxeA) -- [AB Discussions page](https://github.com/LCA-ActivityBrowser/activity-browser/discussions) -- [AB scientific article](https://doi.org/10.1016/j.simpa.2019.100012) -- The AB has two mailing lists, for [updates](https://brightway.groups.io/g/AB-updates) and [user exchange](https://brightway.groups.io/g/AB-discussion) -- [Brightway2](https://brightway.dev/) -- [Global Sensitiviy Analysis paper](https://onlinelibrary.wiley.com/doi/10.1111/jiec.13194) describing GSA as implemented in the AB; see also our [wiki](https://github.com/LCA-ActivityBrowser/activity-browser/wiki/Global-Sensitivity-Analysis) -- [Modular LCA paper](https://link.springer.com/article/10.1007/s11367-015-1015-3); [documentation modular LCA](http://activity-browser.readthedocs.io/en/latest/index.html) diff --git a/activity_browser/docs/wiki/Graph-Explorer.md b/activity_browser/docs/wiki/Graph-Explorer.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Graph-Explorer.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Home.md b/activity_browser/docs/wiki/Home.md deleted file mode 100644 index 766b95580..000000000 --- a/activity_browser/docs/wiki/Home.md +++ /dev/null @@ -1,53 +0,0 @@ -Welcome to the Activity Browser wiki! - -> [!IMPORTANT] -> Creating this wiki is an ongoing project. -> While we aim to have it as complete as possible, sections may be missing or incomplete. -> If you want to contribute to the wiki, please check out our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -The wiki aims to help users get started and to document all features in Activity Browser. - -## Getting started -**Check out these resources to get you started with Activity Browser** -- [Installation guide](Installation-Guide) -- [Updating Activity Browser](Installation-Guide#updating-activity-browser) -- [Getting Started](Getting-Started) - - [User interface](Getting-Started#user-interface) - - [Understanding Activity Browser terms](Getting-Started#understanding-activity-browser-terms) - - [Setting up a project](Getting-Started#setting-up-a-project) - - [LCI databases](Getting-Started#lci-databases) - - [Running an LCA calculation](Getting-Started#running-an-lca-calculation) -- [Additional Resources](Getting-Started#additional-resources) -- [Need help?](Need-Help) - -___ -## Tutorials -Have a look at our [tutorials page](Tutorials) to follow along with examples. - -- [Follow a tutorial to do your first LCA](Tutorials#your-first-lca) - -___ -## Video overview of Activity Browser - -[![What is the Activity Browser video](https://img.youtube.com/vi/oeL_FOsNYfU/hqdefault.jpg)](https://www.youtube.com/watch?v=oeL_FOsNYfU) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -___ -## Documentation - -- [📚 Projects](Projects) -- [📒 DatabasesPane](Databases) -- [🧾 Activities](Activities) -- [🌍 Impact Categories](Impact-Categories) -- [🧮 LCA Calculation Setups](LCA-Calculation-Setups) -- [📊 LCA Results](LCA-Results) -- [🎰 Uncertainty](Uncertainty) -- [📈 Flow Scenarios](Flow-Scenarios) -- [📈 Parameter Scenarios](Parameters) -- [🔁 Graph Explorer](Graph-Explorer) -- [🧩 Plugins](Plugins) -- [⚙️ Settings](Settings) diff --git a/activity_browser/docs/wiki/Impact-Categories.md b/activity_browser/docs/wiki/Impact-Categories.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Impact-Categories.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Installation-Guide.md b/activity_browser/docs/wiki/Installation-Guide.md deleted file mode 100644 index f5a82ed57..000000000 --- a/activity_browser/docs/wiki/Installation-Guide.md +++ /dev/null @@ -1,103 +0,0 @@ -## Introduction -Thank you for showing interesting in the Beta for Activity Browser 3.0. This release has been a year in the making and we're happy to -show of the hard work that's been put in and get it to a level where we're happy to show it to the wider public. Most notably AB3 will -support Brightway25 and multifunctionality. But there have also been a lot of changes to the look and experience of the Activity Browser. - -Making so many changes inevitably also comes with creating a lot of bugs. We have tried to catch as may of them as we could, but in the -end noone is better at finding bugs than you, our users, are. We invite you to install the Activity Browser 3.0 Beta and break it in as -many ways as you possibly can. As long as you report the bugs you find back to us, so we can fix them. - -Thank you for your help, and enjoy the new Activity Browser! - -> [!IMPORTANT] -> This is a **beta installation**. As always use of the Activity Browser is **at your own risk**, but take extra care with this installation. Back-up critical projects before opening them. - -## Distributions on PyPI and Anaconda -The Activity Browser 3 Beta is available both on [PyPI]() and [Anaconda](). Because not all necessary libraries are available on Anaconda -right now you need to do an extra `pip install` inside your Conda environment. - -#### Quick-Install PyPI -``` -pip install activity-browser -``` - -#### Quick-Install Anaconda -``` -conda create -n ab_beta -c conda-forge lca::activity-browser -conda activate ab_beta -pip install PySide6 -``` - -For more elaborate installing instructions check out the page below for both [installing from PyPI](#installing-from-pypi) and [installing from Anaconda](#installing-from-anaconda). - -## Installing from PyPI -Installing from the Python Package Index (PyPI) can be done using the standard `pip` command. We strongly recommended installing the -Activity Browser into a separate [virtual environment](https://realpython.com/python-virtual-environments-a-primer/) - -First make sure you have Python installed on your PC by entering the following command into your terminal or command prompt. - -``` -python --version -``` -If you get an error please install Python [using their install instructions](https://www.python.org/downloads/). - -### Creating a virtual environment -Firstly, create a directory for your virtual environments, such as C:/Users/me/virtualenvs/. Then create a virtual environment in that -location using the following command: -``` -python -m venv C:/Users/me/virtualenvs/ab-beta -``` -Afterwards, you need to activate the virtual environment, which differs between operating systems and shells. Using Window Command Prompt -activate the environment using this command: -``` -C:\Users\me\virtualenvs\ab-beta\Scripts\activate.bat -``` -For a full overview of activation commands, [check out the documentation here](https://docs.python.org/3/library/venv.html#how-venvs-work) - -### Activity Browser Beta installation -After creating the virtual environment, installing the Beta should be as simple as using the following command: -``` -pip install activity-browser -``` - -### Launching the Activity Browser -The Activity Browser can then be launched like so: -``` -activity-browser -``` - -## Installing from Anaconda -First make sure you have Conda installed - -``` -conda --version -``` - -If you get an error, please download and install miniconda from anaconda.com https://www.anaconda.com/download/success - -### Activity Browser Beta installation -Next we're going to create a new environment for the Activity Browser Beta release. - -``` -conda create -n ab_beta -c conda-forge lca::activity-browser -``` - -This will go through a few steps, some of which like `solving environment` may take a while. After installation has finished you can -activate the environment like so: - -``` -conda activate ab_beta -``` - -### PySide6 installation -We will need to install `PySide6` from a different source, as the fully functional version is not available on anaconda. - -``` -pip install PySide6 -``` - -### Launching the Activity Browser -Launch the Activity Browser like you would normally -``` -activity-browser -``` \ No newline at end of file diff --git a/activity_browser/docs/wiki/LCA-Calculation-Setups.md b/activity_browser/docs/wiki/LCA-Calculation-Setups.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/LCA-Calculation-Setups.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/LCA-Results.md b/activity_browser/docs/wiki/LCA-Results.md deleted file mode 100644 index b5717114d..000000000 --- a/activity_browser/docs/wiki/LCA-Results.md +++ /dev/null @@ -1,222 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -## Overview - -### Inventory - -### LCA overview results - -### Score matrix - -## Contribution Analysis -### Differences between approaches -Activity Browser has three contribution analysis approaches available to assess results, -`Elementary Flow (EF) Contributions`, `Process contributions` and `First Tier (FT) Contributions`. - -Before we discuss the different approaches, we introduce a small example for the production of _'steel'_: -These approaches are extensively discussed independent of Activity Browser by -[van der Meide et al. (2025)](https://doi.org/10.31219/osf.io/sfgj6_v1) -if you want to learn more. - -![steel production example](./assets/steel_production_example.svg) - -The amounts we use are: - -| activity | product | technosphere exchanges | biosphere exchanges | -|------------------------|--------------------|---------------------------------|--------------------------| -| coal production | 10 kg coal | | 0.02 kg CH4 | -| electricity production | 10 kWh electricity | 10 kg coal | 10.808 kg CO2 | -| steel production | 10 kg steel | 5 kWh electricity
5 kg coal | 10 kg CO2 | - - -Note: These numbers are used for ease of understanding, not for realism. - - -To produce 1 kg of steel, we get a climate change impact of 1.6 kg CO2 eq. with the -_'IPCC 2021 GWP 100'_ impact category. -In the way Brightway (and thus Activity Browser) calculate results, a _contribution matrix_ is calculated with -all impacts _from_ all EFs and all activities. -For the system and functional unit above, this would be: - -| | coal prod. | elec. prod. | steel prod. | -|-----------------------|------------|-------------|-------------| -| CO2 | - | 0.5404... | 1 | -| CH4 | 0.0596... | - | - | - -The _contribution matrix_ show the dis-aggregated results for each individual biosphere flow for each activity. - -#### Elementary Flow (EF) contributions -If we take the sum the _rows_ to one column, we get the EF contributions -(the contribution of all CO2 and CH4 impacts together). - -In the case above, the EF contributions are: -- CO2: 1.5404... (96.3%) -- CH4: 0.0596... (3.7%) - -#### Process contributions -If we take the sum of the _columns_ to one row, we get the process contributions -(the contribution of all coal, electricity and steel production impacts together). - -In the case above, the process contributions are: -- coal production: 0.0596... (3.7%) -- electricity production: 0.5404... (33.8%) -- steel production: 1 (62.5%) - -To summarize, the difference between EF and process contributions is the direction the contribution matrix is summed. - -#### First Tier (FT) contributions -The FT contributions take a very different approach, instead of calculating the impact of processes anywhere in the -system, FT contributions are the process of the functional unit and all its inputs. -By calculating the impact of the inputs to the functional unit, the impacts are accumulated. -In the example above this would mean that the impact of _'coal'_ is calculated from only the coal needed directly by -_'steel production'_, the impact from coal produced for _'electricity production'_ would be included in the -_'electricty'_. -Together with the _direct_ impact from _'steel production'_, this is the _first tier_. - -This approach becomes more useful when using large systems to accumulate impacts into relevant parts of your foreground -system. - -Activity Browser calculates these impacts by applying _partial LCAs_ (LCA on part of the functional unit) on the inputs, -scaled to the functional unit. - -In the case above, the FT contributions are: -- coal: 0.0298... (1.9%) -- electricity: 0.5702... (35.6%) -- steel production: 1 (62.5%) - -Note that we now use the names of the products _'coal'_ and _'electricity'_ as we now assess the impacts of these inputs, -not the processes. - -Note also how the impact of _'steel production'_ is unchanged, as this still shows the _direct_ impact, but that the -impact of _'electricity'_ is higher than _'electricity production'_ in the process contributions. -This is due to the fact that we include all impacts in the production of electricity, not just the _direct_ impacts. -However, these results are compensated by a lower impact of _'coal'_ (compared to process contributions of -_'coal production'_). -The total impact is still 1.6. - -### Manipulating results -In this section we generalize a little bit for the different contribution approaches, -we call the _from_ part of the contributions (the EFs or activities or FT above) _entities_. - -There are several ways Activity Browser manipulates your results by default: -- All reference flows are compared to eachother. -- The contributions are **sorted** so that the most important contributions are shown first. - - The sorting is done on the _mean square_ (ignoring zero values) of each row of contributing entities. -- A `cut-off` of 5% is applied, this only shows results that contribute at least 5% to the total range of results, - all other entities are grouped into the `Rest (+)` and `Rest (-)` groups for positive and negative - contributions respectively. -- The contributions are _normalized_ to the LCA scores, - meaning contributions are shown as a percentage contribution of the score, counting up to 100%. - -These defaults exist to show you the most relevant results in most cases, but you may often want to make this more -specific for your analysis. -You can manually manipulate the contribution results in the menu shown below, which we will explain bit by bit -in the next sections. -![contributions cutoff](./assets/contribution_manipulation.png) - -#### Cut-off -You can manually change the `Cut-off type` of the results in three ways: -- The `Minimum %` mode shows contributions _from_ entities of at least _x_% or higher. - - For example: If the cut-off is set to 5% for process contribtions, then all contributions of at least 5% are shown. -- The `Cumulative %` mode shows contributions that cumulatively contribute at least _x_%. - - For example: If the cut-off is set to 80% for process contributions, then the first _n_ processes (sorted highest - to lowest) that count up to 80% are shown. -- The `Top #` mode shows contributions from the _x_ entities that contribute the most (as absolute). - - For example: If the cut-off is set to 5, then the first 5 processes (sorted highest - to lowest) will be shown. - -The cut-off is applied per item (e.g. per reference flow or impact category, see [compare](#compare)) below). -This means that if you want to see the top 5 contributors, you will only see the top 5 per item, even if a contributor would -also be present for another item. - -You can adjust the `Cut-off level` to change how many results you see. - -All contributions that are below the cut-off will be grouped into the `Rest (+)` and `Rest (-)` groups. -The Rest groups are only present when there are positive or negative numbers remaining for the respective rest groups. - -#### Compare -The `Compare` menu allows you to compare different dimensions of results. -You can compare between: -- _Reference flows_ -- _Impact categories_ -- _Scenarios_ (only available in scenario LCA, see [scenarios](#scenarios)) - -The compare mode defines what is shown in the figure. - -#### Aggregation -The `Aggregate by` menu can be used to _group_ results based on field names. -This is useful to group contributors together so you have fewer -and larger- contributors. -As an example, EF contributions can be grouped on the name to group all flows with the same name -(which would for example group all EFs with the name _carbon dioxide_ together). -As another example, process contributions can be grouped based on their reference product name -(which would for example group all processes with the product name _electricity, high voltage_ together). - -#### Plot and Table -By default, Activity Browser shows a plot and a table. -You can disable one of them if you want to focus on the other. - -#### Relative and Absolute -You can choose between `Relative` and `Absolute` results. -The `Relative` results will sum to 100% (the total `Score` or `Range`), -the `Absolute` results will sum to the impact score. -For `Relative`, you can choose what you use as the 100% reference, the `Score` or the `Range`. - -#### Score and Range -The `Score`/`Range` determines what you use as the _total_ to which the contributions are counted. -- For `Score`, this is the total score (sum) of the results - - For example, if all your negative results together have a score of -2 and all your positive results together have a - score of 10, the _score_ is 8 (-2 + 10). - - An entity with a contribution of 4 would have a relative contribution of 4/8 = 50%. -- For `Range`, this is the full _range_ of results - - For example, if all your negative results together have a score of -2 and all your positive results together have a - score of 10, the _range_ is 12 (-2 * -1 + 10). - - An entity with a contribution of 4 would have a relative contribution of 4/12 = 33.3...%. - -The `Score` or `Range` setting are only relevant when your results contain both positive and negative contributions. - -### Positive and negative numbers in contribution results -It can happen in LCA that you get both positive and negative numbers in your contribution results. -Some reasons for this could be negative characterization factors, flows with negative numbers or using -substitution flows. - -When there are both positive and negative numbers in the result, Activity Browser will show a marker to indicate -where the total _score_ is, and show positive and negative contributions to the impact separately. - -Below is a simple example (with unrealistic values) to demonstrate this: - -![CA example with positive and negative results](./assets/ca_positive_negative_example.png) - -## Sankey -The `Sankey` tab shows results from [graph traversal](https://docs.brightway.dev/projects/graphtools/en/latest/index.html). -Graph traversal calculates results step-by-step for _nodes_ (activites) in the _graph_ (supply chain/product system). -This is explained in detail by -[van der Meide et al. (2025)](https://doi.org/10.31219/osf.io/sfgj6_v1) (path contributions). - -### Sankey configuration -In the `Sankey` tab, you can set the -Reference flow, Impact category and Scenario (only available in scenario LCA, see [scenarios](#scenarios)) to be shown. -you can also set a `cutoff` and `calculation depth` setting. - -The `cutoff` setting will stop traversing the supply chain once the impact is below the percentage specified. -The `calculation depth` will stop traversing the supply chain once that number of calculations have been performed. - -### Sankey results -In the Sankey, the red arrows show the _cumulative_ impact of the _product_ flow -(_direct_ from that process and _indirect_ from all upstream processes involved in producing that product), -the boxes show the _direct_ (process contribution) impact of that process. -Effectively, the sankey graph is the First Tier contribution analysis, repeated for every activity you see in the graph, -making it _n-tier_ contributions. - -Using the example above in the [contribution analysis](#contribution-analysis) section, we show the sankey below. -The [process contribution](#process-contributions) results are also shown in the boxes below. - -![sankey example](./assets/sankey_example.svg) - -## Other Results tabs -The Monte Carlo and Senstivity Analysis tabs are explained on the [Uncertainty](Uncertainty) page. - -## Scenarios diff --git a/activity_browser/docs/wiki/Need-Help.md b/activity_browser/docs/wiki/Need-Help.md deleted file mode 100644 index 8d5688455..000000000 --- a/activity_browser/docs/wiki/Need-Help.md +++ /dev/null @@ -1,8 +0,0 @@ -Activity Browser supports its users through the community. -If you have **questions** about using Activity Browser and can't find the answer in this wiki, ask it on our -[discussions](https://github.com/LCA-ActivityBrowser/activity-browser/discussions) page! -If you have **found a problem** or have **suggestions to improve** Activity Browser, open an -[issue](https://github.com/LCA-ActivityBrowser/activity-browser/issues). -If you want to **contribute to the Activity Browser** project, you can check out our -[contributing](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md) -page to see how you can help out. diff --git a/activity_browser/docs/wiki/Parameters.md b/activity_browser/docs/wiki/Parameters.md deleted file mode 100644 index 91c915a83..000000000 --- a/activity_browser/docs/wiki/Parameters.md +++ /dev/null @@ -1,86 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -## General concepts - -## Creating parameters - -Parameters are -[special objects in Brightway](https://docs.brightway.dev/en/latest/content/api/bw2data/parameters/index.html) -that allow users to create incredibly complex systems of interlocking parts. - -What parameters actually do is store a _value_ and allow recalculation of that -value through a _formula_. While there are technically three layers of -parameters (`Project`, `Database` and `Activity`), the AB encourages users to -only use two (`Project` and `Activity`). - -Note that each parameter has a _name_ which is unique within their 'group'. -So project parameters have unique names within the project and activity -parameters have names unique within their 'group'. The Activity Browser will -strongly enforce this uniqueness and won't allow name changes if a conflict -exists. - -The reason for this uniqueness is that a parameter _name_ can be used in -_formulas_ to insert the _value_ of that parameter at that specific place -in the _formula_. - -### Project parameters - -A new project parameter can be created by clicking the `New` button next -to the 'Project' label. A default name is assigned to this parameter which -can later be altered (renaming) by right-clicking on the parameter and -selecting the `Rename parameter` option from the drop-down menu that opens. - -Both the _amount_ and _formula_ fields can be edited by double-clicking -in them, though keep in mind that the _formula_ field has precedence when -determining the _amount_ of the parameter. - -Finally, a (project) parameter can be deleted by right-clicking on the -parameter and selecting the `Delete parameter` option from the drop-down -menu. Do note however that a parameter can __only__ be deleted if it is -not being used in any other _formula_ field, if the Activity Browser finds -that this __is__ the case, the `Delete` option will be grayed out. - -### Activity Parameters - -Where project parameters can be used by any formula anywhere in the project, -activity parameters are a lot more narrow in scope. These parameters are made -to target the specific exchanges that exist within the activity that is -parameterized. - -There are some rules for activity parameters: - -* Multiple parameters can be created for one activity. - -* Exchanges for activity B __cannot__ use parameters created for activity A. - -* Activity parameters must have a unique name __within__ the 'group' of the - related activity. Two parameters on __different__ activities can have the - same name. - -* If a project parameter and an activity parameter share a name, the activity - parameter will be preferred if that name is used in a formula for one of the - exchanges. - -In the Activity Browser activity parameters can be created in two ways: - -1. The first way is through dragging-and-dropping activities from the activities - table on the left side into the activity parameters table on the right side. - This allows for an easy way of parameterizing multiple activities at once. - However, the user still has to go into each activity (by way of the Activity - Detail tab) and parameterize the relevant exchanges. - -2. The second way is through directly parameterizing exchanges within the Activity - Detail tab (by editing the _formula_ field). As soon as an exchange formula is - stored, the Activity Browser will generate a new activity parameter for the - related activity. - -Activity parameters can be `Renamed` and `Deleted` through right-clicking the -parameter, much the same as project parameters. Additionally, the Activity -Detail tab can be opened for the parameterized activity by way of the -`Open activities` option. - -## Scenarios diff --git a/activity_browser/docs/wiki/Plugins.md b/activity_browser/docs/wiki/Plugins.md deleted file mode 100644 index 3a70635f7..000000000 --- a/activity_browser/docs/wiki/Plugins.md +++ /dev/null @@ -1,66 +0,0 @@ -Since the `2.8.0` version, Activity Browser supports plugins. -Plugins are a flexible way to add new functionalities to Activity Browser without modifying the software itself. - -The plugin code has been designed and written by [Remy le Calloch](https://github.com/Pan6ora) -(supported by [G-SCOP laboratories](https://g-scop.grenoble-inp.fr/en/laboratory/g-scop-laboratory)) -with revisions from the Activity Browser. - -## Available plugins -> [!CAUTION] -> Plugins are not always developed by Activity Browser maintainers. -> Below are listed plugins from people we know but we do not verify plugins. -> -> **Use plugins at your own risk**. - -| Name | Description | Links | Author(s) | -|:------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------| -| [ScenarioLink](https://github.com/polca/ScenarioLink) | Enables you to seamlessly fetch and reproduce scenario-based LCA databases, such as those generated by [premise](https://github.com/polca/premise) | [anaconda](https://anaconda.org/romainsacchi/ab-plugin-scenariolink), [pypi](https://pypi.org/project/ab-plugin-scenariolink/), [github](https://github.com/polca/ScenarioLink) | Romain Sacchi & Marc van der Meide | -| [ReSICLED](https://github.com/Pan6ora/ab-plugin-ReSICLED) | Evaluating the recyclability of electr(on)ic product for improving product design | [anaconda](https://anaconda.org/pan6ora/ab-plugin-resicled), [github](https://github.com/Pan6ora/ab-plugin-ReSICLED) | G-SCOP Laboratory | -| [Notebook](https://github.com/Pan6ora/ab-plugin-Notebook) | Use Jupyter notebooks from AB | [anaconda](https://anaconda.org/pan6ora/ab-plugin-template), [github](https://github.com/Pan6ora/ab-plugin-Notebook) | Rémy Le Calloch | -| [template](https://github.com/Pan6ora/activity-browser-plugin-template) | An empty plugin to start from | [anaconda](https://anaconda.org/pan6ora/ab-plugin-template), [github](https://github.com/Pan6ora/activity-browser-plugin-template) | Rémy Le Calloch | - -## Installation -### Detailed instructions -Every plugin's webpage (links are provided in the above table) should have a **Get this plugin** section with installation instructions. - -### General instructions -Plugins are often conda packages (like the Activity Browser). -To add a plugin, install it in your conda environment. - -> [!TIP] -> add `-c conda-forge` to the install command like below to avoid problems with dependencies. -> -> ```bash -> conda activate ab -> conda install -c pan6ora -c conda-forge ab-plugin-notebook -> ``` - -## Usage -Once a new plugin is installed restart the Activity Browser. - -> [!IMPORTANT] -> If you need help using a plugin or experience problems when using a plugin, -> contact the developers of the plugin, the Activity Browser team cannot help you. - -### Enabling a plugin -Plugins are enabled **per project**. -Simply open the plugin manager in the `Tools > Plugins` menu. -Select the plugins you want to use and close the plugin manager. -New tabs should have appeared in Activity Browser for each plugin. - -### Disabling a plugin -Disable a plugin the same way you activated it. - -> [!WARNING] -> Keep in mind that all data created by the plugin in a project could be erased when you disable it. - -## Developing a plugin -> [!IMPORTANT] -> The plugin system is still in development so keep in mind that things may change at any point. - -To add your plugin to the list above either open an issue, or a pull request. -All submitted plugins will be reviewed, although all risks associated with their use shall be born by the user. - -The best place to start to create new plugins is the -[plugin template](https://github.com/Pan6ora/activity-browser-plugin-template). -Its code and README will help you to understand how to create a plugin. diff --git a/activity_browser/docs/wiki/Projects.md b/activity_browser/docs/wiki/Projects.md deleted file mode 100644 index 5395e0e6c..000000000 --- a/activity_browser/docs/wiki/Projects.md +++ /dev/null @@ -1,85 +0,0 @@ -Projects are one of the many ways in which [Brightway](https://docs.brightway.dev/en/latest/) helps you structure your data. -A project is a standalone environment in which you store your -[LCI databases](Databases), [Impact Categories](Impact-Categories), [Calculation Setups](LCA-Calculation-Setups) and any other data. -Data that is stored in project _One_ cannot be used in project _Two_ and vice versa. -Use this to your advantage in any way you like. - -[Read more about data organization in Brightway...](Getting-Started#organization-of-data-in-brightway-and-activity-browser) - -Projects are stored separately from your Activity Browser installation in a folder dependent on your operating system -and user preferences. -This means you can install multiple version of Activity Browser and access the same projects. -It also means that removing Activity Browser is not going to remove projects or fix any issues related to your project. - -If you want to know where a particular project is stored, check the Activity Browser console window, which will display -the folder for the current project when you open it. - -## Selecting a project -When you launch the Activity Browser you will be dropped into your startup project, Brightway's "default" project by -default. -You can always see what project you are in by checking the window title bar, the toolbar at the bottom of the -screen, or see what project is selected in the drop-down menu at the top of the `Project` tab. - -You can switch between projects in one of two ways: through the `Project` > `Open project` menu in the main menu bar -or you can either choose a project from `Project` tab's drop-down menu. - -## Creating a new project -You can create a new project by either `Project` > `New` menu in the main menu bar -or by clicking the `New` button at the top of the `Project` tab. - -You'll be asked to provide a unique name for your new project, after which the Activity Browser will create and switch \ -to your new project and allow you to set-up your project in any way you like. - -[Read more about setting up a project...](Getting-Started#setting-up-a-project) - -## Deleting a project -You can delete your current project by either the -`Project` > `Delete` menu in the main menu bar or by clicking the `Delete` button at the top of the `Project` tab. - -You will be asked for confirmation and whether you want to delete the project folder from the disk as well. -If you do not delete your project from disk, Brightway will just unregister the project, which will hide it from the project selection -menus, but the data is preserved in the project folder mentioned above. -If you choose to delete it from disk entirely, the project and its data are removed entirely. - -> [!WARNING] -> Deleting a project from disk can not be undone. -> -> Make sure you anticipate the consequences of deleting a projecgt before doing so! - -## Duplicating a project -You can duplicate your current project by either the `Project` > `Duplicate` menu in the main menu bar -or clicking the `Duplicate` button at the top of the Project tab. - -You will be asked to provide a unique name for your duplicate project, after which the Activity Browser will switch -to the duplicated project. -This feature is useful if you want to test out anything that may break your data, by first duplicating your project -you ensure that your data is preserved if you want to return to it. - -## Exporting a project -You can export your entire project to a `.tar.gz` archive file. -This archive will contain all data stored within the project like -[LCI databases](Databases), [Impact Categories](Impact-Categories), [Calculation Setups](LCA-Calculation-Setups) and any other data. -You can export your project through the `Project` > `Export this project` menu in the -main menu bar. - -You will be asked for a location the `.tar.gz` archive with your project data should be saved to. - -> [!NOTE] -> Exporting may take a while to complete, especially for large projects with many databases. - -## Importing a project -Similarly, you can also import a project that has been exported to a `.tar.gz` archive. -You can import the project through the `Project` > `Import a project` menu in the main menu bar. - -You will be prompted for a unique project name, after which the project will be installed and the Activity Browser will -switch to your imported project. - -## Brightway25 projects -> [!IMPORTANT] -> Brightway25 is not yet officially supported by the Activity Browser. -> Projects created using Brightway25 use a different structure -> and managing them through the Activity Browser may cause breaking issues. -> Brightway25 projects are shown in the project selection menus, but cannot yet be used in Activity Browser. - -If you know what you're doing feel free to enable these projects by setting the `AB_BW25` environmental variable. -Needless to say this is at your own risk, we will not provide you with support for this yet. diff --git a/activity_browser/docs/wiki/Settings.md b/activity_browser/docs/wiki/Settings.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Settings.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Tutorials.md b/activity_browser/docs/wiki/Tutorials.md deleted file mode 100644 index 340c32f97..000000000 --- a/activity_browser/docs/wiki/Tutorials.md +++ /dev/null @@ -1,343 +0,0 @@ - - -# Contents - -- [General](#general) - - [Your first LCA](#your-first-lca) - -# General - -## Your first LCA -### What will you do -In this tutorial we will create a simple product system and perform a calculation. -We will create a database and create activities and connect them to eachother. -Next, we will create a calculation setup and perform a calculation. - -The system we will create will be a simplified system to create electricity from coal. -The data we use should not be used for any studies, it is just educational. - -### Before you start -> [!IMPORTANT] -> Make sure you have/know the following: -> - [x] Know the basics of LCA -> - [x] [Know common brightway terminology](https://docs.brightway.dev/en/latest/content/overview/glossary.html) -> - [x] [Know how Brightway organizes data](Getting-Started#organization-of-data-in-brightway-and-activity-browser) -> - [x] [Have a working installation of Activity Browser](Installation-Guide) -> - [x] [Have a project set up](Getting-Started#setting-up-a-project) - -### 1. Create a new database -To create a product system, we first need a place in the project where to put it. -For this, we use databases. - -- Click the `New Database...` button, this will open a popup. -- In the window, fill in the name _'first lca tutorial'_, confirm. -- We now see the new database in the `DatabasesPane` table on the left. - -![create a database](./assets/_tutorials/first_lca_tutorial_create_db.png) - -### 2. Create the system -Now that we have a database, we can start creating activities, which will be stored in the database. - -#### 2.1 Creating activities -Lets create the first activity: -To assess the environmental impact of generating electricity from coal, we need to model the production of -electricity and coal first. - -- Right-click on your new database in the `DatabasesPane` table and choose `New activity`. - -![create an activity context menu](./assets/_tutorials/first_lca_tutorial_create_act_context.png) - -- Name your new activity _'electricity production, coal'_, confirm. -- You now see your new activity in the `Activity Details` tab on the right and in the database on the left. - -This tab shows all information about an activity. - -Your `Activity Details` tab should look like below: - -![activity details - new activity](./assets/_tutorials/first_lca_tutorial_act_details_1.png) - -> [!TIP] -> You can only edit activities when the database is not set to `Read-only` and the activity is set to `Edit Activity`. -> You can set these in the `DatabasesPane` table and in the top left of the `Activity Details` tab respectively. -> This is done to avoid accidental changes. -> -> Every time you close an `Activity Details` tab, the editing state will be reset, when you open the activity again you -> need to re-enable editing to continue to make changes. -> -> Your changes are saved automatically. - -Now, we can fill in information in the empty activity. - -- In the `Products` table, change the `Product` name to _'electricity'_ and `Unit` to _'kilowatt hour'_. -- Your changes are saved automatically when you press `Enter` or when you click somewhere else. -- Optionally set the `Location` to your favourite country, the default is `GLO` for _'global'_. - -The top part of your activity should now look like this: - -![activity details - product name](./assets/_tutorials/first_lca_tutorial_act_details_2.png) - -> [!NOTE] -> Locations and Units in Brightway and Activity Browser are 'just' text to help you organize your activities. -> Locations and Units don't have an inherent meaning or relationships to each other. - -#### 2.2 Linking activities -Now that we have one activity, we need to link a biosphere flow to it. - -- In the `DatabasesPane` table in the `Project` tab on the left, open the database _'biosphere3'_ by double-clicking on it. - -> [!NOTE] -> All databases that you have open in a project are shown as tabs underneath the `DatabasesPane` table. - -- Search for _'carbon dioxide, fossil'_ in the database. -- In the `Activity Details` enable the (still empty) `Biosphere Flows` table by ticking the box. -- Drag the one of the _'carbon dioxide, fossil'_ biosphere flows to the `Biosphere Flows` table, the value of the `category` column does not matter right now. -- Set the `Amount` to 0.9 kilogram by double-clicking on the `Amount` field and changing the value. - - Remember that changes are saved automatically. - -> [!TIP] -> You can resize the tables in the activity details by dragging the 'splitter' between them up or down. - -#### 2.3 Finishing your system -Of course, electricity cannot be generated from nothing, so we need to -add the production of coal as a new activity to the system. - -We will essentially repeat the steps in 2.1 and 2.2 with other data. -If you are unsure about something specific, just read back to find the information. - -- Create a new activity named _'coal mining'_ with as `Product` name _'coal'_ and as `Unit` _'kilogram'_. -- Again add the _'carbon dioxide, fossil'_ biosphere flow to the activity, set the `Amount` to 0.15 kilogram. -- Now switch back to the `Activity Details` of the activity _'electricity production, coal'_. - - Remember, if you have closed this activity in the mean time, you need to re-enable the `Edit Activty` toggle. -- From the database _'first lca tutorial'_ add the _'coal mining'_ activity you just created to the - `Technosphere Flows` table of the _electricity production_ process. - - Remember to show the `Technosphere Flows` table again before adding the flow. -- Set the `Amount` to 0.4 kilogram. - -The `Activity Details` of the process _'electricity production, coal'_ should look like this now: - -![activity details - finished activity](./assets/_tutorials/first_lca_tutorial_act_details_3.png) - -Now, the mining of coal also takes some electricity, so we need to go back to the coal mining process -and also add electricity as input there - -- Open the _'coal mining'_ activity again and add _'electricity production'_ to the process. -- Set the `Amount` to 0.01 kilowatt hour. - -#### 2.4 Inspecting your system -You have now finished creating a simple product-system for producing electricity from coal! - -It is good practice to inspect if everything in developing your system went correctly. -You can inspect your system in a two ways: -1. Through the `Technosphere Flows` and `Downstream Consumers` tables in `Activity Details`. -2. Through the `Graph Explorer`. - -##### Activity tables -In addition to the input flows from the technosphere and the biosphere, you can also see the -`Downstream Consumers` table at the bottom of the `Activity Details` tab, -which are activities that consume the product your activity produces. - -For the _'electricity production, coal'_ activity, you should see the flow of 0.01 kilowatt hour to _'coal mining'_. - -You can further explore your system from the `Technosphere Flows` and `Downstream Consumers` tables by right-clicking -on a flow and choosing `Open Activity`, which will open the `Activity Details` of that activity. - -> [!NOTE] -> The `Downstream Consumers` table is a Read-only table, you cannot change flows from that table. -> If you want to change a flow, you need to open the activity (Right-click > `Open activity`) and change the flow. - -##### Graph Explorer -You can also look at the supply chain network visually with the `Graph Explorer`. -You can open the graph explorer in two ways: -1. Right-clicking on an activity in a database and choosing `Open activity in Graph Explorer`. -2. In the top left of the `Activity Details` tab by clicking the `Graph Explorer` logo. - -![graph explorer context menu](./assets/_tutorials/first_lca_tutorial_db_graph_explorer.png) - -The `Graph Explorer` will show a visual representation of your system. -If you hover on the flows (arrows), you will see the amount of the flow. - -Our system should look similar to the following: - -![graph explorer context menu](./assets/_tutorials/first_lca_tutorial_graph_explorer.png) - -### 3 Creating a calculation setup -Now that we created a product-system, we can calculate its environmental impact. - -A calculation setup exists of at least one reference product and at least one impact category. - -- On the right, open the tab `LCA setup`. -- Click `New`, name your calculation setup _'first calculation setup'_. -- On the left, find your activity _'electricity production'_ in the database we created - and drag it to the `Reference flows` table. -- Next, on the left, open the tab `Impact Categories` and search for _'GWP100'_ and choose one of the impact categories, - for this tutorial, it does not matter which one. -- Drag it to the `Impact categories` table. - -Your calculation setup should now look like this: - -![lca setup](./assets/_tutorials/first_lca_tutorial_lca_setup.png) - -### 4 Running an LCA calculation -Now you are ready to calculate results. - -- Click the `Calculate` button on the top left of the `LCA Setup` tab. - -When Activity Browser finished the calculation, it will automatically open the `LCA results` tab on the right. -Your results should now look like this: - -![lca results](./assets/_tutorials/first_lca_tutorial_lca_results.png) - -Congratulations! You have successfully calculated your first LCA. - -> [!NOTE] -> The activities you see in there `Reference flows` table are linked to your system, if you change your system, the changes are saved automatically. -> Do keep in mind that you do need to re-calculate your results every time you make changes. - -### 5 (Optional) Extending your system -The above tutorial is not completely realistic, next, we will add three optional steps: - -1. Adding some detail: additional activities -2. Adding more impact categories: different ways of assessing your system -3. Adding an alternative: a different way of producing electricity - -You don't need to follow all steps, but the next steps build on each other, so you need to follow them in order. - -#### 5.1 Adding detail -To add detail, we will add an additional activity to produce steel. - -- Create a new activity _'steel production'_ and make the `Product` name 'steel' and as `Unit` 'kilogram', set the `Amount` to 2. -- Producing steel itself emits some carbon dioxide, but we also need some coal. -- Add a coal input of 0.5 kilogram and a carbon dioxide flow of 0.5 kilogram - -If you don't recall exactly how to add these, go back to step 2.1-2.3 above. - -Producing electricity does not require steel directly, but of course machines and a building would be needed. -We can represent this in LCA with a very small flow of steel to other processes to represent the depreciation of the machines. - -- Add a _'steel'_ flow of 0.001 kilogram to both _'electricity production, coal'_ and _'coal production'_ -- Now recalculate your results (step 4 above). - -You have now extended your system! -If you want, you can add more activities and flows to make your system more realistic. - -#### 5.2 Adding more impact categories -LCA often compares products based on different categories, not just climate change impact, -we will add an additional impact category to measure water use. - -First, lets add an impact category to the calculation setup - -- In the impact categories and search _'water use'_ and add it to the calculation setup. - -If you don't recall exactly how to do this, go back to step 3 above. - -Even though we just added the impact category, we can't yet calculate results. -This is because our system does not yet have any water flows, so the impact would be zero. -We will now add the water use biosphere flow to the system and then re-calculate results. - -- Search in the _'biosphere3'_ table for _'water'_ -- You will see _many_ results, we don't want to search through all of these results, but we can manually filter the results further. -- In the column `Categories`, click the funnel button and write _'air'_. -- Activity Browser will filter all results in the column `Categories` for entries that contain _'air'_. -- Now you have much fewer results -- Choose the flow _'water'_, _'air'_ and add it to the activity _'electricity production, coal'_, set the `Amount` to 0.0001 - -Now, we can re-calculate the results - -- Go to the `LCA Setup` tab and re-calculate the results. -- In the top of the `LCA Results` tab you can `Choose impact category`, where you can switch to the water use. -- You can now see the impact of water use on your system. - -You have now added a new impact category! -If you want, you can add more flows and impact categories to make your system more realistic and assess different kinds of impacts. - -#### 5.3 Adding an alternative -LCA often compares different alternatives as well, we will add an alternative way of producing electricity to compare the two. - -We will add an alternative production of electricity, based on natural gas - -- Create a new activity _'natural gas production'_ and make the `Product` name 'natural gas' and as `Unit` 'megajoule'. -- Add an input of 0.002 kilogram of _'steel'_. -- Add a biosphere flow of _'methane, fossil'_ (choose one) of 0.01 kilogram. -- Create a new activity _'electricity production, natural gas'_ and make the `Product` name 'electricity' and as `Unit` 'kilowatt hour'. -- Add an input of 10 megajoule of _'natural gas'_ and 0.002 kilogram of _'steel'_. -- Also add a biosphere flow of _'carbon dioxide, fossil'_ of 0.7 kilogram. - -If you don't recall exactly how to add these, go back to step 2.1-2.3 above. - -We now have two different ways of producing electricity, from coal and natural gas. - -- Now, add the _'electricity'_ flow from _'electricity production, natural gas'_ to the calculation setup. - -If you don't recall exactly how to do this, go back to step 3 above. - -Finally, we can now re-calculate the results and compare these two alternatives. - -- Calculate the results -- Switch to the impact category _'water use'_ (as you did in step 5.2) - - You will see that there is some water impact from the natural gas-based electricity. - This is because the steel we use in these activities is made with electricity from coal, which affects the impact. - -You have now added a new alternative! -If you want, you can add more alternatives to make assess different methods of electricity production. diff --git a/activity_browser/docs/wiki/Uncertainty.md b/activity_browser/docs/wiki/Uncertainty.md deleted file mode 100644 index 930b261ba..000000000 --- a/activity_browser/docs/wiki/Uncertainty.md +++ /dev/null @@ -1,54 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -## Uncertainty - -___ -## Monte Carlo simulation -[Monte Carlo Simulation](https://en.wikipedia.org/wiki/Monte_Carlo_method) is method that relies on repeated random sampling of data to produce numerical results for uncertain input data. In LCA, economic and environmental flows as well as other data such as characterization factors or parameters may include uncertainty information (e.g. mathematical distributions or pedigree scores). During Monte Carlo simulation, random samples of this data are generated to calculate LCA results. - -In the Activity Browser, Monte Carlo Simulation can be used. The **steps **for this are: -1. To create a [calculation setup](https://github.com/LCA-ActivityBrowser/activity-browser/wiki#creating-a-calculation-setup) and perform a (static, i.e. non-stochastic) LCA. -2. Then the user should go to the `Monte Carlo` tab -3. Then the following settings are available - -* Here the users needs to specify the number of **iterations** (we recommend to start with at least 100). -* A **random seed** can be determined, which can be used to reproduce the same random values again. -* Finally, the user interface provides the option for **including or excluding uncertainty information** at the level of the technology matrix (technosphere), the interventions matrix (biosphere), the characterization factors, and parameters (if any have been defined by the practitioner). - -![overview monte carlo setup](./assets/overview_monte_carlo_setup.jpg) - -An example for Monte Carlo Simulation results are shown below. - -![monte carlo results](./assets/monte_carlo_results.jpg) - -___ -## Global Sensitivity Analysis -### Overview -Global Sensitivity Analysis (GSA) is a family of methods that aim to determine which input variables are contributing the most to variations in the outcome of a stochastic model. In the context of Life Cycle Assessment (LCA), this means that GSA aims at identifying those variables (e.g. economic flows, environmental flows, characterization factors, or parameters) that due to their uncertainty distributions affect LCA results most. This provides the LCA practitioner with a shortlist of important variables for his model. For some of these variables, it may be possible to collect additional data to reduce uncertainties, which may then reduce the overall uncertainties of the LCA results. - -The **AB implements the delta-moment independent method** to calculate the global sensitivities. The approach is described in detail in our [scientific paper](https://onlinelibrary.wiley.com/doi/10.1111/jiec.13194). Our implementation uses the Sensitivity Analysis Library [SALib](https://github.com/SALib/SALib). - -Here we describe the basic steps for performing GSA with the Activity Browser. - - -### Step 1: creating a calculation setup and calculating LCA results -[How to create a calculation setup](https://github.com/LCA-ActivityBrowser/activity-browser/wiki#creating-a-calculation-setup) - -### Step 2: performing Monte Carlo Simulation -Monte Carlo simulation needs to be performed in order to obtain sampled data for the LCA inputs (economic and environmental flows, characterization factors, and parameters) and the corresponding LCA results, which, together, form the required input data for the GSA. A description of how to perform Monte Carlo Simulation in the AB is provided [here](https://github.com/LCA-ActivityBrowser/activity-browser/wiki/Monte-Carlo-Simulation). - -### Step 3: Global Sensitivity Analysis -Now the user can go to the `Sensitivity Analysis` tab to perform GSA. The figure below shows the options the user has at this level. -* While the Monte Carlo Simulation was performed for all reference flows and impact categories at once, the GSA is performed for one reference flow and impact category at a time. This means that the user needs to **select the reference flow and impact categories** that he is interested in. GSA can be repeated later for other reference flows or impact categories based on the same Monte Carlo Simulation results. -* The user can specify the **cut-off values **used for flows in the A (technosphere) and B (biosphere) matrices. -* Finally, the user can **select to export both input and output data** to the GSA. If the user does not select this option, he will later only have the option to export the output data. - -![global sensitivity analysis setup](./assets/overview_global_sensitivity_analysis_setup.jpg) - -After the GSA is performed, the user will see a table with all input variables (environmental, economic flows, characterization factors and parameters) sorted by their delta value, which is the result of the GSA and characterizes their overall relevance. Additional data and metadata is also provided in the table. - -![GSA results only delta](./assets/global_sensitivity_analysis_results.jpg) diff --git a/activity_browser/docs/wiki/_Footer.md b/activity_browser/docs/wiki/_Footer.md deleted file mode 100644 index 153ff8e07..000000000 --- a/activity_browser/docs/wiki/_Footer.md +++ /dev/null @@ -1,13 +0,0 @@ -Activity Browser is a __community project__, we rely on __you__ for it to be awesome. - -| Activity Browser logo | ❓ Need help?
[💬 Ask the community](https://github.com/LCA-ActivityBrowser/activity-browser/discussions?discussions_q=) | 💡 Ideas to improve?
[💭 Request a feature](https://github.com/LCA-ActivityBrowser/activity-browser/issues/new?assignees=&labels=feature&projects=&template=feature_request.yml) | 🔥 Something Broken?
[🪲 Start a bug report](https://github.com/LCA-ActivityBrowser/activity-browser/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml) | ⚙️ Want to help out?
[🛠️ Learn how to contribute](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md) | -|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| - -> [!TIP] -> **Activity Browser** now has an open beta for Version 3🚀. -> -> The beta supports many new features such as Multi-functionality and uses Brightway 2.5 under the hood. -> Help us by making Activity Browser even better by using and providing feedback on Activity Browser. -> -> Learn more about the beta -> [here](https://lca-activitybrowser.github.io/activity-browser/beta.html). diff --git a/activity_browser/docs/wiki/_Sidebar.md b/activity_browser/docs/wiki/_Sidebar.md deleted file mode 100644 index a2d61f385..000000000 --- a/activity_browser/docs/wiki/_Sidebar.md +++ /dev/null @@ -1,65 +0,0 @@ -### Navigation -___ -○ [**🏠 Home**](Home) - -
⁉️ Getting Started & Help - -- [Installation Guide](Installation-Guide) -- [Getting Started](Getting-Started) -- [Need Help?](Need-Help) -
- -
🎓 Tutorials - - -- [Your First LCA](Tutorials#your-first-lca) -
- -___ -○ [**📚 Projects**](Projects) - -○ [**📒 DatabasesPane**](Databases) - -○ [**🧾 Activities**](Activities) - -○ [**🌍 Impact Categories**](Impact-Categories) - -
🧮 LCA calculation setup - -- [Overview](LCA-Calculation-Setups) -- [Scenarios](Flow-Scenarios) -- [Parameters](Parameters#scenarios) -
- -
📊 LCA results - -- [Overview](LCA-Results#overview) -- [Contribution Analysis](LCA-Results#contribution-analysis) -- [Sankey](LCA-Results#sankey) -
- -___ - -○ [**🔁 Graph Explorer**](Graph-Explorer) - -○ [**🧩 Plugins**](Plugins) - -
🚀 Advanced topics - --
🎰 Uncertainty in LCA - - - [Uncertainty](Uncertainty) - - [Monte Carlo Simulation](Uncertainty#monte-carlo-simulation) - - [Global Sensitivity Analysis](Uncertainty#global-sensitivity-analysis) -
- --
📈 Scenarios - - - [Flow Scenarios](Flow-Scenarios) - - [Parameter Scenarios](Parameters) -
-
- -○ [**⚙️ Settings**](Settings) diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_1.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_1.png deleted file mode 100755 index 0f31ef261..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_1.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_2.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_2.png deleted file mode 100755 index 5280b7933..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_2.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_3.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_3.png deleted file mode 100755 index 8bbf6ab29..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_3.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_act_context.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_act_context.png deleted file mode 100755 index e5c665ab8..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_act_context.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_db.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_db.png deleted file mode 100755 index 029e62ecc..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_db.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_db_graph_explorer.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_db_graph_explorer.png deleted file mode 100755 index 7305186b8..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_db_graph_explorer.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_graph_explorer.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_graph_explorer.png deleted file mode 100755 index bb922210d..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_graph_explorer.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_results.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_results.png deleted file mode 100755 index a72a46a8c..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_results.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_setup.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_setup.png deleted file mode 100755 index eee28a897..000000000 Binary files a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_setup.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/activitybrowser.png b/activity_browser/docs/wiki/assets/activitybrowser.png deleted file mode 100644 index 7a947cdab..000000000 Binary files a/activity_browser/docs/wiki/assets/activitybrowser.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/brightway_org-scheme.png b/activity_browser/docs/wiki/assets/brightway_org-scheme.png deleted file mode 100644 index 04f79fde6..000000000 Binary files a/activity_browser/docs/wiki/assets/brightway_org-scheme.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/ca_positive_negative_example.png b/activity_browser/docs/wiki/assets/ca_positive_negative_example.png deleted file mode 100644 index 7b2155bfa..000000000 Binary files a/activity_browser/docs/wiki/assets/ca_positive_negative_example.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/contribution_manipulation.png b/activity_browser/docs/wiki/assets/contribution_manipulation.png deleted file mode 100644 index 4beaab453..000000000 Binary files a/activity_browser/docs/wiki/assets/contribution_manipulation.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/global_sensitivity_analysis_results.jpg b/activity_browser/docs/wiki/assets/global_sensitivity_analysis_results.jpg deleted file mode 100644 index 47aeae0cf..000000000 Binary files a/activity_browser/docs/wiki/assets/global_sensitivity_analysis_results.jpg and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/monte_carlo_results.jpg b/activity_browser/docs/wiki/assets/monte_carlo_results.jpg deleted file mode 100644 index 5e0597d51..000000000 Binary files a/activity_browser/docs/wiki/assets/monte_carlo_results.jpg and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/overview_global_sensitivity_analysis_setup.jpg b/activity_browser/docs/wiki/assets/overview_global_sensitivity_analysis_setup.jpg deleted file mode 100644 index 7880b038c..000000000 Binary files a/activity_browser/docs/wiki/assets/overview_global_sensitivity_analysis_setup.jpg and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/overview_monte_carlo_setup.jpg b/activity_browser/docs/wiki/assets/overview_monte_carlo_setup.jpg deleted file mode 100644 index 61089533f..000000000 Binary files a/activity_browser/docs/wiki/assets/overview_monte_carlo_setup.jpg and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_bio_vsn.png b/activity_browser/docs/wiki/assets/project_setup_dialog_bio_vsn.png deleted file mode 100755 index 1820814fe..000000000 Binary files a/activity_browser/docs/wiki/assets/project_setup_dialog_bio_vsn.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_choose_type.png b/activity_browser/docs/wiki/assets/project_setup_dialog_choose_type.png deleted file mode 100755 index 8ba17eca7..000000000 Binary files a/activity_browser/docs/wiki/assets/project_setup_dialog_choose_type.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_ei_login.png b/activity_browser/docs/wiki/assets/project_setup_dialog_ei_login.png deleted file mode 100755 index 4294afc33..000000000 Binary files a/activity_browser/docs/wiki/assets/project_setup_dialog_ei_login.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_ei_vsn_and_model.png b/activity_browser/docs/wiki/assets/project_setup_dialog_ei_vsn_and_model.png deleted file mode 100755 index 10e25cf2b..000000000 Binary files a/activity_browser/docs/wiki/assets/project_setup_dialog_ei_vsn_and_model.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/project_tab_until_databases.png b/activity_browser/docs/wiki/assets/project_tab_until_databases.png deleted file mode 100755 index fec74c67e..000000000 Binary files a/activity_browser/docs/wiki/assets/project_tab_until_databases.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/sankey_example.svg b/activity_browser/docs/wiki/assets/sankey_example.svg deleted file mode 100644 index 07aff177b..000000000 --- a/activity_browser/docs/wiki/assets/sankey_example.svg +++ /dev/null @@ -1,481 +0,0 @@ - -coal(1.86%)electricity(35.6%)coal(1.86%)steel productionGLO(63%)coal productionGLO(4%)electricity productionGLO(34%) diff --git a/activity_browser/docs/wiki/assets/sdf_addition_combination.png b/activity_browser/docs/wiki/assets/sdf_addition_combination.png deleted file mode 100644 index a46647f66..000000000 Binary files a/activity_browser/docs/wiki/assets/sdf_addition_combination.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/sdf_product_combination.png b/activity_browser/docs/wiki/assets/sdf_product_combination.png deleted file mode 100644 index dbecb6288..000000000 Binary files a/activity_browser/docs/wiki/assets/sdf_product_combination.png and /dev/null differ diff --git a/activity_browser/docs/wiki/assets/steel_production_example.svg b/activity_browser/docs/wiki/assets/steel_production_example.svg deleted file mode 100644 index 5c3b5b0e3..000000000 --- a/activity_browser/docs/wiki/assets/steel_production_example.svg +++ /dev/null @@ -1,373 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/activity_browser/info.py b/activity_browser/info.py index 98a8b45bc..fcf3ba875 100644 --- a/activity_browser/info.py +++ b/activity_browser/info.py @@ -1,11 +1,4 @@ -import ast -import os.path from importlib.metadata import PackageNotFoundError, version -from loguru import logger - -from .utils import safe_link_fetch, sort_semantic_versions - - # get AB version try: diff --git a/activity_browser/mod/README.md b/activity_browser/mod/README.md new file mode 100644 index 000000000..a1d206dc2 --- /dev/null +++ b/activity_browser/mod/README.md @@ -0,0 +1,58 @@ +# mod + +Monkey-patches and modifications to third-party libraries used by Activity Browser. + +## Overview + +This module contains patches and modifications to external libraries to fix bugs, add features, or adapt functionality for Activity Browser's specific needs. These modifications are applied at import time. + +## Directory Structure + +- **`bw2analyzer/`** - Patches for brightway2-analyzer +- **`bw2io/`** - Patches for brightway2-io +- **`ecoinvent_interface/`** - Patches for ecoinvent-interface +- **`peewee/`** - Patches for peewee ORM +- **`pyprind/`** - Patches for pyprind progress bars +- **`tqdm/`** - Patches for tqdm progress bars + +## Key Files + +- **`__init__.py`** - Imports all patched modules, replacing the original imports +- **`patching.py`** - Core patching utilities and helpers + +## How It Works + +When Activity Browser imports this module, it automatically imports the patched versions of external libraries. These patches are typically applied to: + +1. **Fix bugs** that haven't been addressed upstream +2. **Add Qt integration** for progress bars and UI elements +3. **Adapt functionality** to work better within a GUI context +4. **Add features** needed by Activity Browser but not available in the base libraries + +## Import Pattern + +The module is imported early in Activity Browser's initialization: + +```python +import activity_browser.mod.bw2analyzer as bw2analyzer +import activity_browser.mod.bw2io as bw2io +``` + +This ensures that the patched versions are used throughout the application. + +## Development Notes + +- Patches should be minimally invasive +- Document why each patch is needed +- Consider contributing fixes upstream when appropriate +- Test patches thoroughly as they modify external library behavior +- Keep patches up-to-date with upstream library versions + +## Warning + +Modifying third-party libraries can lead to maintenance challenges. Use this approach sparingly and only when: +- The issue can't be solved in Activity Browser code +- Upstream changes are not accepted or released +- The modification is essential for Activity Browser functionality + +Always prefer upstream contributions over local patches when possible. diff --git a/activity_browser/mod/bw2analyzer/README.md b/activity_browser/mod/bw2analyzer/README.md new file mode 100644 index 000000000..2c21210fb --- /dev/null +++ b/activity_browser/mod/bw2analyzer/README.md @@ -0,0 +1,185 @@ +# bw2analyzer + +Monkey-patches for brightway2-analyzer library. + +## Overview + +This directory contains modifications and patches to the brightway2-analyzer library. These patches fix bugs, add features, or adapt functionality specifically for Activity Browser's needs. + +## Key Files + +- **`contribution.py`** - Patches for contribution analysis functions +- **`__init__.py`** - Module initialization and patch application + +## Purpose + +Brightway2-analyzer provides LCA analysis tools including: +- Contribution analysis +- Graph traversal +- Tagged exchanges +- Monte Carlo analysis helpers + +Activity Browser patches this library to: +- Fix issues not yet addressed upstream +- Add GUI-specific functionality +- Improve performance for interactive use +- Handle edge cases + +## Contribution Analysis Patches + +The `contribution.py` file likely patches contribution analysis to: +- Handle large result sets more efficiently +- Provide progress callbacks for GUI +- Fix calculation edge cases +- Add sorting and filtering options +- Improve memory usage + +## Common Patches + +### Progress Callbacks +Add callbacks for long-running operations: +```python +def contribution_analysis(lca, progress_callback=None): + # Original function doesn't support callbacks + # Patch adds progress updates for GUI + for i, item in enumerate(items): + if progress_callback: + progress_callback(i / len(items) * 100) + # ... process item +``` + +### Error Handling +Improve error messages for GUI context: +```python +def patched_function(*args, **kwargs): + try: + return original_function(*args, **kwargs) + except Exception as e: + # Convert to user-friendly error + raise ABError(f"Analysis failed: {str(e)}") +``` + +### Performance Optimizations +Speed up operations for interactive use: +```python +def optimized_function(data): + # Add caching for repeated calls + cache_key = hash_data(data) + if cache_key in cache: + return cache[cache_key] + result = expensive_operation(data) + cache[cache_key] = result + return result +``` + +## Patch Application + +Patches are applied when the module is imported: + +```python +# In activity_browser/mod/__init__.py +import activity_browser.mod.bw2analyzer as bw2analyzer +``` + +This replaces the original bw2analyzer with the patched version. + +## Development Guidelines + +When adding patches: + +1. **Minimal changes** - Only patch what's necessary +2. **Document reasons** - Explain why each patch is needed +3. **Track upstream** - Monitor if fix is applied upstream +4. **Version awareness** - Handle different bw2analyzer versions +5. **Test thoroughly** - Ensure patches don't break existing functionality +6. **Consider alternatives** - Can it be done in AB code instead? + +## Contribution Analysis + +Typical contribution analysis patches might include: + +### Cutoff Support +```python +def contribution_analysis(lca, cutoff=0.01): + """Add cutoff parameter to limit results.""" + # Original doesn't support cutoff + # Patch filters results below threshold +``` + +### Sorting Options +```python +def contribution_analysis(lca, sort_by='amount'): + """Add sorting parameter.""" + # Original returns unsorted + # Patch adds sorting by amount, name, or impact +``` + +### Result Formatting +```python +def contribution_analysis(lca, format='dict'): + """Control output format.""" + # Original returns specific format + # Patch allows choosing format (dict, list, DataFrame) +``` + +## Testing Patches + +Test patches with: +- Unit tests for patched functions +- Integration tests with real LCA data +- Comparison with original behavior +- Edge cases and error conditions +- Performance benchmarks + +## Maintenance + +When updating Activity Browser: + +1. **Check brightway2-analyzer version** - New version may fix issues +2. **Review patches** - Are they still needed? +3. **Test compatibility** - Ensure patches work with new version +4. **Update if needed** - Adjust patches for API changes +5. **Contribute upstream** - Submit fixes to brightway2-analyzer + +## Alternative to Patching + +Instead of patching, consider: +- Wrapping functions in AB code +- Using composition instead of modification +- Contributing fixes directly to brightway2 +- Using configuration/options if available + +Patching should be last resort when: +- Upstream fix is not available +- Functionality is GUI-specific +- Performance optimization is needed +- Workaround is required + +## Risks of Patching + +Be aware that patches: +- May break with upstream updates +- Can cause confusion (behavior differs from docs) +- Require maintenance +- May conflict with other patches +- Complicate debugging + +## Documentation + +Always document: +- What is patched +- Why it's patched +- When it can be removed +- Any side effects +- Upstream issue tracking + +## Contributing Upstream + +When possible, contribute patches upstream: +1. Open issue on brightway2-analyzer +2. Propose fix or enhancement +3. Submit pull request +4. Maintain patch until merged +5. Remove patch once in released version + +This benefits the entire Brightway community and reduces AB maintenance burden. diff --git a/activity_browser/settings.py b/activity_browser/settings.py deleted file mode 100644 index 5655c1d3b..000000000 --- a/activity_browser/settings.py +++ /dev/null @@ -1,325 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import os -import shutil -from pathlib import Path -from typing import Optional, Any -from loguru import logger - -import bw2data as bd - -import platformdirs -from qtpy.QtWidgets import QMessageBox - -from .app import signals - - -DEFAULT_BW_DATA_DIR = bd.projects._base_data_dir - - -def pathlib_encoder(value: Any) -> Any: - if isinstance(value, Path): - return str(value) - else: - return value - - -class BaseSettings(object): - """Base Class for handling JSON settings files.""" - - def __init__(self, directory: str, filename: str = None): - self.data_dir = directory - self.filename = filename or "default_settings.json" - self.settings_file = os.path.join(self.data_dir, self.filename) - self.settings: Optional[dict] = None - self.initialize_settings() - - @classmethod - def get_default_settings(cls) -> dict: - """Returns dictionary containing the default settings for the file""" - raise NotImplementedError - - def restore_default_settings(self) -> None: - """Undo all user settings and return to original state.""" - self.settings = self.get_default_settings() - self.write_settings() - - def initialize_settings(self) -> None: - """Attempt to find and read the settings_file, creates a default - if not found - """ - if os.path.isfile(self.settings_file): - self.load_settings() - else: - self.settings = self.get_default_settings() - self.write_settings() - - def load_settings(self) -> None: - with open(self.settings_file, "r") as infile: - self.settings = json.load(infile) - - def write_settings(self) -> None: - with open(self.settings_file, "w") as outfile: - json.dump(self.settings, outfile, indent=4, sort_keys=True, default=pathlib_encoder) - - -class ABSettings(BaseSettings): - """ - Interface to the json settings file. Will create a userdata directory via platformdirs if not - already present. - """ - - def __init__(self, filename: str): - ab_dir = str(platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser")) - if not os.path.isdir(ab_dir): - os.makedirs(ab_dir, exist_ok=True) - self.update_old_settings(ab_dir, filename) - - # Currently loaded plugins objects as: - # {plugin_name: , ...} - # this list is generated at startup and never writen in settings. - # it is filled by the plugin controller - self.plugins = {} - - super().__init__(ab_dir, filename) - - if not self.healthy(): - logger.warn("Settings health check failed, resetting") - self.restore_default_settings() - - def healthy(self) -> bool: - """ - Checks the settings file to see if it is healthy. Returns True if all checks pass, otherwise returns False. - """ - healthy = True - - # check for write access to the current bw dir - healthy = healthy and os.access(self.settings.get("current_bw_dir"), os.W_OK) - - # check for write access to the custom bw dirs - access = [os.access(path, os.W_OK) for path in self.settings.get("custom_bw_dirs")] - healthy = healthy and False not in access - - return healthy - - @staticmethod - def update_old_settings(directory: str, filename: str) -> None: - """Recycling code to enable backward compatibility: This function is only required for compatibility - with the old settings file and can be removed in a future release - """ - file = os.path.join(directory, filename) - if not os.path.exists(file): - package_dir = Path(__file__).resolve().parents[1] - old_settings = os.path.join(package_dir, "ABsettings.json") - if os.path.exists(old_settings): - shutil.copyfile(old_settings, file) - if os.path.isfile(file): - with open(file, "r") as current: - current_settings = json.load(current) - if "current_bw_dir" not in current_settings: - new_settings_content = { - "current_bw_dir": current_settings["custom_bw_dir"], - "custom_bw_dirs": [current_settings["custom_bw_dir"]], - "startup_project": current_settings["startup_project"], - } - with open(file, "w") as new_file: - json.dump(new_settings_content, new_file, default=pathlib_encoder) - - @classmethod - def get_default_settings(cls) -> dict: - """Using methods from the commontasks file to set default settings""" - return { - "current_bw_dir": cls.get_default_directory(), - "custom_bw_dirs": [cls.get_default_directory()], - "startup_project": cls.get_default_project_name(), - } - - @property - def custom_bw_dir(self) -> str: - """Returns the custom brightway directory, or the default""" - return self.settings.get("custom_bw_dirs", self.get_default_directory()) - - @property - def current_bw_dir(self) -> str: - """Returns the current brightway directory""" - return self.settings.get("current_bw_dir", self.get_default_directory()) - - @current_bw_dir.setter - def current_bw_dir(self, directory: str) -> None: - self.settings["current_bw_dir"] = directory - self.write_settings() - - @custom_bw_dir.setter - def custom_bw_dir(self, directory: str) -> None: - """Sets the custom brightway directory to `directory`""" - if directory not in self.settings["custom_bw_dirs"]: - self.settings["custom_bw_dirs"].append(directory) - self.write_settings() - - def remove_custom_bw_dir(self, directory: str) -> None: - """Removes the brightway directory to 'directory'""" - try: - self.settings["custom_bw_dirs"].remove(directory) - self.write_settings() - except KeyError as e: - QMessageBox.warning( - self, - f"Error while attempting to remove a brightway environmental dir: {e}", - ) - - @property - def startup_project(self) -> str: - """Get the startup project from the settings, or the default""" - project = self.settings.get("startup_project", self.get_default_project_name()) - if project and project not in bd.projects: - project = self.get_default_project_name() - return project - - @startup_project.setter - def startup_project(self, project: str) -> None: - """Sets the startup project to `project`""" - self.settings.update({"startup_project": project}) - - @staticmethod - def get_default_directory() -> str: - """Returns the default brightway application directory""" - return DEFAULT_BW_DATA_DIR - - @staticmethod - def get_default_project_name() -> Optional[str]: - """Returns the default project name.""" - if "default" in bd.projects: - return "default" - elif len(bd.projects): - return next(iter(bd.projects)).name - else: - return None - - @property - def theme(self) -> str: - """Returns the current brightway directory""" - return self.settings.get("theme", "Light theme") - - @theme.setter - def theme(self, new_theme: str) -> None: - self.settings.update({"theme": new_theme}) - - -class ProjectSettings(BaseSettings): - """ - Handles user settings which are specific to projects. Created initially to handle read-only/writable database status - Code based on ABSettings class, if more different types of settings are needed, could inherit from a base class - - structure: singleton, loaded dependent on which project is selected. - Persisted on disc, Stored in the BW2 projects data folder for each project - a dictionary1 of dictionaries2 - Dictionary1 keys are settings names (currently just 'read-only-databases'), values are dictionary2s - Dictionary2 keys are database names, values are bools - - For now, decided to not include saving writable-activities to settings. - As activities are identified by tuples, and saving them to json requires extra code - https://stackoverflow.com/questions/15721363/preserve-python-tuples-with-json - This is currently not worth the effort but could be returned to later - - """ - - def __init__(self, filename: str): - # on selection of a project (signal?), find the settings file for that project if it exists - # it can be a custom location, based on ABsettings. So check that, and if not, use default? - # once found, load the settings or just an empty dict. - self.connect_signals() - super().__init__(bd.projects.dir, filename) - - bd.projects.dir.joinpath("activity_browser").mkdir(exist_ok=True) - - # https://github.com/LCA-ActivityBrowser/activity-browser/issues/235 - # Fix empty settings file and populate with currently active databases - if "read-only-databases" not in self.settings: - self.settings.update(self.process_brightway_databases()) - self.write_settings() - if "plugins_list" not in self.settings: - self.settings.update({"plugins_list": []}) - self.write_settings() - - def connect_signals(self): - - # Reload the project settings whenever a project switch occurs. - signals.project.changed.connect(self.reset_for_project_selection) - - # save new plugin for this project - signals.plugin_selected.connect(self.add_plugin) - - @classmethod - def get_default_settings(cls) -> dict: - """Return default empty settings dictionary.""" - settings = cls.process_brightway_databases() - settings["plugins_list"] = [] - return settings - - @staticmethod - def process_brightway_databases() -> dict: - """Process brightway database list and return new settings dictionary. - - NOTE: This ignores the existing database read-only settings. - """ - return {"read-only-databases": {name: True for name in bd.databases.list}} - - def reset_for_project_selection(self) -> None: - """On switching project, attempt to read the settings for the new - project. - """ - logger.info(f"Project settings directory: {bd.projects.dir}") - - bd.projects.dir.joinpath("activity_browser").mkdir(exist_ok=True) - - self.settings_file = os.path.join(bd.projects.dir, self.filename) - self.initialize_settings() - # create a plugins_list entry for old projects - if "plugins_list" not in self.settings: - self.settings.update({"plugins_list": []}) - self.write_settings() - - def add_db(self, db_name: str, read_only: bool = True) -> None: - """Store new databases and relevant settings here when created/imported""" - self.settings["read-only-databases"].setdefault(db_name, read_only) - self.write_settings() - - def modify_db(self, db_name: str, read_only: bool) -> None: - """Update write-rules for the given database""" - self.settings["read-only-databases"].update({db_name: read_only}) - self.write_settings() - - def remove_db(self, db_name: str) -> None: - """When a database is deleted from a project, the settings are also deleted.""" - self.settings["read-only-databases"].pop(db_name, None) - self.write_settings() - - def db_is_readonly(self, db_name: str) -> bool: - """Check if given database is read-only, defaults to yes.""" - return self.settings["read-only-databases"].get(db_name, True) - - def get_editable_databases(self): - """Return list of database names where read-only is false - - NOTE: discards the biosphere3 database based on name. - """ - iterator = self.settings.get("read-only-databases", {}).items() - return (name for name, ro in iterator if not ro and name != "biosphere3") - - def add_plugin(self, name: str, select: bool = True): - """Add a plugin to settings or remove it""" - if select: - self.settings["plugins_list"].append(name) - self.write_settings() - return - if name in self.settings["plugins_list"]: - self.settings["plugins_list"].remove(name) - self.write_settings() - - def get_plugins_list(self): - """Return a list of plugins names""" - return self.settings["plugins_list"] - - -ab_settings = ABSettings("ABsettings.json") -project_settings = ProjectSettings("AB_project_settings.json") diff --git a/activity_browser/static/README.md b/activity_browser/static/README.md new file mode 100644 index 000000000..630b86ffd --- /dev/null +++ b/activity_browser/static/README.md @@ -0,0 +1,63 @@ +# static + +Static resources for the Activity Browser application. + +## Overview + +This directory contains all static assets used by Activity Browser including HTML templates, stylesheets, icons, fonts, JavaScript libraries, and other non-code resources. + +## Directory Structure + +- **`css/`** - Cascading Style Sheets for HTML views +- **`database_classifications/`** - Database classification mappings and schemas +- **`fonts/`** - Font files used in the application +- **`icons/`** - Application icons in various formats and sizes +- **`javascript/`** - JavaScript libraries and scripts for web views +- **`startscreen/`** - Start screen assets and templates + +## HTML Templates + +- **`activity_graph.html`** - Template for activity relationship graph visualization +- **`navigator.html`** - Base navigator template +- **`sankey_navigator.html`** - Sankey diagram visualization template +- **`spinner.html`** - Loading spinner template +- **`tree_navigator.html`** - Tree structure navigator template + +## Purpose + +These static resources support: + +1. **Visualization** - Interactive graphs, Sankey diagrams, and charts +2. **Branding** - Application icons and logo +3. **Styling** - Consistent look and feel across web views +4. **Classification** - Database and activity classification systems +5. **User Experience** - Welcome screens, loading indicators, navigation + +## Web Views + +Activity Browser embeds web views (Qt WebEngine) for rich interactive visualizations. These HTML templates use JavaScript libraries to render: + +- Force-directed graphs showing activity relationships +- Sankey diagrams for flow visualization +- Tree navigators for hierarchical data exploration +- Interactive charts and plots + +## Resource Loading + +Static resources are accessed via: + +```python +from pathlib import Path + +static_dir = Path(__file__).parent.resolve() / "static" +icon_path = static_dir / "icons" / "main_icon.png" +``` + +## Maintenance + +When adding new static resources: +- Place files in the appropriate subdirectory +- Ensure proper licensing for third-party assets +- Optimize file sizes (compress images, minify CSS/JS) +- Document dependencies and versions for JavaScript libraries +- Include resources in `MANIFEST.in` for packaging diff --git a/activity_browser/static/css/README.md b/activity_browser/static/css/README.md new file mode 100644 index 000000000..b44887c96 --- /dev/null +++ b/activity_browser/static/css/README.md @@ -0,0 +1,245 @@ +# css + +Cascading Style Sheets for Activity Browser's HTML views. + +## Overview + +This directory contains CSS files that style the HTML-based visualizations and web views in Activity Browser. These stylesheets control the appearance of graphs, Sankey diagrams, tree navigators, and other interactive visualizations. + +## Files + +- **`navigator.common.css`** - Common styles shared across navigators +- **`navigator.css`** - Base navigator styles +- **`activity_graph.css`** - Activity relationship graph styles +- **`sankey_navigator.css`** - Sankey diagram visualization styles +- **`tree_navigator.css`** - Tree structure navigator styles + +## Purpose + +These stylesheets provide: +- **Consistent appearance** - Unified look across all visualizations +- **Responsive design** - Adapt to different window sizes +- **Interactive styling** - Hover effects, selections, highlights +- **Theme support** - Match Activity Browser's overall design +- **Accessibility** - Readable colors, proper contrast + +## Common Patterns + +### Node Styling +```css +.node { + fill: #4a90e2; + stroke: #2c5aa0; + stroke-width: 2px; + cursor: pointer; +} + +.node:hover { + fill: #5da5ff; + stroke-width: 3px; +} + +.node.selected { + stroke: #ff6b6b; + stroke-width: 4px; +} +``` + +### Edge/Link Styling +```css +.link { + stroke: #999; + stroke-opacity: 0.6; + fill: none; +} + +.link:hover { + stroke-opacity: 1; + stroke-width: 3px; +} +``` + +### Text Styling +```css +.label { + font-family: Arial, sans-serif; + font-size: 12px; + fill: #333; + pointer-events: none; +} +``` + +## navigator.common.css + +Shared styles for all navigators: +- Layout and positioning +- Controls and buttons +- Tooltips +- Loading indicators +- Error messages + +## activity_graph.css + +Styles for activity relationship graphs: +- Node appearance (activities) +- Edge appearance (exchanges/relationships) +- Labels and annotations +- Graph controls (zoom, pan) +- Legend styling + +## sankey_navigator.css + +Styles for Sankey diagrams: +- Flow paths (width proportional to amount) +- Node boxes +- Flow colors (by category) +- Tooltips showing values +- Legend and scale + +## tree_navigator.css + +Styles for tree structures: +- Tree nodes (collapsible) +- Branches/connections +- Expand/collapse icons +- Indentation levels +- Selection highlighting + +## Color Schemes + +### Default Colors +- **Primary**: Blue (#4a90e2) +- **Secondary**: Green (#2ecc71) +- **Warning**: Orange (#f39c12) +- **Error**: Red (#e74c3c) +- **Neutral**: Gray (#95a5a6) + +### Category Colors +Different colors for flow types: +- **Technosphere**: Blue +- **Biosphere**: Green +- **Production**: Orange +- **Substitution**: Purple + +## Responsive Design + +Stylesheets adapt to window size: + +```css +@media (max-width: 768px) { + .node { + /* Smaller nodes on small screens */ + r: 4px; + } + + .label { + /* Smaller text on small screens */ + font-size: 10px; + } +} +``` + +## Interactive States + +### Hover States +Visual feedback when hovering: +```css +.interactive:hover { + opacity: 0.8; + cursor: pointer; +} +``` + +### Selection States +Highlight selected items: +```css +.selected { + stroke: #ff6b6b; + stroke-width: 3px; + filter: drop-shadow(0 0 5px rgba(255, 107, 107, 0.5)); +} +``` + +### Disabled States +Gray out disabled elements: +```css +.disabled { + opacity: 0.4; + cursor: not-allowed; +} +``` + +## Animations + +Smooth transitions: +```css +.node { + transition: all 0.3s ease; +} + +.link { + transition: stroke-width 0.2s ease, stroke-opacity 0.2s ease; +} +``` + +## Tooltips + +Styled tooltips for data display: +```css +.tooltip { + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 1000; +} +``` + +## Development Guidelines + +When modifying CSS: + +1. **Test in web view** - Not just browser (Qt WebEngine may differ) +2. **Use CSS variables** - For easy theme changes +3. **Mobile-first** - Design for smallest screens first +4. **Performance** - Avoid expensive effects on many elements +5. **Accessibility** - Maintain contrast ratios (WCAG AA) +6. **Cross-browser** - Test in different rendering engines +7. **Documentation** - Comment complex selectors +8. **Organization** - Group related styles +9. **Naming** - Use clear, descriptive class names +10. **Validation** - Run through CSS validator + +## CSS Variables + +Use CSS custom properties for theming: +```css +:root { + --primary-color: #4a90e2; + --text-color: #333; + --background-color: #fff; + --hover-opacity: 0.8; +} + +.node { + fill: var(--primary-color); +} +``` + +## Browser Compatibility + +Ensure compatibility with Qt WebEngine: +- Test rendering in actual application +- Check vendor prefixes +- Verify CSS feature support +- Test on all platforms (Windows, macOS, Linux) + +## Resources + +- [MDN CSS Reference](https://developer.mozilla.org/en-US/docs/Web/CSS) +- [CSS-Tricks](https://css-tricks.com/) +- [Can I Use](https://caniuse.com/) - Feature compatibility +- [D3.js Styling](https://d3js.org/) - SVG styling patterns diff --git a/activity_browser/static/icons/README.md b/activity_browser/static/icons/README.md new file mode 100644 index 000000000..ec6d698ec --- /dev/null +++ b/activity_browser/static/icons/README.md @@ -0,0 +1,214 @@ +# icons + +Application icons and graphical assets. + +## Overview + +This directory contains all icon files used throughout Activity Browser, including the application icon, toolbar icons, menu icons, and node type indicators. + +## Directory Structure + +- **`main/`** - Main application icon in various sizes and formats +- **`context/`** - Context menu icons +- **`nodes/`** - Node type icons (for graph visualizations) +- **`metaprocess/`** - Meta-process related icons + +## File Formats + +Icons are typically provided in multiple formats: +- **PNG** - Raster format with transparency (various sizes: 16x16, 24x24, 32x32, 48x48, 256x256) +- **SVG** - Vector format (scalable without quality loss) +- **ICO** - Windows icon format (contains multiple sizes) +- **ICNS** - macOS icon format + +## main/ + +Main application icon used for: +- Application window icon +- Taskbar/dock icon +- Desktop shortcut icon +- About dialog +- Installer icon + +Sizes provided: +- 16x16 - Taskbar, title bar +- 24x24 - Small toolbar buttons +- 32x32 - Medium toolbar buttons, list views +- 48x48 - Large icons +- 256x256 - High DPI displays, splash screen +- 512x512 - macOS Retina displays + +## context/ + +Icons for context menu actions: +- Copy +- Paste +- Delete +- Edit +- Open +- Save +- Export +- Import +- Search +- Refresh +- Settings + +## nodes/ + +Icons representing different node types in graphs: +- Activity nodes +- Product nodes +- Biosphere flow nodes +- Technosphere flow nodes +- Waste flow nodes +- Substitution nodes + +## metaprocess/ + +Icons for meta-process operations: +- Aggregation +- Disaggregation +- Grouping +- Filtering + +## Icon Loading + +Icons are loaded via `activity_browser/ui/icons.py`: + +```python +from activity_browser.ui.icons import get_icon + +# Load icon by name +icon = get_icon("save") + +# Use in action +action = QAction(get_icon("open"), "Open", parent) + +# Use in button +button = QPushButton(get_icon("delete"), "Delete") +``` + +## Icon Themes + +Activity Browser may support multiple icon themes: +- Light theme (dark icons on light background) +- Dark theme (light icons on dark background) +- High contrast theme (for accessibility) + +## Icon Design Guidelines + +When creating or modifying icons: + +### Size and Resolution +- Provide multiple sizes (16, 24, 32, 48, 256) +- Ensure clarity at smallest size (16x16) +- Use even dimensions for pixel-perfect rendering +- Support high DPI (2x, 3x scales) + +### Style Consistency +- Match existing icon style +- Use consistent line weights +- Maintain similar level of detail +- Use the same color palette + +### Visual Clarity +- Simple, recognizable shapes +- Clear at small sizes +- Sufficient contrast +- Not too much detail + +### Accessibility +- Work in light and dark themes +- Sufficient contrast ratios +- Distinct shapes (not just color differences) +- Test with colorblindness simulators + +### File Optimization +- Optimize PNG files (use tools like pngcrush) +- Clean up SVG files (remove unnecessary elements) +- Use transparency appropriately +- Keep file sizes small + +## Color Palette + +Standard colors used in Activity Browser icons: +- **Primary**: Blue (#4a90e2) +- **Success**: Green (#2ecc71) +- **Warning**: Orange (#f39c12) +- **Error**: Red (#e74c3c) +- **Info**: Cyan (#3498db) +- **Neutral**: Gray (#95a5a6) + +## Platform-Specific Icons + +### Windows +- Use ICO format for application icon +- Provide 16, 32, 48, 256 sizes in single ICO file +- Follow Windows icon guidelines + +### macOS +- Use ICNS format for application icon +- Provide 16, 32, 128, 256, 512, 1024 sizes +- Follow macOS icon guidelines +- Support Retina displays + +### Linux +- Use PNG for application icon +- Provide standard sizes: 16, 24, 32, 48, 64, 128, 256 +- Follow freedesktop.org icon naming spec +- Install to appropriate directories + +## Icon Attribution + +If using third-party icons: +- Check license compatibility (LGPL-compatible) +- Provide attribution if required +- Document source and license +- Consider creating custom icons instead + +## Tools for Icon Creation + +Recommended tools: +- **Inkscape** - Free vector graphics editor (SVG) +- **GIMP** - Free raster graphics editor (PNG) +- **ImageMagick** - Batch processing and conversion +- **icon-resizer** - Generate multiple sizes from SVG + +## Updating Icons + +When updating icons: +1. Edit source SVG file +2. Export to required PNG sizes +3. Optimize files +4. Generate platform-specific formats (ICO, ICNS) +5. Update icons in all directories +6. Test in application on all platforms +7. Verify high DPI rendering +8. Check light and dark themes + +## Icon Resources + +Free icon sources (check licenses): +- [Font Awesome](https://fontawesome.com/) +- [Material Icons](https://material.io/icons/) +- [Feather Icons](https://feathericons.com/) +- [Heroicons](https://heroicons.com/) + +## Testing Icons + +Test icons: +- At all sizes (16px to 256px) +- On different backgrounds +- With different themes +- On high DPI displays +- On all platforms +- In actual UI contexts + +## Maintenance + +Keep icons: +- Up-to-date with design trends +- Consistent with application style +- Optimized for performance +- Properly licensed +- Version controlled diff --git a/activity_browser/ui/README.md b/activity_browser/ui/README.md new file mode 100644 index 000000000..665e545aa --- /dev/null +++ b/activity_browser/ui/README.md @@ -0,0 +1,86 @@ +# ui + +Core UI components and widgets for the Activity Browser interface. + +## Overview + +This module contains reusable UI components, custom widgets, dialog windows, wizards, and web views that make up the Activity Browser user interface. These components are built using Qt (PySide6) via the qtpy compatibility layer. + +## Directory Structure + +- **`core/`** - Core UI classes including the application class, threading, and tree models +- **`delegates/`** - Qt item delegates for custom cell rendering in tables and trees +- **`dialogs/`** - Dialog windows for various user interactions +- **`widgets/`** - Reusable custom widget components + +## Key Files + +- **`icons.py`** - Icon loading and management utilities + +## Core Components + +### `core/` +- **`application.py`** - `ABApplication` class extending QApplication with global shortcuts +- **`threading.py`** - Worker threads for background operations +- **`tree_model.py`** - Custom tree models for hierarchical data +- **`mimedata.py`** - Custom MIME data for drag-and-drop operations + +### `widgets/` +Custom reusable widgets: +- **`abstract_page.py`** - Base class for main content pages +- **`abstract_pane.py`** - Base class for dock panes +- **`buttons.py`** - Custom button widgets +- **`combobox.py`** - Enhanced combo box widgets +- **`tree_view.py`** - Custom tree view components +- **`table_view.py`** - Custom table view components +- **`plot.py`** - Plotting widgets +- And many more specialized widgets... + +## Design Patterns + +### Abstract Base Classes +Many widgets inherit from abstract base classes: +- `AbstractPage` - For main content area pages +- `AbstractPane` - For dock-able side panels + +### Custom Widgets +Widgets extend Qt base classes to add: +- Custom styling and appearance +- Application-specific behavior +- Signal/slot connections +- Validation and error handling + +### Delegates +Item delegates customize table/tree cell rendering and editing: +- Custom editors for specific data types +- Validation during inline editing +- Formatted display of values + +## Qt Integration + +All UI components use **qtpy** for Qt compatibility: + +```python +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt, Signal, Slot +``` + +This allows flexibility in Qt bindings (PySide6, PyQt6, etc.). + +## Usage Pattern + +Import widgets as needed: + +```python +from activity_browser.ui.widgets import AbstractPage +from activity_browser.ui.core.application import ABApplication +from activity_browser.ui.dialogs import MyDialog +``` + +## Development Guidelines + +- Inherit from abstract base classes when appropriate +- Use qtpy imports for Qt compatibility +- Keep widgets reusable and decoupled from application logic +- Follow Qt naming conventions (camelCase for methods) +- Emit signals for state changes rather than direct calls diff --git a/activity_browser/ui/core/README.md b/activity_browser/ui/core/README.md new file mode 100644 index 000000000..23cff1dca --- /dev/null +++ b/activity_browser/ui/core/README.md @@ -0,0 +1,178 @@ +# core + +Core UI classes and utilities for the Activity Browser interface. + +## Overview + +This directory contains fundamental UI classes that provide the foundation for Activity Browser's user interface, including the main application class, threading utilities, tree models, and MIME data handling. + +## Key Files + +### `application.py` +**ABApplication** - Main Qt application class + +Extends `QApplication` with Activity Browser-specific functionality: +- **Global shortcuts** - Register keyboard shortcuts across the application +- **Main window reference** - Centralized access to main window +- **Application lifecycle** - Startup, shutdown, event handling +- **Style management** - Application-wide styling and theming + +```python +from activity_browser.ui.core.application import ABApplication + +app = ABApplication() +app.main_window = main_window + +@app.global_shortcut("Ctrl+S") +def save_action(): + # Triggered by Ctrl+S anywhere in the app + pass +``` + +### `threading.py` +**ABThread** - Worker thread for background operations + +Provides threading utilities to keep the UI responsive: +- Run long operations in background +- Progress reporting +- Thread-safe signal emission +- Cancellation support + +```python +from activity_browser.ui.core.threading import ABThread + +def long_operation(progress_callback): + for i in range(100): + # Do work + progress_callback(i) + +worker = ABThread(long_operation) +worker.progress.connect(update_progress_bar) +worker.finished.connect(on_complete) +worker.start() +``` + +### `tree_model.py` +Custom tree models for hierarchical data display + +Implements Qt's model/view architecture for tree structures: +- **Efficient data handling** - Lazy loading of tree nodes +- **Custom data roles** - Additional data beyond display +- **Drag and drop** - Support for tree item manipulation +- **Filtering and sorting** - Built-in data organization + +```python +from activity_browser.ui.core.tree_model import TreeModel + +model = TreeModel(root_data) +tree_view.setModel(model) +``` + +### `mimedata.py` +Custom MIME data for drag-and-drop operations + +Defines MIME types for Activity Browser data: +- Activities +- Exchanges +- Databases +- Methods +- Parameters + +Enables drag-and-drop between different parts of the UI: + +```python +from activity_browser.ui.core.mimedata import ActivityMimeData + +# Create MIME data +mime = ActivityMimeData(activity_key) + +# Set on drag operation +drag = QDrag(widget) +drag.setMimeData(mime) +``` + +## Architecture Patterns + +### Application Singleton +`ABApplication` is a singleton accessed throughout the app: +```python +from activity_browser import app + +app.application # The ABApplication instance +``` + +### Threading Pattern +Long operations follow this pattern: +1. Create worker thread with target function +2. Connect signals (progress, finished, error) +3. Start thread (non-blocking) +4. Update UI via signals when complete + +### Model/View Pattern +Tree and table data uses Qt's model/view: +- **Model** - Data management and business logic +- **View** - Data display and user interaction +- **Delegate** - Custom cell rendering and editing + +## Global Shortcuts + +Register shortcuts using the decorator: +```python +@app.application.global_shortcut("Ctrl+F") +def find_action(): + # Search dialog or functionality + pass +``` + +Shortcuts are automatically attached when `app.main_window` is set. + +## Development Guidelines + +### Threading +- **Never block the main thread** - Use ABThread for slow operations +- **Update UI from main thread only** - Use signals to communicate back +- **Handle errors gracefully** - Catch exceptions in worker threads +- **Support cancellation** - Allow users to abort long operations + +### Models +- **Lazy loading** - Load data only when needed +- **Efficient updates** - Use beginInsertRows/endInsertRows properly +- **Custom roles** - Define additional data roles for internal use +- **Sort/filter proxies** - Use QSortFilterProxyModel for filtering + +### MIME Data +- **Use specific MIME types** - Define clear types for each data kind +- **Include sufficient data** - Store enough info for the drop target +- **Check compatibility** - Validate MIME data before accepting drops + +## Performance Considerations + +### Tree Models +- Implement lazy loading for large trees +- Cache frequently accessed data +- Use flat data structures when possible +- Batch updates with begin/end calls + +### Threading +- Pool threads for multiple small operations +- Cancel obsolete operations when new ones start +- Clean up thread resources properly +- Monitor thread count to avoid resource exhaustion + +## Signal/Slot Connections + +Core classes emit important signals: + +**ABThread**: +- `started` - Thread began execution +- `progress(int)` - Progress update (0-100) +- `finished` - Thread completed successfully +- `error(Exception)` - Thread encountered an error + +**TreeModel**: +- `dataChanged` - Model data was modified +- `rowsInserted` - New rows added +- `rowsRemoved` - Rows deleted +- `modelReset` - Model structure changed completely + +Connect to these signals to keep UI synchronized with data changes. diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index c0c3c5392..9f783575f 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -1,11 +1,13 @@ from typing import Optional -from collections import defaultdict - from loguru import logger + import pandas as pd -from PySide6.QtCore import QModelIndex, Qt + +from PySide6 import QtGui +from PySide6.QtCore import QModelIndex, Qt, QAbstractItemModel from PySide6.QtWidgets import QWidget -from PySide6.QtCore import QAbstractItemModel + +from activity_browser.ui.icons import qicons class TreeNode: @@ -50,10 +52,24 @@ def can_fetch_more(self) -> bool: class ABTreeModel(QAbstractItemModel): - def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: + def __init__(self, + df: pd.DataFrame = None, + parent: Optional[QWidget] = None, + chunk_size: int = -1, + enable_sorting: bool = False + ) -> None: super().__init__(parent) self.df = df if df is not None else pd.DataFrame() + self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) + self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered + self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon + self.grouped_columns: list[str] = [] # list of columns currently used for grouping + + self.sorted_column: str | None = None + self.sort_order = Qt.SortOrder.AscendingOrder + self.sorting_enabled = enable_sorting + self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -162,8 +178,8 @@ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 #--- data overrides --- def data(self, index: QModelIndex, role: int = Qt.DisplayRole): - if not index.isValid() or self.df.empty: - return None + # if not index.isValid() or self.df.empty: + # return None if role == Qt.DisplayRole: return self.displayData(index) @@ -182,10 +198,10 @@ def data(self, index: QModelIndex, role: int = Qt.DisplayRole): def displayData(self, index: QModelIndex) -> any: node = index.internalPointer() - + if not isinstance(node, TreeNode): return None - + if not node.is_leaf: # branch node # For branch nodes, show the name in the first column only # (spanning will be handled by the view) @@ -194,10 +210,9 @@ def displayData(self, index: QModelIndex) -> any: if index.column() == 0: return None # leaf node tree column is empty - # Use the pre-computed df_position for O(1) iloc access - col_idx = index.column() - 1 # Adjust for tree column - if col_idx < 0 or col_idx >= len(self.df.columns): - return None + # Get the pandas column index (disregard hidden columns) + col_name = self.columns()[index.column()] + col_idx = self.df.columns.get_loc(col_name) val = self.df.iat[node.df_position, col_idx] @@ -269,13 +284,22 @@ def isBranchNode(self, index: QModelIndex) -> bool: return not node.is_leaf def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): - if orientation == Qt.Vertical or not role == Qt.DisplayRole: + if orientation == Qt.Vertical: return None - - if section == 0: - return "index" - - return self.df.columns[section - 1] + + if role == Qt.DisplayRole: + if section == 0: + return "" + + return self.columns()[section] + + if role == Qt.ItemDataRole.FontRole and section in self.filtered_columns: + font = QtGui.QFont() + font.setUnderline(True) + return font + + if role == Qt.ItemDataRole.DecorationRole and section in self.filtered_columns: + return qicons.filter def canFetchMore(self, parent: QModelIndex) -> bool: """Check if this parent has more children that can be loaded.""" @@ -318,6 +342,125 @@ def fetchMore(self, parent: QModelIndex) -> None: self.endInsertRows() # --- helper functions --- + def set_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: + self.beginResetModel() + + self.df = df + self.grouped_columns = group or self.grouped_columns + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.endResetModel() + + def update_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: + self.layoutAboutToBeChanged.emit() + self.df = df + self.grouped_columns = group or self.grouped_columns + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def group(self, columns: list[str] = None) -> None: + self.layoutAboutToBeChanged.emit() + self.grouped_columns = columns or self.grouped_columns + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def ungroup(self) -> None: + self.layoutAboutToBeChanged.emit() + self.grouped_columns = [] + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + if not self.sorting_enabled: + logger.warning(f"Called sort() on {self.__class__.__name__} with sorting disabled.") + return + + self.layoutAboutToBeChanged.emit() + + self.sorted_column = self.headerData(column) if isinstance(column, int) else column + self.sort_order = order + + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def filter(self, key: str = None, query: str = None) -> None: + """Filter the DataFrame based on a simple substring match across all columns.""" + self.layoutAboutToBeChanged.emit() + + if query is not None and key is not None: + self.df_query[key] = query + + self.apply_filter() + + self.layoutChanged.emit() + + def build_df_index(self): + # dataframe we will use to build the new index + df = self.df[self.grouped_columns].copy() + + # unpack iterables in the grouped columns + for col in self.grouped_columns: + # Check if the column contains iterables (excluding strings) + sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else None + if not isinstance(sample_val, (list, tuple, set)): + continue + + # Unpack the iterable into separate columns and add to the dataframe + unpacked = pd.DataFrame(df[col].tolist(), index=df.index) + for i, unpacked_col in enumerate(unpacked.columns): + df[f"{col}_{i}"] = unpacked[unpacked_col] + + # Remove the original column from the dataframe + df = df.drop(columns=[col]) + + df["index"] = range(len(df)) + + new_index = pd.MultiIndex.from_frame(df) + new_index.names = [i + "_i" for i in new_index.names] + + self.df.index = new_index + + def reset_hierarchy(self, df: pd.DataFrame = None) -> None: + df = df if df is not None else self.df + old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] + + # Rebuild the node hierarchy + self.build_node_hierarchy(df.index) + + # Update persistent indexes + new_persistent = [] + for old_index, old_node in old_persistent_indices: + if isinstance(old_node, TreeNode): + # Try to find the same path in the new hierarchy + new_node = self.node_map.get(old_node.path) + if new_node is not None: + new_index = self.createIndex(new_node.row_in_parent, old_index.column(), new_node) + new_persistent.append(new_index) + else: + new_persistent.append(QModelIndex()) + else: + new_persistent.append(QModelIndex()) + + # Update the model's persistent indexes + self.changePersistentIndexList(self.persistentIndexList(), new_persistent) + def build_node_hierarchy(self, pandas_index: pd.Index) -> None: """ Build the unified TreeNode hierarchy with all information combined: @@ -326,54 +469,56 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: - loaded counts - DataFrame positions """ + self.root = TreeNode(tuple()) self.node_map = {tuple(): self.root} - + # Convert index to frame once for all operations idx_df = pandas_index.to_frame(index=False) - + # Create a mapping from full path to DataFrame position path_to_position = {} - for df_pos, row_tuple in enumerate(idx_df.itertuples(index=False, name=None)): + for row_tuple in idx_df.itertuples(index=False, name=None): + df_pos = self.df.index.get_loc(row_tuple) path_to_position[row_tuple] = df_pos - + # Process each level to build the hierarchy for level in range(idx_df.shape[1]): # Get unique child paths at this level (as tuples) child_paths = idx_df.iloc[:, :level + 1].drop_duplicates() child_tuples = list(child_paths.itertuples(index=False, name=None)) - + for child_path in child_tuples: if pd.isna(child_path[-1]): continue # skip NaN children - + # Skip if we've already created this node if child_path in self.node_map: continue - + # Determine parent path if level == 0: parent_path = tuple() else: parent_path = tuple(val for val in child_path[:-1] if not pd.isna(val)) - + # Get or create parent node parent_node = self.node_map.get(parent_path) if parent_node is None: parent_node = self.root - + # Check if this is a leaf node (full depth) is_leaf = (level == idx_df.shape[1] - 1) df_position = path_to_position.get(child_path, -1) if is_leaf else -1 - + # Create the child node child_node = TreeNode(child_path, df_position) - + # Add child to parent parent_node.add_child(child_node) - + # Store in node map self.node_map[child_path] = child_node - + # Initialize loaded counts if self.lazy: # Load first chunk for each node @@ -383,129 +528,36 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: # All children loaded for node in self.node_map.values(): node.loaded_count = node.total_children() - - def reset_hierarchy(self, df: pd.DataFrame = None) -> None: - df = df if df is not None else self.df - old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] - - # Rebuild the node hierarchy - self.root = TreeNode(tuple()) - self.build_node_hierarchy(df.index) - - # Update persistent indexes - new_persistent = [] - for old_index, old_node in old_persistent_indices: - if isinstance(old_node, TreeNode): - # Try to find the same path in the new hierarchy - new_node = self.node_map.get(old_node.path) - if new_node is not None: - new_index = self.createIndex(new_node.row_in_parent, old_index.column(), new_node) - new_persistent.append(new_index) - else: - new_persistent.append(QModelIndex()) - else: - new_persistent.append(QModelIndex()) - # Update the model's persistent indexes - self.changePersistentIndexList(self.persistentIndexList(), new_persistent) + def apply_filter(self): + pandas_query = " & ".join(self.df_query.values()) + filtered_df = self.df.query(pandas_query) + self.reset_hierarchy(filtered_df) - self.layoutChanged.emit() + def apply_sort(self): + if self.df.empty or not self.sorting_enabled: + return + logger.debug(f"Applying sorting in : {self.__class__.__name__}") - def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: - if self.df.empty: - return # Extract the unique order of higher levels - column_name = self.headerData(column) if column > 0 else None higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] # Build a new index by sorting only within each higher level sorted_index = [] - + for lvl in higher_levels: mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index - partial_df = self.df.loc[mask, column_name or self.df.columns[0]].copy() - if column_name is not None: - partial_df.sort_values(ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) + partial_df = self.df.loc[mask, self.sorted_column or self.df.columns[0]].copy() + if self.sorted_column is not None: + partial_df.sort_values(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), inplace=True) else: - partial_df = partial_df.sort_index(ascending=(order == Qt.SortOrder.AscendingOrder)) + partial_df = partial_df.sort_index(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder)) sorted_index.append(partial_df.index) sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order - self.filter() - - def filter(self, key: str = None, query: str = None) -> None: - """Filter the DataFrame based on a simple substring match across all columns.""" - self.layoutAboutToBeChanged.emit() - if query is not None and key is not None: - self.df_query[key] = query - - pandas_query = " & ".join(self.df_query.values()) - filtered_df = self.df.query(pandas_query) - - self.reset_hierarchy(filtered_df) - self.layoutChanged.emit() - - def set_dataframe(self, df: pd.DataFrame) -> None: - self.beginResetModel() - self.df = df - self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) - - self.reset_hierarchy() - self.endResetModel() - - def group(self, columns: list[str]) -> None: - """Regroup the DataFrame by the specified columns. - - Unpacks columns containing iterables (lists, tuples, sets) by spreading them - into separate columns that become separate levels in the multiindex. - """ - self.layoutAboutToBeChanged.emit() - df = self.df[columns].copy() - - # Build the list of columns for the new index, unpacking iterables - for col in columns: - # Check if the column contains iterables (excluding strings) - sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else None - - if not isinstance(sample_val, (list, tuple, set)): - continue - - # Unpack the iterable into separate columns - unpacked = pd.DataFrame(df[col].tolist(), index=df.index) - - # Name the new columns - unpacked.columns = [f"{col}_{i}" for i in range(len(unpacked.columns))] - - # Add unpacked columns to the dataframe - for unpacked_col in unpacked.columns: - df[unpacked_col] = unpacked[unpacked_col] - - # Remove the original column from the dataframe - df = df.drop(columns=[col]) - - levels = list(df.columns) + list(df.index.names) - - df = df.reset_index() - df = df[levels] - - new_index = pd.MultiIndex.from_frame(df) - new_index.names = [i+"_i" if not i.endswith("_i") else i for i in new_index.names] - - self.df = self.df.set_index(new_index) - self.reset_hierarchy() - self.layoutChanged.emit() - - def ungroup(self) -> None: - """Ungroup the DataFrame by resetting the index.""" - self.layoutAboutToBeChanged.emit() - self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) - self.df.index.name = "index" - self.reset_hierarchy() - self.layoutChanged.emit() - def values_from_indices(self, key: str, indices: list[QModelIndex]): """ Returns the values from the given indices. diff --git a/activity_browser/ui/delegates/README.md b/activity_browser/ui/delegates/README.md new file mode 100644 index 000000000..099aaa61e --- /dev/null +++ b/activity_browser/ui/delegates/README.md @@ -0,0 +1,138 @@ +# delegates + +Qt item delegates for custom cell rendering and editing in tables and trees. + +## Overview + +This directory contains custom Qt delegates that control how data is displayed and edited in table and tree views throughout Activity Browser. Delegates enable specialized rendering, validation, and editing behavior for different data types. + +## What are Delegates? + +In Qt's Model/View architecture, delegates handle: +- **Display** - How data appears in cells (colors, icons, formatting) +- **Editing** - What widget appears when user edits a cell +- **Validation** - Checking user input before accepting +- **Decoration** - Adding icons, colors, or other visual elements + +## Usage Pattern + +Assign delegates to specific columns by defining them in the `defaultColumnDelegates` attribute of the `ABTreeView` class: + +```python +class View(widgets.ABTreeView): + """ + A view that displays the exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + hovered_item (ExchangesItem): The item currently being hovered over. + """ + defaultColumnDelegates = { + "column_name": delegates.DelegateYouWantToUse, + } +``` + + +## Creating Custom Delegates + +Inherit from `QStyledItemDelegate`: + +```python +from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit + +class MyDelegate(QStyledItemDelegate): + def createEditor(self, parent, option, index): + """Create the editing widget.""" + editor = QLineEdit(parent) + editor.setValidator(...) # Add validation + return editor + + def setEditorData(self, editor, index): + """Load data into editor.""" + value = index.data(Qt.EditRole) + editor.setText(str(value)) + + def setModelData(self, editor, model, index): + """Save editor data back to model.""" + value = editor.text() + model.setData(index, value, Qt.EditRole) + + def displayText(self, value, locale): + """Format value for display.""" + return f"{value:.2f}" +``` + +## Key Methods + +### `createEditor(parent, option, index)` +Creates the widget used for editing: +- **parent** - Parent widget for the editor +- **option** - Style options for the item +- **index** - Model index being edited +- **Returns** - Editor widget (QLineEdit, QComboBox, etc.) + +### `setEditorData(editor, index)` +Populates the editor with current value: +- **editor** - The editor widget +- **index** - Model index with data + +### `setModelData(editor, model, index)` +Saves edited value back to model: +- **editor** - The editor widget +- **model** - The data model +- **index** - Model index to update + +### `displayText(value, locale)` +Formats value for display (optional): +- **value** - Raw data value +- **locale** - Locale for formatting +- **Returns** - Formatted string + +### `paint(painter, option, index)` +Custom rendering (advanced): +- **painter** - QPainter for drawing +- **option** - Style options +- **index** - Model index to render + +## Validation + +Add validators to editors: + +```python +def createEditor(self, parent, option, index): + editor = QLineEdit(parent) + validator = QDoubleValidator(0.0, 1000.0, 2, editor) + editor.setValidator(validator) + return editor +``` + +## Signal Handling + +Delegates can emit signals on edits: + +```python +from qtpy.QtCore import Signal + +class MyDelegate(QStyledItemDelegate): + editingFinished = Signal(QModelIndex, object) + + def setModelData(self, editor, model, index): + value = editor.text() + model.setData(index, value) + self.editingFinished.emit(index, value) +``` + +## Development Guidelines + +When creating delegates: + +1. **Inherit from QStyledItemDelegate** - Preferred over QItemDelegate +2. **Validate input** - Add QValidator to editors +3. **Handle edge cases** - Empty values, invalid data, cancellation +4. **Match data types** - Editor should match model data type +5. **Close editor properly** - Emit closeEditor signal when done +6. **Keep it simple** - Complex editing might need a dialog +7. **Test thoroughly** - Verify editing, validation, display +8. **Consider performance** - Efficient for many cells +9. **Support keyboard** - Tab, Enter, Escape navigation +10. **Provide feedback** - Visual cues for invalid input diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index e0fc331ac..c80635da9 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from .checkbox import CheckboxDelegate from .combobox import ComboBoxDelegate -from .database import DatabaseDelegate from .delete_button import DeleteButtonDelegate from .float import FloatDelegate from .json import JSONDelegate @@ -14,13 +13,13 @@ from .date_time import DateTimeDelegate from .property import PropertyDelegate from .amount import AmountDelegate, AbsoluteAmountDelegate +from .card import CardDelegate __all__ = [ "AmountDelegate", "AbsoluteAmountDelegate", "CheckboxDelegate", "ComboBoxDelegate", - "DatabaseDelegate", "DeleteButtonDelegate", "FloatDelegate", "JSONDelegate", @@ -33,4 +32,5 @@ "NewFormulaDelegate", "DateTimeDelegate", "PropertyDelegate", + "CardDelegate", ] diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py new file mode 100644 index 000000000..58218e422 --- /dev/null +++ b/activity_browser/ui/delegates/card.py @@ -0,0 +1,192 @@ +from typing import TypedDict + +from qtpy import QtCore, QtWidgets, QtGui +from qtpy.QtCore import Qt + + +class CardData(TypedDict): + title: str + subtitle: str | None + detail: str | None + categories: list[str] | None + + +class CardDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for rendering card-like items with title, subtitle, categories and background icon.""" + + PADDING = 8 + MARGIN = 2 + TITLE_LINES = 2 + ICON_OPACITY = 0.3 + + def sizeHint(self, option, index): + if index.data() is None: + return super().sizeHint(option, index) + + # Calculate text heights + fm = option.fontMetrics + line_height = fm.height() + + # Title (2 lines, larger font) + title_height = int(line_height * 1 * self.TITLE_LINES) + 5 + + # Subtitle + subtitle_height = int(line_height * 0.9) # 0.9x for smaller font + + # Categories + categories_height = 7 + int(line_height * 0.8) + + # Total height with padding + total_height = (self.PADDING * 2 + self.MARGIN * 2 + + title_height + subtitle_height + categories_height) + + return QtCore.QSize(option.rect.width(), max(total_height, 40)) + + def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): + if index.data() is None: + super().paint(painter, option, index) + return + + painter.save() + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + card_data = index.data() + is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected + font_size = option.font.pointSize() + + # Draw background and border + rect = option.rect.adjusted(self.MARGIN, self.MARGIN, -self.MARGIN, -self.MARGIN) + + # Background + painter.fillRect(rect, option.palette.base()) + + # Border + border_color = option.palette.highlight() if is_selected else option.palette.mid() + painter.setPen(QtGui.QPen(border_color, 1)) + painter.drawRoundedRect(rect, 3, 3) + + # Draw background icon + icon = index.data(Qt.ItemDataRole.DecorationRole) + icon_size = 0 + if icon and not icon.isNull(): + painter.setOpacity(self.ICON_OPACITY) + icon_size = int(rect.height() * 0.8) + icon_x = rect.right() - icon_size - 10 + icon_y = rect.top() + (rect.height() - icon_size) // 2 + icon.paint(painter, icon_x, icon_y, icon_size, icon_size) + painter.setOpacity(1.0) + + # Setup text area + text_rect = rect.adjusted(self.PADDING, self.PADDING, -self.PADDING, -self.PADDING) + y = text_rect.top() + + # Draw title (bold, larger, 2 lines) + title = card_data.get('title', '') + title_font = option.font + title_font.setPointSize(int(option.font.pointSize() * 1)) + title_font.setWeight(QtGui.QFont.Weight.DemiBold) + painter.setFont(title_font) + painter.setPen(option.palette.text().color()) + + title_fm = QtGui.QFontMetrics(title_font) + title_height = 5 + title_fm.height() * self.TITLE_LINES + title_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), title_height) + + # Elide title text if it's too long for 2 lines + title_text = str(title) + max_width = title_rect.width() + + # Split into words and fit within 2 lines with eliding + words = title_text.split() + line1_words = [] + line2_words = [] + current_line = line1_words + + for word in words: + test_text = " ".join(current_line + [word]) + if title_fm.horizontalAdvance(test_text) <= max_width: + current_line.append(word) + elif current_line is line1_words and len(line2_words) == 0: + # Move to second line + current_line = line2_words + current_line.append(word) + else: + # Need to elide + break + + line1_text = " ".join(line1_words) + line2_text = " ".join(line2_words) + + # If there are remaining words, elide the second line + if len(line1_words) + len(line2_words) < len(words): + line2_text = title_fm.elidedText(title_text if not line1_text else " ".join(words[len(line1_words):]), + Qt.TextElideMode.ElideRight, max_width) + + # Draw title lines + painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent(), line1_text) + if line2_text: + painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent() + title_fm.height(), line2_text) + + y += title_height + + # Draw subtitle (smaller) + subtitle = card_data.get('subtitle', '') + if subtitle: + subtitle_font: QtGui.QFont = option.font + subtitle_font.setPointSize(int(font_size * 0.9)) + subtitle_font.setWeight(QtGui.QFont.Weight.Light) + painter.setFont(subtitle_font) + + subtitle_fm = QtGui.QFontMetrics(subtitle_font) + subtitle_height = subtitle_fm.height() + subtitle_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), subtitle_height) + + # Elide subtitle if too long + subtitle_text = subtitle_fm.elidedText(str(subtitle), Qt.TextElideMode.ElideRight, subtitle_rect.width()) + painter.drawText(subtitle_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, subtitle_text) + y += subtitle_height + + # Draw detail (bottom left) + detail = card_data.get('detail', '') + detail_width = 0 + if detail: + detail_font = option.font + detail_font.setPointSize(int(font_size * 0.8)) + painter.setFont(detail_font) + + detail_fm = QtGui.QFontMetrics(detail_font) + detail_height = detail_fm.height() + + # Reserve half width for detail, half for categories + max_detail_width = text_rect.width() // 2 - 10 + detail_rect = QtCore.QRect(text_rect.left(), text_rect.bottom() - detail_height, + max_detail_width, detail_height) + + # Elide detail if too long + detail_text_elided = detail_fm.elidedText(str(detail), Qt.TextElideMode.ElideRight, max_detail_width) + painter.drawText(detail_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, detail_text_elided) + detail_width = detail_fm.horizontalAdvance(detail_text_elided) + 10 + + # Draw categories (pipe separated, bottom right) + categories = card_data.get('categories', []) + if categories and isinstance(categories, (list, tuple)): + categories_text = " | ".join(str(cat) for cat in categories) + categories_font = option.font + categories_font.setPointSize(int(font_size * 0.8)) + painter.setFont(categories_font) + + categories_fm = QtGui.QFontMetrics(categories_font) + categories_height = categories_fm.height() + + # Adjust width to account for detail on left + available_width = text_rect.width() - detail_width + categories_rect = QtCore.QRect(text_rect.left() + detail_width, text_rect.bottom() - categories_height, + available_width, categories_height) + + # Elide categories if too long + categories_text_elided = categories_fm.elidedText(categories_text, Qt.TextElideMode.ElideRight, available_width) + painter.drawText(categories_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, categories_text_elided) + + painter.restore() + + diff --git a/activity_browser/ui/delegates/database.py b/activity_browser/ui/delegates/database.py deleted file mode 100644 index a80441a05..000000000 --- a/activity_browser/ui/delegates/database.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -from bw2data import databases -from qtpy import QtCore, QtWidgets - - -class DatabaseDelegate(QtWidgets.QStyledItemDelegate): - """Nearly the same as the string delegate, but presents as - a combobox menu containing the databases of the current project. - """ - - def __init__(self, parent=None): - super().__init__(parent) - - def createEditor(self, parent, option, index): - editor = QtWidgets.QComboBox(parent) - editor.insertItems(0, databases.list) - return editor - - def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): - """Populate the editor with data if editing an existing field.""" - value = str(index.data(QtCore.Qt.DisplayRole)) - editor.setCurrentText(value) - - def setModelData( - self, - editor: QtWidgets.QComboBox, - model: QtCore.QAbstractItemModel, - index: QtCore.QModelIndex, - ): - """Take the editor, read the given value and set it in the model.""" - value = editor.currentText() - model.setData(index, value, QtCore.Qt.EditRole) diff --git a/activity_browser/ui/delegates/new_formula.py b/activity_browser/ui/delegates/new_formula.py index 6f43e3755..b118624bb 100644 --- a/activity_browser/ui/delegates/new_formula.py +++ b/activity_browser/ui/delegates/new_formula.py @@ -38,7 +38,7 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): from activity_browser.ui.widgets import ABFormulaEdit viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") - formula = ABFormulaEdit(viewport, scope, index.data()) + formula = ABFormulaEdit(viewport, scope, index.data(), simple=True) painter.setClipRect(option.rect) painter.translate(option.rect.topLeft()) diff --git a/activity_browser/ui/delegates/string.py b/activity_browser/ui/delegates/string.py index 4ded24422..2708f2336 100644 --- a/activity_browser/ui/delegates/string.py +++ b/activity_browser/ui/delegates/string.py @@ -7,6 +7,7 @@ class StringDelegate(QtWidgets.QStyledItemDelegate): def displayText(self, value, locale): if isinstance(value, (list, tuple)): + value = [str(v) for v in value] return ", ".join(value) return str(value) diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index 38130f2b0..cb782e719 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -37,7 +37,7 @@ def createEditor(self, parent, option, index): app.actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) - else: + elif isinstance(index.data(), dict): return UncertaintyDialog(parent=app.main_window, initial=index.data()) def setEditorData(self, editor, index: QtCore.QModelIndex): diff --git a/activity_browser/ui/dialogs/README.md b/activity_browser/ui/dialogs/README.md new file mode 100644 index 000000000..ae8cfbd87 --- /dev/null +++ b/activity_browser/ui/dialogs/README.md @@ -0,0 +1,269 @@ +# dialogs + +UI dialog windows for various user interactions. + +## Overview + +This directory contains dialog windows used throughout Activity Browser for user interactions such as configuration, data entry, item selection, and information display. + +## Dialog Categories + +### Input Dialogs +Collect information from users: +- Text input dialogs +- Numeric value entry +- Form-based data entry +- Multi-field configuration + +### Selection Dialogs +Allow users to choose items: +- Activity selection +- Database selection +- Method selection +- File/directory choosers +- List item selection + +### Configuration Dialogs +Manage settings and preferences: +- Application settings +- Project settings +- Database properties +- Import/export configuration +- Plugin configuration + +### Information Dialogs +Display information to users: +- About dialog +- Progress dialogs +- Status messages +- Error and warning dialogs +- Help and documentation + +### Confirmation Dialogs +Request user confirmation: +- Delete confirmations +- Overwrite warnings +- Action confirmations +- Discard changes prompts + +## Common Dialog Types + +### QDialog-based +Standard modal dialogs: +```python +from qtpy.QtWidgets import QDialog + +class MyDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def accept(self): + if self.validate(): + # Process and close + super().accept() +``` + +### QMessageBox-based +Simple message dialogs: +```python +from qtpy.QtWidgets import QMessageBox + +result = QMessageBox.question( + parent, + "Confirm Delete", + "Are you sure you want to delete this item?", + QMessageBox.Yes | QMessageBox.No +) +``` + +### QFileDialog-based +File and directory selection: +```python +from qtpy.QtWidgets import QFileDialog + +filepath = QFileDialog.getOpenFileName( + parent, + "Select File", + "", + "Excel files (*.xlsx)" +) +``` + +## Dialog Features + +### Modal vs. Modeless +- **Modal** - Blocks parent window until closed (most common) +- **Modeless** - Allows interaction with parent (for utilities) + +### Button Boxes +Standard button configurations: +```python +from qtpy.QtWidgets import QDialogButtonBox + +buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel +) +buttons.accepted.connect(self.accept) +buttons.rejected.connect(self.reject) +``` + +### Validation +Validate before accepting: +```python +def accept(self): + if not self.name_input.text(): + QMessageBox.warning(self, "Error", "Name is required") + return + super().accept() +``` + +### Progress Indication +Show progress for long operations: +```python +from qtpy.QtWidgets import QProgressDialog + +progress = QProgressDialog("Processing...", "Cancel", 0, 100, parent) +progress.setWindowModality(Qt.WindowModal) +progress.setValue(50) +``` + +## Usage Patterns + +### Simple Confirmation +```python +from qtpy.QtWidgets import QMessageBox + +reply = QMessageBox.question( + self, + "Confirm", + "Delete this database?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No # Default button +) + +if reply == QMessageBox.Yes: + # Perform deletion + pass +``` + +### Custom Dialog +```python +class MyDialog(QDialog): + def __init__(self, data, parent=None): + super().__init__(parent) + self.data = data + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Add widgets + self.name_edit = QLineEdit() + layout.addWidget(QLabel("Name:")) + layout.addWidget(self.name_edit) + + # Add buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_result(self): + """Return dialog result.""" + return self.name_edit.text() +``` + +### Using Custom Dialog +```python +dialog = MyDialog(data, parent=self) +if dialog.exec_() == QDialog.Accepted: + result = dialog.get_result() + # Use result +``` + +## Development Guidelines + +When creating dialogs: + +1. **Inherit from QDialog** - Use Qt's base dialog class +2. **Set parent** - Pass parent widget for proper hierarchy +3. **Provide clear title** - Set window title with setWindowTitle() +4. **Use button boxes** - Standard OK/Cancel buttons +5. **Validate input** - Check data in accept() method +6. **Return results** - Provide method to get dialog results +7. **Handle cancellation** - Clean up if user cancels +8. **Size appropriately** - Fit content, but not too large +9. **Be modal when needed** - Block parent for critical choices +10. **Show progress** - Use QProgressDialog for long operations + +## Threading in Dialogs + +Long operations should use worker threads: + +```python +from activity_browser.ui.core.threading import ABThread + +class MyDialog(QDialog): + def accept(self): + # Show progress + self.progress = QProgressDialog("Processing...", None, 0, 0, self) + self.progress.show() + + # Run in background + worker = ABThread(self.process_data) + worker.finished.connect(self.on_complete) + worker.start() + + def on_complete(self): + self.progress.close() + super().accept() +``` + +## Signal Integration + +Dialogs should emit signals for application updates: + +```python +from activity_browser import app + +class MyDialog(QDialog): + def accept(self): + # Save data + self.save_changes() + + # Notify application + app.signals.data_changed.emit() + + super().accept() +``` + +## Accessibility + +Make dialogs accessible: +- Clear focus order (tab navigation) +- Keyboard shortcuts for buttons +- Screen reader compatible labels +- Escape key to cancel +- Enter key to accept (when safe) + +## Testing + +Test dialogs thoroughly: +```python +def test_my_dialog(qtbot): + dialog = MyDialog() + qtbot.addWidget(dialog) + + # Test initial state + assert dialog.name_edit.text() == "" + + # Simulate user input + qtbot.keyClicks(dialog.name_edit, "Test Name") + + # Test validation + dialog.accept() + assert dialog.result() == QDialog.Accepted +``` diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py index d50ef14d4..bb65f2768 100644 --- a/activity_browser/ui/dialogs/__init__.py +++ b/activity_browser/ui/dialogs/__init__.py @@ -1,8 +1,4 @@ -from .database_selection_dialog import ABDatabaseSelectionDialog from .list_edit_dialog import ABListEditDialog from .progress_dialog import ABProgressDialog -from .uncertainty import UncertaintyWizard -from .new_node_dialog import NewNodeDialog -from .progress_dialog import ABProgressDialog from .uncertainty_dialog import UncertaintyDialog diff --git a/activity_browser/ui/dialogs/new_node_dialog.py b/activity_browser/ui/dialogs/new_node_dialog.py deleted file mode 100644 index c723d60cb..000000000 --- a/activity_browser/ui/dialogs/new_node_dialog.py +++ /dev/null @@ -1,61 +0,0 @@ - -from typing import Optional, Tuple -from qtpy.QtWidgets import QDialog, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget - - -class NewNodeDialog(QDialog): - """ - Gathers the paremeters for creating a new process. - """ - - def __init__(self, process: bool = True, parent: Optional[QWidget] = None): - super().__init__(parent) - layout = QGridLayout() - row = 0 - if process: - self.setWindowTitle("New process") - layout.addWidget(QLabel("Process name"), row, 0) - else: - self.setWindowTitle("New product") - layout.addWidget(QLabel("Product name"), row, 0) - self._process_name_edit = QLineEdit() - self._process_name_edit.textChanged.connect(self._handle_text_changed) - layout.addWidget(self._process_name_edit, row, 1) - row += 1 - self._ref_product_name_edit = QLineEdit() - if process: - layout.addWidget(QLabel("Product name"), row, 0) - layout.addWidget(self._ref_product_name_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Unit"), row, 0) - self._unit_edit = QLineEdit("kilogram") - layout.addWidget(self._unit_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Location"), row, 0) - default_loc = "GLO" if process else "" - self._location_edit = QLineEdit(default_loc) - layout.addWidget(self._location_edit, row, 1) - row += 1 - self._ok_button = QPushButton("OK") - self._ok_button.clicked.connect(self.accept) - self._ok_button.setEnabled(False) - layout.addWidget(self._ok_button, row, 0) - cancel_button = QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) - layout.addWidget(cancel_button, row, 1) - self.setLayout(layout) - - def _handle_text_changed(self, text: str): - self._ok_button.setEnabled(text != "") - self._ref_product_name_edit.setPlaceholderText(text) - - def get_new_process_data(self) -> Tuple[str, str, str, str]: - """Return the parameters the user entered.""" - return ( - self._process_name_edit.text(), - self._ref_product_name_edit.text(), - self._unit_edit.text(), - self._location_edit.text() - ) - - diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py deleted file mode 100644 index 0e86244a5..000000000 --- a/activity_browser/ui/dialogs/uncertainty.py +++ /dev/null @@ -1,737 +0,0 @@ -from loguru import logger - -import numpy as np -import seaborn as sns - -from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import Signal, Slot -from stats_arrays import uncertainty_choices as uncertainty -from stats_arrays.distributions import * - -from activity_browser.ui.widgets.plot import ABPlot -from activity_browser.bwutils.pedigree import PedigreeMatrix -from activity_browser.bwutils.uncertainty import get_uncertainty_interface, EMPTY_UNCERTAINTY - - - -class UncertaintyWizard(QtWidgets.QWizard): - """Using this wizard, guide the user through selecting an 'uncertainty' - distribution (and related values) for their activity/process exchanges. - - Note that this can also be used for setting uncertainties on parameters - """ - - TYPE = 0 - PEDIGREE = 1 - - complete = Signal(tuple, object) # feed the CF uncertainty back to the origin - - def __init__(self, unc_object: object, parent=None): - super().__init__(parent) - - self.obj = get_uncertainty_interface(unc_object) - self.using_pedigree = False - - self.pedigree = PedigreeMatrixPage(self) - self.type = UncertaintyTypePage(self) - self.pages = (self.type, self.pedigree) - - for i, p in enumerate(self.pages): - self.setPage(i, p) - self.setStartId(self.TYPE) - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - - self.button(QtWidgets.QWizard.FinishButton).clicked.connect( - self.update_uncertainty - ) - self.pedigree.enable_pedigree.connect(self.used_pedigree) - self.extract_uncertainty() - - @staticmethod - def standard_dist_fields(dist_id: int) -> list: - if dist_id in {2, 3}: - return ["loc", "scale"] - elif dist_id in {4, 7}: - return ["minimum", "maximum"] - elif dist_id in {5, 6}: - return ["loc", "minimum", "maximum"] - elif dist_id in {8, 9, 10, 11, 12}: - return ["loc", "scale", "shape"] - else: - return [] - - @property - def uncertainty_info(self) -> dict: - data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} - data["uncertainty type"] = self.field("uncertainty type") - data["negative"] = bool(self.field("negative")) - for field in self.standard_dist_fields(data["uncertainty type"]): - data[field] = float(self.field(field)) - return data - - @Slot(bool, name="togglePedigree") - def used_pedigree(self, toggle: bool) -> None: - self.using_pedigree = toggle - - @Slot(name="modifyUncertainty") - def update_uncertainty(self): - """Update the uncertainty information of the relevant object, optionally - including a pedigree update. - """ - from activity_browser import app - - self.amount_mean_test() - if self.obj.data_type == "exchange": - app.actions.ExchangeModify.run(self.obj.data, self.uncertainty_info) - if self.using_pedigree: - app.actions.ExchangeModify.run( - self.obj.data, {"pedigree": self.pedigree.matrix.factors} - ) - elif self.obj.data_type == "parameter": - app.actions.ParameterModify.run(self.obj.data, "data", self.uncertainty_info) - if self.using_pedigree: - app.actions.ParameterModify.run( - self.obj.data, "data", self.pedigree.matrix.factors - ) - elif self.obj.data_type == "cf": - self.complete.emit(self.obj.data, self.uncertainty_info) - - def extract_uncertainty(self) -> None: - """Used to extract possibly existing uncertainty information from the - given exchange/parameter - - Exchange objects have uncertainty shortcuts built in, other - objects which sometimes have uncertainty do not. - """ - for k, v in self.obj.uncertainty.items(): - if k in EMPTY_UNCERTAINTY: - self.setField(k, v) - - # If no loc/mean value is set yet, convert the amount. - if not self.field("loc") or self.field("loc") == "nan": - val = getattr(self.obj, "amount", 1.0) - if self.field("uncertainty type") == LognormalUncertainty.id: - val = np.log(val) - self.setField("loc", str(val)) - # Let the other fields default to 'nan' if no values are set. - for f in ("scale", "shape", "maximum", "minimum"): - if not self.field(f): - self.setField(f, "nan") - - def extract_lognormal_loc(self) -> None: - """Special handling for looking at the uncertainty['loc'] field - - This should only be used when the 'original' set uncertainty is - lognormal. - """ - mean = getattr(self.obj, "amount", 1.0) - loc = self.obj.uncertainty.get("loc", np.NaN) - if not np.isnan(loc) and self.obj.uncertainty_type != LognormalUncertainty: - loc = np.log(loc) - if np.isnan(loc): - loc = np.log(mean) - self.setField("loc", str(loc)) - - def amount_mean_test(self) -> None: - """Asks if the 'amount' of the object should be updated to account for - the user altering the loc/mean value. - """ - from activity_browser import app - - uc_type = self.field("uncertainty type") - no_change = {UndefinedUncertainty.id, NoUncertainty.id} - mean = float(self.field("loc")) - if uc_type == LognormalUncertainty.id: - mean = np.exp(mean) - elif uc_type in self.type.mean_is_calculated: - mean = self.type.calculate_mean - if not np.isclose(self.obj.amount, mean) and uc_type not in no_change: - msg = ( - "Do you want to update the 'amount' field to match mean?" - "\nAmount: {}\tMean: {}".format(self.obj.amount, mean) - ) - choice = QtWidgets.QMessageBox.question( - self, - "Amount differs from mean", - msg, - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.Yes, - ) - if choice == QtWidgets.QMessageBox.Yes: - if self.obj.data_type == "exchange": - app.actions.ExchangeModify.run(self.obj.data, {"amount": mean}) - - elif self.obj.data_type == "parameter": - try: - app.actions.ParameterModify.run(self.obj.data, "amount", mean) - except Exception as e: - QtWidgets.QMessageBox.warning( - app.main_window, - "Could not save changes", - str(e), - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - elif self.obj.data_type == "cf": - altered = {k: v for k, v in self.obj.uncertainty.items()} - altered["amount"] = mean - data = [*self.obj.data] - data[1] = altered - self.obj = get_uncertainty_interface(tuple(data)) - - -class UncertaintyTypePage(QtWidgets.QWizardPage): - """Present a list of uncertainty types directly retrieved from the `stats_arrays` package.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setFinalPage(True) - self.dist = None - self.complete = False - self.goto_pedigree = False - self.previous = None - self.mean_is_calculated = { - TriangularUncertainty.id, - UniformUncertainty.id, - DiscreteUniform.id, - BetaUncertainty.id, - } - - # Selection of uncertainty distribution. - box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") - self.distribution = QtWidgets.QComboBox(box1) - self.distribution.addItems([ud.description for ud in uncertainty.choices]) - self.distribution.currentIndexChanged.connect(self.distribution_selection) - self.registerField("uncertainty type", self.distribution, "currentIndex") - self.pedigree = QtWidgets.QPushButton("Use pedigree") - self.pedigree.clicked.connect(self.pedigree_page) - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0, 2, 1) - box_layout.addWidget(self.distribution, 0, 1, 2, 2) - box_layout.addWidget(self.pedigree, 0, 3, 2, 1) - box1.setLayout(box_layout) - - # Set values for selected uncertainty distribution. - self.field_box = QtWidgets.QGroupBox("Fill out or change required parameters") - self.locale = QtCore.QLocale( - QtCore.QLocale.English, QtCore.QLocale.UnitedStates - ) - self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) - self.validator = QtGui.QDoubleValidator() - self.validator.setLocale(self.locale) - self.loc = QtWidgets.QLineEdit() - self.loc.setValidator(self.validator) - self.loc.textEdited.connect(self.balance_mean_with_loc) - self.loc.textEdited.connect(self.check_negative) - self.loc.textEdited.connect(self.generate_plot) - self.loc_label = QtWidgets.QLabel("Loc:") - self.mean = QtWidgets.QLineEdit() - self.mean.setValidator(self.validator) - self.mean.textEdited.connect(self.balance_loc_with_mean) - self.mean.textEdited.connect(self.check_negative) - self.mean.textEdited.connect(self.generate_plot) - self.mean_label = QtWidgets.QLabel("Mean:") - self.blocked_label = QtWidgets.QLabel("Mean:") - self.blocked_mean = QtWidgets.QLineEdit("nan") - self.blocked_mean.setDisabled(True) - self.scale = QtWidgets.QLineEdit() - self.scale.setValidator(self.validator) - self.scale.textEdited.connect(self.generate_plot) - self.scale_label = QtWidgets.QLabel("Sigma/scale:") - self.shape = QtWidgets.QLineEdit() - self.shape.setValidator(self.validator) - self.shape.textEdited.connect(self.generate_plot) - self.shape_label = QtWidgets.QLabel("Shape:") - self.minimum = QtWidgets.QLineEdit() - self.minimum.setValidator(self.validator) - self.minimum.textEdited.connect(self.generate_plot) - self.min_label = QtWidgets.QLabel("Minimum:") - self.maximum = QtWidgets.QLineEdit() - self.maximum.setValidator(self.validator) - self.maximum.textEdited.connect(self.generate_plot) - self.max_label = QtWidgets.QLabel("Maximum:") - self.negative = QtWidgets.QRadioButton(self) - self.negative.setChecked(False) - self.negative.setHidden(True) - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(self.blocked_label, 0, 0) - box_layout.addWidget(self.blocked_mean, 0, 1) - box_layout.addWidget(self.loc_label, 2, 0) - box_layout.addWidget(self.loc, 2, 1) - box_layout.addWidget(self.mean_label, 2, 3) - box_layout.addWidget(self.mean, 2, 4) - box_layout.addWidget(self.scale_label, 4, 0) - box_layout.addWidget(self.scale, 4, 1) - box_layout.addWidget(self.shape_label, 6, 0) - box_layout.addWidget(self.shape, 6, 1) - box_layout.addWidget(self.min_label, 8, 0) - box_layout.addWidget(self.minimum, 8, 1) - box_layout.addWidget(self.max_label, 10, 0) - box_layout.addWidget(self.maximum, 10, 1) - self.field_box.setLayout(box_layout) - - self.registerField("loc", self.loc, "text") - self.registerField("scale", self.scale, "text") - self.registerField("shape", self.shape, "text") - self.registerField("minimum", self.minimum, "text") - self.registerField("maximum", self.maximum, "text") - self.registerField("negative", self.negative, "checked") - - self.plot = SimpleDistributionPlot(self) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(box1) - layout.addWidget(self.field_box) - layout.addWidget(self.plot) - self.setLayout(layout) - - def hide_param(self, *params, hide: bool = True): - if "loc" in params: - self.loc_label.setHidden(hide) - self.loc.setHidden(hide) - if "scale" in params: - self.scale_label.setHidden(hide) - self.scale.setHidden(hide) - if "shape" in params: - self.shape_label.setHidden(hide) - self.shape.setHidden(hide) - if "min" in params: - self.min_label.setHidden(hide) - self.minimum.setHidden(hide) - if "max" in params: - self.max_label.setHidden(hide) - self.maximum.setHidden(hide) - - def special_distribution_handling(self): - """Special kansas city shuffling for this distribution.""" - if self.dist.id == LognormalUncertainty.id: - self.mean.setHidden(False) - self.mean_label.setHidden(False) - # Convert 'mean' to lognormal mean - if self.previous is not None and self.previous != LognormalUncertainty.id: - self.wizard().extract_lognormal_loc() - self.balance_mean_with_loc() - else: - self.mean.setHidden(True) - self.mean_label.setHidden(True) - # Override the lognormal mean and copy the amount in its place - if self.previous and self.previous == LognormalUncertainty.id: - self.loc.setText(str(getattr(self.wizard().obj, "amount", 1))) - # Hide or show additional untouchable 'mean' field. - if self.dist.id in self.mean_is_calculated: - self.blocked_label.setHidden(False) - self.blocked_mean.setHidden(False) - else: - self.blocked_label.setHidden(True) - self.blocked_mean.setHidden(True) - self.loc_label.setText(self.distribution_loc_label) - self.previous = self.dist.id - self.field_box.updateGeometry() - - @property - def distribution_loc_label(self) -> str: - """Many distributions have a special name for the value that is entered - into the 'loc' field. - """ - if self.dist.id == LognormalUncertainty.id: - return "Loc (ln(mean)):" - elif self.dist.id == TriangularUncertainty.id: - return "Mode:" - elif self.dist.id == BetaUncertainty.id: - return "Loc / alpha:" - elif self.dist.id in {GammaUncertainty.id, WeibullUncertainty.id}: - return "Loc / offset:" - else: - return "Mean:" - - @property - def calculate_mean(self) -> float: - """Some distributions do not specifically use a mean to generate - their random values, in those cases present a calculated mean. - - If any of the data is missing or the calculation fails, float('nan') - is returned. - """ - array = self.dist.from_dicts(self.wizard().uncertainty_info) - try: - calc = self.dist.statistics(array).get("mean") - # Catch exception for DiscreteUniform (https://bitbucket.org/cmutel/stats_arrays/pull-requests/5/) - except TypeError: - array = self.dist.fix_nan_minimum(array) - calc = (array["maximum"] + array["minimum"]) / 2 - calc = calc.mean() if isinstance(calc, np.ndarray) else calc - return float(calc) - - @Slot(name="changeDistribution") - def distribution_selection(self): - """Selected distribution and present the correct uncertainty parameters. - - See https://stats-arrays.readthedocs.io/en/latest/index.html for which - fields to show and hide. - """ - self.dist = uncertainty.id_dict[self.distribution.currentIndex()] - - # Huge if/elif tree to ensure the correct fields are shown. - if self.dist.id in {0, 1}: - self.hide_param("loc", "scale", "shape", "min", "max") - elif self.dist.id in {2, 3}: - self.hide_param("shape", "min", "max") - self.hide_param("loc", "scale", hide=False) - elif self.dist.id in {4, 7}: - self.hide_param("loc", "scale", "shape") - self.hide_param("min", "max", hide=False) - elif self.dist.id in {5, 6}: - self.hide_param("scale", "shape") - self.hide_param("loc", "min", "max", hide=False) - elif self.dist.id in {8, 9, 10, 11, 12}: - self.hide_param("min", "max") - self.hide_param("loc", "scale", "shape", hide=False) - self.special_distribution_handling() - self.generate_plot() - - def completed_active_fields(self) -> bool: - """Returns a boolean value based on the distribution id. - If the distribution contains an average, minimum and maximum this forces the - average to exist exclusively within these bounds""" - completed = False - if self.dist.id in {0, 1}: - completed = True - elif self.dist.id in {2, 3}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.loc, self.scale) - ] - ) - elif self.dist.id in {4, 7}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.minimum, self.maximum) - ] - ) - elif self.dist.id in {5, 6}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.minimum, self.maximum, self.loc) - ] - ) and ( - float(self.minimum.text()) - < float(self.loc.text()) - < float(self.maximum.text()) - ) - elif self.dist.id in {8, 9, 10, 11, 12}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.scale, self.shape, self.loc) - ] - ) - return completed - - @Slot(name="locToMean") - def balance_mean_with_loc(self): - if self.loc.text(): - self.mean.setText(str(np.exp(float(self.loc.text())))) - - @Slot(name="meanToLoc") - def balance_loc_with_mean(self): - if not self.mean.hasAcceptableInput(): - self.loc.setText("nan") - return - val = float(self.mean.text() if self.mean.text() else "nan") - val = -1 * val if val < 0 else val - self.loc.setText(str(np.log(val) if val != 0 else float("nan"))) - - @Slot(name="testValueNegative") - def check_negative(self) -> None: - """Determine which QLineEdit to use to set the negative value. - - Another special edge-case for the lognormal distribution. - """ - if not self.mean.hasAcceptableInput(): - return - val = float(self.mean.text() if self.mean.text() else "nan") - if self.dist.id == LognormalUncertainty.id and val < 0: - self.setField("negative", True) - else: - self.setField("negative", False) - - def initializePage(self) -> None: - self.distribution_selection() - self.balance_mean_with_loc() - - def nextId(self) -> int: - if self.goto_pedigree: - return UncertaintyWizard.PEDIGREE - return -1 - - def isComplete(self) -> bool: - return self.complete - - @Slot(name="gotoPedigreePage") - def pedigree_page(self) -> None: - self.goto_pedigree = True - self.wizard().next() - - @Slot(name="regenPlot") - def generate_plot(self) -> None: - """Called whenever a value changes, (re)generate the plot. - - Also tests if all of the visible QLineEdit fields have valid values. - """ - self.complete = self.completed_active_fields() - no_dist = self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id} - if self.complete or no_dist: - array = self.dist.from_dicts(self.wizard().uncertainty_info) - if self.dist.id in self.mean_is_calculated: - mean = self.calculate_mean - self.blocked_mean.setText(str(mean)) - if self.dist.id == LognormalUncertainty.id: - mean = self.dist.statistics(array).get("median") - elif no_dist: - mean = self.wizard().obj.amount - else: - mean = self.dist.statistics(array).get("mean") - data = self.dist.random_variables(array, 1000) - if not np.any(np.isnan(data)): - self.plot.plot(data, mean) - self.completeChanged.emit() - - -class PedigreeMatrixPage(QtWidgets.QWizardPage): - """Guide the user through filling out a pedigree matrix. - - There are 5 indicators used, each carrying a score from 1 to 5 - with 1 indicating 'less uncertain' and 5 'more uncertain'. - - NOTE: Currently, the pedigree matrix will always default to a lognormal distribution. - - NOTE: using terms and quoting from the paper: - 'Empirically based uncertainty factors for the pedigree matrix in ecoinvent' (2016) - doi: 10.1007/s11367-013-0670-5 - """ - - enable_pedigree = Signal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - self.setFinalPage(True) - self.matrix = None - - self.field_box = QtWidgets.QGroupBox("Fill out or change required parameters") - self.locale = QtCore.QLocale( - QtCore.QLocale.English, QtCore.QLocale.UnitedStates - ) - self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) - self.validator = QtGui.QDoubleValidator() - self.validator.setLocale(self.locale) - self.loc = QtWidgets.QLineEdit() - self.loc.setValidator(self.validator) - self.loc.textEdited.connect(self.balance_mean_with_loc) - self.loc.textEdited.connect(self.check_negative) - self.loc.textEdited.connect(self.check_complete) - self.mean = QtWidgets.QLineEdit() - self.mean.setValidator(self.validator) - self.mean.textEdited.connect(self.balance_loc_with_mean) - self.mean.textEdited.connect(self.check_negative) - self.mean.textEdited.connect(self.check_complete) - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(QtWidgets.QLabel("Loc (ln(mean)):"), 0, 0) - box_layout.addWidget(self.loc, 0, 1) - box_layout.addWidget(QtWidgets.QLabel("Mean:"), 0, 3) - box_layout.addWidget(self.mean, 0, 4) - self.field_box.setLayout(box_layout) - - box = QtWidgets.QGroupBox("Select pedigree values") - - self.reliable = QtWidgets.QComboBox(box) - self.reliable.addItems( - [ - "1) Verified data based on measurements", - "2) Verified data partly based on assumptions", - "3) Non-verified data partly based on qualified measurements", - "4) Qualified estimate", - "5) Non-qualified estimate", - ] - ) - self.complete = QtWidgets.QComboBox(box) - self.complete.addItems( - [ - "1) Representative relevant data from all sites, over an adequate period", - "2) Representative relevant data from >50% sites, over an adequate period", - "3) Representative relevant data from <50% sites OR >50%, but over shorter period", - "4) Representative relevant data from one site OR some sites but over shorter period", - "5) Representativeness unknown", - ] - ) - self.temporal = QtWidgets.QComboBox(box) - self.temporal.addItems( - [ - "1) Data less than 3 years old", - "2) Data less than 6 years old", - "3) Data less than 10 years old", - "4) Data less than 15 years old", - "5) Data age unknown or more than 15 years old", - ] - ) - self.geographical = QtWidgets.QComboBox(box) - self.geographical.addItems( - [ - "1) Data from area under study", - "2) Average data from larger area in which area under study is included", - "3) Data from area with similar production conditions", - "4) Data from area with slightly similar production conditions", - "5) Data from unknown OR distinctly different area", - ] - ) - self.technological = QtWidgets.QComboBox(box) - self.technological.addItems( - [ - "1) Data from enterprises, processes and materials under study", - "2) Data from processes and materials under study, different enterprise", - "3) Data from processes and materials under study from different technology", - "4) Data on related processes and materials", - "5) Data on related processes on lab scale OR from different technology", - ] - ) - self.reliable.currentIndexChanged.connect(self.check_complete) - self.complete.currentIndexChanged.connect(self.check_complete) - self.temporal.currentIndexChanged.connect(self.check_complete) - self.geographical.currentIndexChanged.connect(self.check_complete) - self.technological.currentIndexChanged.connect(self.check_complete) - - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(QtWidgets.QLabel("Reliability"), 0, 0, 2, 2) - box_layout.addWidget(self.reliable, 0, 2, 2, 3) - box_layout.addWidget(QtWidgets.QLabel("Completeness"), 2, 0, 2, 2) - box_layout.addWidget(self.complete, 2, 2, 2, 3) - box_layout.addWidget(QtWidgets.QLabel("Temporal correlation"), 4, 0, 2, 2) - box_layout.addWidget(self.temporal, 4, 2, 2, 3) - box_layout.addWidget(QtWidgets.QLabel("Geographical correlation"), 6, 0, 2, 2) - box_layout.addWidget(self.geographical, 6, 2, 2, 3) - box_layout.addWidget( - QtWidgets.QLabel("Further technological correlation"), 8, 0, 2, 2 - ) - box_layout.addWidget(self.technological, 8, 2, 2, 3) - box.setLayout(box_layout) - - self.plot = SimpleDistributionPlot(self) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.field_box) - layout.addWidget(box) - layout.addWidget(self.plot) - self.setLayout(layout) - - def cleanupPage(self): - self.enable_pedigree.emit(False) - - def initializePage(self): - # if the parent contains an 'obj' with uncertainty, extract data - self.setField("uncertainty type", 2) - self.loc.setText(self.field("loc")) - self.balance_mean_with_loc() - obj = getattr(self.wizard(), "obj") - try: - matrix = PedigreeMatrix.from_dict(obj.uncertainty.get("pedigree", {})) - self.pedigree = matrix.factors - except AssertionError as e: - logger.info("Could not extract pedigree data: {}".format(str(e))) - self.pedigree = {} - self.check_complete() - - def nextId(self): - """Ensures that 'Next' button does not show.""" - return -1 - - @property - def pedigree(self) -> tuple: - return ( - self.reliable.currentIndex() + 1, - self.complete.currentIndex() + 1, - self.temporal.currentIndex() + 1, - self.geographical.currentIndex() + 1, - self.technological.currentIndex() + 1, - ) - - @pedigree.setter - def pedigree(self, data: dict) -> None: - self.reliable.setCurrentIndex(data.get("reliability", 1) - 1) - self.complete.setCurrentIndex(data.get("completeness", 1) - 1) - self.temporal.setCurrentIndex(data.get("temporal correlation", 1) - 1) - self.geographical.setCurrentIndex(data.get("geographical correlation", 1) - 1) - self.technological.setCurrentIndex( - data.get("further technological correlation", 1) - 1 - ) - - @Slot(name="locToMean") - def balance_mean_with_loc(self): - self.setField("loc", self.loc.text()) - if self.loc.text(): - self.mean.setText(str(np.exp(float(self.loc.text())))) - - @Slot(name="meanToLoc") - def balance_loc_with_mean(self): - if not self.mean.hasAcceptableInput(): - self.loc.setText("nan") - return - val = float(self.mean.text() if self.mean.text() else "nan") - val = -1 * val if val < 0 else val - loc_val = str(np.log(val)) if val != 0 else "nan" - self.loc.setText(loc_val) - self.setField("loc", loc_val) - - @Slot(name="testValueNegative") - def check_negative(self) -> None: - """Determine which QLineEdit to use to set the negative value. - - Another special edge-case for the lognormal distribution. - """ - if not self.mean.hasAcceptableInput(): - return - val = float(self.mean.text() if self.mean.text() else "nan") - if val < 0: - self.setField("negative", True) - else: - self.setField("negative", False) - - @Slot(name="constructPedigreeMatrix") - def check_complete(self) -> None: - self.matrix = PedigreeMatrix.from_numbers(self.pedigree) - self.setField("scale", self.matrix.calculate()) - self.generate_plot() - - @Slot(name="regenPlot") - def generate_plot(self) -> None: - """Called whenever a value changes, (re)generate the plot. - - Also tests if all of the visible QLineEdit fields have valid values. - """ - array = LognormalUncertainty.from_dicts(self.wizard().uncertainty_info) - median = LognormalUncertainty.statistics(array).get("median") - data = LognormalUncertainty.random_variables(array, 1000) - if not np.any(np.isnan(data)): - self.plot.plot(data, median) - self.enable_pedigree.emit(True) - - -class SimpleDistributionPlot(ABPlot): - def plot(self, data: np.ndarray, mean: float, label: str = "Value"): - self.reset_plot() - try: - sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") - except RuntimeError as e: - logger.error("{}: Plotting without KDE.".format(e)) - sns.histplot( - data.T, kde=False, stat="density", ax=self.ax, edgecolor="none" - ) - self.ax.set_xlabel(label) - self.ax.set_ylabel("Probability density") - # Add vertical line at given mean of x-axis - self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) - self.ax.legend(loc="upper right") - _, height = self.canvas.get_width_height() - self.setMinimumHeight(height / 2) - self.canvas.draw() diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index d1c54d8b7..37d304a91 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -4,6 +4,7 @@ from qtpy.QtCore import Qt, QSize from qtpy.QtGui import QIcon, QPixmap + PACKAGE_DIR = Path(__file__).resolve().parents[1] @@ -70,6 +71,11 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: biosphere = create_path("nodes", "biosphere.png"), readonly_process = create_path("nodes", "read-only-process.png"), + # exchanges + link = create_path("exchanges", "link.png"), + unlink = create_path("exchanges", "unlink.png"), + relink = create_path("exchanges", "relink.png"), + # other superstructure = create_path("main", "superstructure.png"), copy_to_clipboard = create_path("main", "copy_to_clipboard.png"), @@ -87,9 +93,12 @@ def __getattribute__(self, name): if name == 'empty': return empty_icon() elif name in icons: - return QIcon(icons[name]) + if name not in _initialized_icons: + _initialized_icons[name] = QIcon(icons[name]) + return _initialized_icons[name] else: raise AttributeError(f"QIcons has no icon '{name}'") +_initialized_icons = {} qicons = QIcons() diff --git a/activity_browser/ui/web/__init__.py b/activity_browser/ui/web/__init__.py deleted file mode 100644 index b8839b90a..000000000 --- a/activity_browser/ui/web/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -from .navigator import GraphNavigatorWidget -from .sankey_navigator import SankeyNavigatorWidget -from .tree_navigator import TreeNavigatorWidget diff --git a/activity_browser/ui/web/navigator.py b/activity_browser/ui/web/navigator.py deleted file mode 100644 index 78308196c..000000000 --- a/activity_browser/ui/web/navigator.py +++ /dev/null @@ -1,503 +0,0 @@ -import itertools -import json -import os -from copy import deepcopy -from typing import Optional -from loguru import logger - -import networkx as nx -from qtpy import QtWidgets -from qtpy.QtCore import Slot - -from activity_browser import app -from bw2data import Database, get_activity, databases, Edge -from bw2data.backends import ExchangeDataset, ActivityDataset - -from ...bwutils.commontasks import identify_activity_type, get_activity_name -from .base import BaseGraph, BaseNavigatorWidget - - - - -# TODO: -# save graph as image -# zoom reverse direction between canvas and minimap -# break long geographies into max length -# enable other layouts (e.g. force) -# random_graph should not work for biosphere -# a selection possibility method would be nice if many nodes are to be added up/downstream (now the only way is to open all and close those that one is not interested in) - -# ISSUES: -# - tooltips show values, but these are not scaled to a product system, i.e. the do not make sense as a system - - -class GraphNavigatorWidget(BaseNavigatorWidget): - HELP_TEXT = """ - How to use the Graph Navigator: - - EXPANSION MODE (DEFAULT): - Click on activities to expand graph. - - click: expand upwards - - click + shift: expand downstream - - click + alt: delete activity - - Checkbox "Add only direct up-/downstream exchanges" - there are two ways to expand the graph: - 1) adding direct up-/downstream nodes and connections (DEFAULT). - 2) adding direct up-/downstream nodes and connections AS WELL as ALL OTHER connections between the activities in the graph. - The first option results in cleaner (but not complete) graphs. - - Checkbox "Remove orphaned nodes": by default nodes that do not link to the central activity (see title) are removed (this may happen after deleting nodes). Uncheck to disable. - - Checkbox "Flip negative flows" (experimental): Arrows of negative product flows (e.g. from ecoinvent treatment activities or from substitution) can be flipped. - The resulting representation can be more intuitive for understanding the physical product flows (e.g. that wastes are outputs of activities and not negative inputs). - - - NAVIGATION MODE: - Click on activities to jump to specific activities (instead of expanding the graph). - """ - HTML_FILE = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "../../static/navigator.html" - ) - - def __init__(self, parent=None, key=None): - super().__init__(parent, css_file="navigator.css") - self.setObjectName(get_activity_name(get_activity(key), str_length=30)) - self.key = key - self.tab = parent - - self.graph = Graph() - - # default settings - self.navigation_label = itertools.cycle( - ["Current mode: Expansion", "Current mode: Navigation"] - ) - self.selected_db = None - - self.button_navigation_mode = QtWidgets.QPushButton(next(self.navigation_label)) - self.checkbox_direct_only = QtWidgets.QCheckBox( - "Add only direct up-/downstream exchanges" - ) - self.checkbox_remove_orphaned_nodes = QtWidgets.QCheckBox( - "Remove orphaned nodes" - ) - self.checkbox_flip_negative_edges = QtWidgets.QCheckBox("Flip negative flows") - self.layout = QtWidgets.QVBoxLayout() - - # Prepare graph - self.draw_graph() - - # Construct layout and set signals. - self.construct_layout() - self.update_graph_settings() - self.connect_signals() - - if key: - self.selected_db = key[0] - self.new_graph(key) - - @Slot(name="loadFinishedHandler") - def load_finished_handler(self) -> None: - """Executed when webpage has been loaded for the first time or refreshed. - This is needed to resend the json data the first time after the page has completely loaded. - """ - # print(time.time(), ": load finished") - self.send_json() - - def connect_signals(self): - super().connect_signals() - self.button_navigation_mode.clicked.connect(self.toggle_navigation_mode) - # signals.database_selected.connect(self.set_database) - self.bridge.update_graph.connect(self.update_graph) - # checkboxes - self.checkbox_direct_only.stateChanged.connect(self.update_graph_settings) - self.checkbox_remove_orphaned_nodes.stateChanged.connect( - self.update_graph_settings - ) - self.checkbox_flip_negative_edges.stateChanged.connect( - self.update_graph_settings - ) - self.checkbox_flip_negative_edges.stateChanged.connect(self.reload_graph) - databases.metadata_changed.connect(self.sync_graph) - - def sync_graph(self): - """Sync the graph with the current project.""" - self.graph.update(delete_unstacked=False) - self.send_json() - try: - self.setObjectName(get_activity_name(get_activity(self.key), str_length=30)) - except ActivityDataset.DoesNotExist: - logger.debug("Graph activity no longer exists. Closing tab.") - self.tab.close_tab_by_tab_name(self.tab.get_tab_name(self)) - - def construct_layout(self) -> None: - """Layout of Graph Navigator""" - self.label_help.setVisible(False) - - # checkbox all_exchanges_in_graph - self.checkbox_direct_only.setChecked(True) - self.checkbox_direct_only.setToolTip( - "When adding activities, show product flows between ALL activities or just selected up-/downstream flows" - ) - - # checkbox remove orphaned nodes - self.checkbox_remove_orphaned_nodes.setChecked(True) - self.checkbox_remove_orphaned_nodes.setToolTip( - "When removing activities, automatically remove those that have no further connection to the original product" - ) - - # checkbox flip negative edges - self.checkbox_flip_negative_edges.setChecked(False) - self.checkbox_flip_negative_edges.setToolTip( - "Flip negative product flows (e.g. from ecoinvent treatment activities or from substitution)" - ) - # Controls Layout - hl_controls = QtWidgets.QHBoxLayout() - hl_controls.addWidget(self.button_back) - hl_controls.addWidget(self.button_forward) - hl_controls.addWidget(self.button_navigation_mode) - hl_controls.addWidget(self.button_refresh) - hl_controls.addWidget(self.button_random_activity) - hl_controls.addWidget(self.button_toggle_help) - hl_controls.addStretch(1) - - # Checkboxes Layout - hl_checkboxes = QtWidgets.QHBoxLayout() - hl_checkboxes.addWidget(self.checkbox_direct_only) - hl_checkboxes.addWidget(self.checkbox_remove_orphaned_nodes) - hl_checkboxes.addWidget(self.checkbox_flip_negative_edges) - hl_checkboxes.addStretch(1) - - # Layout - self.layout.addLayout(hl_controls) - self.layout.addLayout(hl_checkboxes) - self.layout.addWidget(self.label_help) - self.layout.addWidget(self.view) - self.setLayout(self.layout) - - def update_graph_settings(self): - self.graph.direct_only = self.checkbox_direct_only.isChecked() - self.graph.remove_orphaned = self.checkbox_remove_orphaned_nodes.isChecked() - self.graph.flip_negative_edges = self.checkbox_flip_negative_edges.isChecked() - - @property - def is_expansion_mode(self) -> bool: - return "Expansion" in self.button_navigation_mode.text() - - @Slot(name="toggleNavigationMode") - def toggle_navigation_mode(self): - mode = next(self.navigation_label) - self.button_navigation_mode.setText(mode) - logger.info(f"Switched to: {mode}") - self.checkbox_remove_orphaned_nodes.setVisible(self.is_expansion_mode) - self.checkbox_direct_only.setVisible(self.is_expansion_mode) - - def new_graph(self, key: tuple) -> None: - logger.info(f"New Graph for key: {key}") - self.graph.new_graph(key) - self.send_json() - - @Slot(name="reload_graph") - def reload_graph(self) -> None: - app.signals.new_statusbar_message.emit("Reloading graph") - self.graph.update(delete_unstacked=False) - - @Slot(object, name="update_graph") - def update_graph(self, click_dict: dict) -> None: - """ - Update graph based on user command (click+keyboard) and settings. - Settings: - - navigation or expansion mode - - add all or only direct up/downstream nodes - User commands: - - mouse (left or right button) - - additional keyboard keys (shift, alt) - Behaviour: see HELP text - """ - key = click_dict["key"] - keyboard = click_dict["keyboard"] - - # interpret user command: - if not self.is_expansion_mode: # do not expand - self.new_graph(key) - else: - if keyboard["alt"]: # delete node - logger.info(f"Deleting node: {key}") - self.graph.reduce_graph(key) - else: # expansion mode - logger.info(f"Expanding graph: {key}") - if keyboard["shift"]: # downstream expansion - logger.info("Adding downstream nodes.") - self.graph.expand_graph(key, down=True) - else: # upstream expansion - logger.info("Adding upstream nodes.") - self.graph.expand_graph(key, up=True) - self.send_json() - - def set_database(self, name): - """Saves the currently selected database for graphing a random activity""" - self.selected_db = name - - @Slot(name="random_graph") - def random_graph(self) -> None: - """Show graph for a random activity in the currently loaded database.""" - if self.selected_db: - self.new_graph(Database(self.selected_db).random().key) - else: - QtWidgets.QMessageBox.information( - None, "Not possible.", "Please load a database first." - ) - - -class Graph(BaseGraph): - """Python side representation of the graph. - Functionality for graph navigation (e.g. adding and removing nodes). - A JSON representation of the graph (edges and nodes) enables its use in javascript/html/css. - """ - - def __init__(self): - super().__init__() - self.central_activity = None - self.nodes = None - self.edges = None - - # some settings - self.direct_only = True # for a graph expansion: add only direct up-/downstream nodes instead of all connections between the activities in the graph - self.remove_orphaned = True # remove nodes that are isolated from the central_activity after a deletion - self.flip_negative_edges = False # show true flow direction of edges (e.g. for ecoinvent treatment activities, or substitutions) - - def update(self, delete_unstacked: bool = True) -> None: - self.update_datasets() - super().update(delete_unstacked) - self.json_data = self.get_json_data() - - def update_datasets(self): - """Update the activities in the graph.""" - try: - self.nodes = [get_activity(act.key) for act in self.nodes] - self.edges = [Edge(document=ExchangeDataset.get_by_id(exc._document.id)) for exc in self.edges] - except (ActivityDataset.DoesNotExist, ExchangeDataset.DoesNotExist): - try: - get_activity(self.central_activity.key) # test whether the activity still exists - self.new_graph(self.central_activity.key) # if so, create a new graph - except ActivityDataset.DoesNotExist: - logger.warning("Graph activity no longer exists.") - self.nodes = [] - self.edges = [] - - def store_previous(self) -> None: - self.stack.append((deepcopy(self.nodes), deepcopy(self.edges))) - - def store_future(self) -> None: - self.forward_stack.append(self.stack.pop()) - self.nodes, self.edges = self.stack.pop() - - def retrieve_future(self) -> None: - self.nodes, self.edges = self.forward_stack.pop() - - @staticmethod - def upstream_and_downstream_nodes(key: tuple) -> (list, list): - """Returns the upstream and downstream activity objects for a key.""" - activity = get_activity(key) - upstream_nodes = [ex.input for ex in activity.technosphere()] - downstream_nodes = [ex.output for ex in activity.upstream()] - return upstream_nodes, downstream_nodes - - @staticmethod - def upstream_and_downstream_exchanges(key: tuple) -> (list, list): - """Returns the upstream and downstream Exchange objects for a key. - - act.upstream refers to downstream exchanges; brightway is confused here) - """ - activity = get_activity(key) - return [ex for ex in activity.technosphere()], [ - ex for ex in activity.upstream() - ] - - @staticmethod - def inner_exchanges(nodes: list) -> list: - """Returns all exchanges (Exchange objects) between a list of nodes.""" - node_keys = set(node.key for node in nodes) - exchanges = itertools.chain(node.technosphere() for node in nodes) - return [ - ex - for ex in exchanges - if all(k in node_keys for k in (ex["input"], ex["output"])) - ] - - def remove_outside_exchanges(self) -> None: - """ - Ensures that all exchanges are exclusively between nodes of the graph - (i.e. removes exchanges to previously existing nodes). - """ - self.edges = [ - e for e in self.edges if all(k in self.nodes for k in (e.input, e.output)) - ] - - def new_graph(self, key: tuple) -> None: - """Creates a new JSON graph showing the up- and downstream activities for the activity key passed. - Args: - key (tuple): activity key - Returns: - JSON data as a string - """ - self.central_activity = get_activity(key) - - # add nodes - up_nodes, down_nodes = Graph.upstream_and_downstream_nodes(key) - self.nodes = [self.central_activity] + up_nodes + down_nodes - - # add edges - # self.edges = self.inner_exchanges(self.nodes) - up_exs, down_exs = Graph.upstream_and_downstream_exchanges(key) - self.edges = up_exs + down_exs - self.update() - - def expand_graph(self, key: tuple, up=False, down=False) -> None: - """ - Adds up-, downstream, or both nodes to graph. - Different behaviour for "direct nodes only" or "all nodes (inner exchanges)" modes. - """ - up_nodes, down_nodes = Graph.upstream_and_downstream_nodes(key) - - # Add Nodes - if up and not down: - self.nodes = list(set(self.nodes + up_nodes)) - elif down and not up: - self.nodes = list(set(self.nodes + down_nodes)) - elif up and down: - self.nodes = list(set(self.nodes + up_nodes + down_nodes)) - - # Add Edges / Exchanges - if self.direct_only: - up_exs, down_exs = Graph.upstream_and_downstream_exchanges(key) - if up and not down: - self.edges += up_exs - elif down and not up: - self.edges += down_exs - elif up and down: - self.edges += up_exs + down_exs - else: # all - self.edges = Graph.inner_exchanges(self.nodes) - self.update() - - def reduce_graph(self, key: tuple) -> None: - """ - Deletes nodes from graph. - Different behaviour for "direct nodes only" or "all nodes (inner exchanges)" modes. - Can lead to orphaned nodes, which can be removed or kept. - """ - if key == self.central_activity.key: - logger.warning("Cannot remove central activity.") - return - act = get_activity(key) - self.nodes.remove(act) - if self.direct_only: - self.remove_outside_exchanges() - else: - self.edges = Graph.inner_exchanges(self.nodes) - - if self.remove_orphaned: # remove orphaned nodes - self.remove_orphaned_nodes() - - self.update() - - def remove_orphaned_nodes(self) -> None: - """ - Remove orphaned nodes from graph using the networkx. - Orphaned nodes are defined as having no path to the central_activity. - """ - - def format_as_weighted_edges(exchanges, activity_objects=False): - """Returns the exchanges as a list of weighted edges (from, to, weight) for networkx.""" - if activity_objects: - return ((ex.input, ex.output, ex.amount) for ex in exchanges) - else: # keys - return ((ex["input"], ex["output"], ex["amount"]) for ex in exchanges) - - # construct networkx graph - G = nx.MultiGraph() - for node in self.nodes: - G.add_node(node.key) - G.add_weighted_edges_from(format_as_weighted_edges(self.edges)) - - # identify orphaned nodes - # checks each node in current dataset whether it is connected to central node - # adds node_id of orphaned nodes to list - orphaned_node_ids = ( - node - for node in G.nodes - if not nx.has_path(G, node, self.central_activity.key) - ) - - count = 1 - for count, key in enumerate(orphaned_node_ids, 1): - act = get_activity(key) - self.nodes.remove(act) - logger.info(f"Removed ORPHANED nodes: {count}") - - # update edges again to remove those that link to nodes that have been deleted - self.remove_outside_exchanges() - - def get_json_data(self) -> Optional[str]: - """ - Make the JSON graph data from a list of nodes and edges. - - Args: - nodes: a list of nodes (Activity objects) - edges: a list of edges (Exchange objects) - Returns: - A JSON representation of this. - """ - if not self.nodes: - logger.info("Graph has no nodes (activities).") - return - - data = { - "nodes": [Graph.build_json_node(act) for act in self.nodes], - "edges": [ - Graph.build_json_edge(exc, self.flip_negative_edges) - for exc in self.edges - ], - "title": self.central_activity.get("reference product"), - } - # print("JSON DATA (Nodes/Edges):", len(nodes), len(edges)) - # print(data) - return json.dumps(data) - - @staticmethod - def build_json_node(act) -> dict: - """Take an activity and return a valid JSON document.""" - return { - "database": act.key[0], - "id": act.key[1], - "product": act.get("reference product") or act.get("name"), - "name": act.get("name"), - "location": act.get("location"), - "class": identify_activity_type(act), - } - - @staticmethod - def build_json_edge(exc, flip_negative: bool) -> dict: - """Take an exchange object and return a valid JSON document. - - ``flip_negative`` will change the direction of the edge to represent - the correct physical flow direction. However, this is experimental, - and may not be reflected in the actual display of the product/flow. - """ - product = exc.input - reference = product.get("reference product") or product.get("name") - amount = exc.get("amount") - from_act, to_act = exc.input, exc.output - if flip_negative and amount < 0: - from_act, to_act = to_act, from_act - amount = abs(amount) - return { - "source_id": from_act.key[1], - "target_id": to_act.key[1], - "amount": amount, - "unit": exc.get("unit"), - "product": reference, - "tooltip": "{:.3g} {} of {}".format( - amount, exc.get("unit", ""), reference - ), - } diff --git a/activity_browser/ui/web/webutils.py b/activity_browser/ui/web/webutils.py deleted file mode 100644 index f300ffabd..000000000 --- a/activity_browser/ui/web/webutils.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -# type "localhost:3999" in Chrome for DevTools of AB web content -from activity_browser.bwutils.filesystem import get_package_path - -os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "3999" - - -def get_static_js_path(file_name: str = "") -> str: - return str(get_package_path() / "static" / "javascript" / file_name) - - -def get_static_css_path(file_name: str = "") -> str: - return str(get_package_path() / "static" / "css" / file_name) \ No newline at end of file diff --git a/activity_browser/ui/widgets/README.md b/activity_browser/ui/widgets/README.md new file mode 100644 index 000000000..cb3bc002e --- /dev/null +++ b/activity_browser/ui/widgets/README.md @@ -0,0 +1,202 @@ +# widgets + +Reusable custom widget components for the Activity Browser interface. + +## Overview + +This directory contains a collection of custom Qt widgets used throughout Activity Browser. These widgets extend Qt's base widgets with application-specific functionality, styling, and behavior. + +## Key Files + +### Abstract Base Classes +- **`abstract_page.py`** - Base class for main content area pages +- **`abstract_pane.py`** - Base class for dock-able side panels + +### Layout and Container Widgets +- **`central.py`** - Central widget that holds the main content area +- **`dock_widget.py`** - Custom dock widget with additional features +- **`tab_widget.py`** - Enhanced tab widget with custom styling + +### Input Widgets +- **`line_edit.py`** - Enhanced single-line text input +- **`text_edit.py`** - Multi-line text editor with additional features +- **`combobox.py`** - Drop-down selection with search and filtering +- **`formula_edit.py`** - Specialized editor for parameter formulas +- **`database_name_edit.py`** - Input widget for database names with validation + +### Display Widgets +- **`label.py`** - Custom labels with additional styling options +- **`tree_view.py`** - Enhanced tree view for hierarchical data +- **`plot.py`** - Plotting widgets for charts and graphs + +### Interactive Widgets +- **`buttons.py`** - Custom button variations (icon buttons, toggle buttons) +- **`button_collapser.py`** - Collapsible sections with expand/collapse buttons +- **`comparison_switch.py`** - Switch between different comparison views +- **`cutoff_menu.py`** - Menu for selecting cutoff thresholds +- **`menu.py`** - Enhanced context and popup menus + +### Utility Widgets +- **`file_selector.py`** - File/directory selection with browse button +- **`drop_overlay.py`** - Visual overlay for drag-and-drop operations +- **`line.py`** - Visual separator lines + +### Wizards +- **`wizard.py`** - Base wizard dialog for multi-step workflows +- **`wizard_page.py`** - Individual pages within wizards + +## Widget Categories + +### Page Widgets (AbstractPage) +Main content pages inherit from `AbstractPage`: +- Consistent toolbar integration +- Signal connection handling +- State management +- Layout conventions + +```python +from activity_browser.ui.widgets import AbstractPage + +class MyPage(AbstractPage): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() +``` + +### Pane Widgets (AbstractPane) +Dock-able panes inherit from `AbstractPane`: +- Dock widget functionality +- Visibility persistence +- Resize handling +- Title bar customization + +```python +from activity_browser.ui.widgets import AbstractPane + +class MyPane(AbstractPane): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_content() +``` + +### Input Widgets +Enhanced input widgets with: +- Validation +- Placeholder text +- Clear buttons +- Auto-completion +- Format enforcement + +### Display Widgets +Specialized display widgets: +- Custom rendering +- Context menus +- Copy/export functionality +- Sorting and filtering + +## Common Patterns + +### Signal Connections +Widgets connect to global signals: +```python +from activity_browser import app + +app.signals.data_changed.connect(self.refresh) +``` + +### Validation +Input widgets validate data: +```python +class MyLineEdit(QLineEdit): + def validate_input(self): + if not self.text().strip(): + self.setStyleSheet("border: 1px solid red") + return False + return True +``` + +### Context Menus +Many widgets provide context menus: +```python +def contextMenuEvent(self, event): + menu = QMenu(self) + menu.addAction("Copy", self.copy_selection) + menu.addAction("Export", self.export_data) + menu.exec_(event.globalPos()) +``` + +## Styling + +Widgets use Qt stylesheets for consistent appearance: + +```python +self.setStyleSheet(""" + QWidget { + background-color: #ffffff; + color: #000000; + } + QPushButton { + border: 1px solid #cccccc; + border-radius: 3px; + padding: 5px; + } +""") +``` + +## Development Guidelines + +When creating custom widgets: + +1. **Inherit from appropriate base class** - Use AbstractPage/AbstractPane when applicable +2. **Emit signals for state changes** - Enable other components to react +3. **Support keyboard navigation** - Implement tab order and shortcuts +4. **Provide context menus** - Right-click actions for common operations +5. **Validate input** - Check data before accepting +6. **Handle errors gracefully** - Show user-friendly error messages +7. **Use consistent styling** - Follow application design patterns +8. **Document public API** - Docstrings for public methods and signals +9. **Make widgets reusable** - Avoid hard-coding application logic +10. **Test widgets independently** - Unit tests for widget behavior + +## Reusability + +Widgets should be: +- **Self-contained** - Minimal external dependencies +- **Configurable** - Properties for customization +- **Composable** - Can be combined into complex UIs +- **Generic** - Not tied to specific data models + +## Accessibility + +Consider accessibility: +- Keyboard navigation +- Screen reader compatibility +- High contrast support +- Focus indicators +- Logical tab order + +## Performance + +Optimize widget performance: +- Lazy loading of data +- Virtual scrolling for large lists +- Efficient repainting +- Debounced event handlers +- Cache computed values + +## Testing + +Widget tests should verify: +- Initial state and defaults +- User interactions (clicks, text entry) +- Signal emission +- Validation logic +- Edge cases and error handling + +Use pytest-qt for testing: +```python +def test_my_widget(qtbot): + widget = MyWidget() + qtbot.addWidget(widget) + # Test widget behavior +``` diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 7177df41a..8f0434434 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -4,9 +4,7 @@ from .cutoff_menu import CutoffMenu from .line_edit import (ABLineEdit, SignalledComboEdit, SignalledLineEdit, SignalledPlainTextEdit) -from .treeview import ABTreeView -from .item_model import ABItemModel -from .item import ABAbstractItem, ABBranchItem, ABDataItem +from .text_edit import (ABAutoCompleTextEdit, ABTextEdit, MetaDataAutoCompleteTextEdit) from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit @@ -21,6 +19,8 @@ from .central import CentralTabWidget from .menu import ABMenu from .drop_overlay import ABDropOverlay -from .tree_view import ABNewTreeView +from .tree_view import ABTreeView from .buttons import ABCloseButton, ABMinimizeButton from .tab_widget import ABTabWidget +from .web_engine_page import ABWebEnginePage +from .abstract_navigator import ABAbstractNavigator, ABAbstractGraph diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/widgets/abstract_navigator.py similarity index 89% rename from activity_browser/ui/web/base.py rename to activity_browser/ui/widgets/abstract_navigator.py index fdb363926..065e8b563 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/widgets/abstract_navigator.py @@ -8,18 +8,13 @@ from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot -from activity_browser import app -from activity_browser.settings import ab_settings -from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons +from activity_browser.bwutils import filesystem -from ...ui.icons import qicons -from . import webutils -from .webengine_page import Page +from .web_engine_page import ABWebEnginePage - - -class BaseNavigatorWidget(QtWidgets.QWidget): +class ABAbstractNavigator(QtWidgets.QWidget): HELP_TEXT = """ This is the text shown when the user presses 'help'. """ @@ -29,14 +24,14 @@ def __init__(self, parent=None, css_file: str = "", *args, **kwargs): super().__init__(parent) # Graph object subclassed from BaseGraph. - self.graph: Type[BaseGraph] + self.graph: Type[ABAbstractGraph] # Setup JS / Qt interactions self.bridge = Bridge(self) self.channel = QtWebChannel.QWebChannel(self) self.channel.registerObject("bridge", self.bridge) self.view = QtWebEngineWidgets.QWebEngineView(self) - self.page = Page(self.view) + self.page = ABWebEnginePage(self.view) self.view.setPage(self.page) self.view.loadFinished.connect(self.load_finished_handler) self.view.setContextMenuPolicy(Qt.PreventContextMenu) @@ -77,23 +72,17 @@ def toggle_help(self) -> None: def go_forward(self) -> None: if self.graph.forward(): - app.signals.new_statusbar_message.emit("Going forward.") self.send_json() - else: - app.signals.new_statusbar_message.emit("No data to go forward to.") def go_back(self) -> None: if self.graph.back(): - app.signals.new_statusbar_message.emit("Going back.") self.send_json() - else: - app.signals.new_statusbar_message.emit("No data to go back to.") def send_json(self) -> None: if self.graph.json_data is None: return self.bridge.graph_ready.emit(self.graph.json_data) - css_path = webutils.get_static_css_path(self.css_file) + css_path = get_static_css_path(self.css_file) with open(css_path, "r") as css_file: css_code = css_file.read() @@ -113,11 +102,14 @@ def random_graph(self) -> None: def savefilepath(default_file_name: str, file_filter: str = ALL_FILTER): + from activity_browser.bwutils import filesystem + import bw2data as bd + default = default_file_name or "Graph SVG Export" safe_name = bd.utils.safe_filename(default, add_hash=False) filepath, _ = QtWidgets.QFileDialog.getSaveFileName( caption="Choose location to save svg", - dir=os.path.join(ab_settings.data_dir, safe_name), + dir=os.path.join(filesystem.get_project_path(), safe_name), filter=file_filter, ) return filepath @@ -165,7 +157,7 @@ def download_triggered(self, svg: str): to_svg(svg) -class BaseGraph(object): +class ABAbstractGraph(object): def __init__(self): self.json_data = None # stores previous graphs, if any, and enables back/forward buttons @@ -217,3 +209,10 @@ def save_json_to_file(self, filename: str = "graph_data.json") -> None: filepath = os.path.join(os.path.dirname(__file__), filename) with open(filepath, "w") as outfile: json.dump(self.json_data, outfile) + +def get_static_js_path(file_name: str = "") -> str: + return str(filesystem.get_package_path() / "static" / "javascript" / file_name) + + +def get_static_css_path(file_name: str = "") -> str: + return str(filesystem.get_package_path() / "static" / "css" / file_name) \ No newline at end of file diff --git a/activity_browser/ui/widgets/buttons.py b/activity_browser/ui/widgets/buttons.py index 6bce9795f..a153cfd66 100644 --- a/activity_browser/ui/widgets/buttons.py +++ b/activity_browser/ui/widgets/buttons.py @@ -12,7 +12,7 @@ def __init__(self, parent=None): self.label = QtWidgets.QLabel("×", self) - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) self.label.setAlignment(Qt.AlignCenter) self.label.setFixedSize(16, 16) self.label.mousePressEvent = lambda event: self.clicked.emit() @@ -42,7 +42,7 @@ def __init__(self, parent=None): self.label = QtWidgets.QLabel("-", self) - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) self.label.setAlignment(Qt.AlignCenter) self.label.setFixedSize(16, 16) self.label.mousePressEvent = lambda event: self.clicked.emit() diff --git a/activity_browser/ui/widgets/cutoff_menu.py b/activity_browser/ui/widgets/cutoff_menu.py index 397d5ae2f..e0f1f5016 100644 --- a/activity_browser/ui/widgets/cutoff_menu.py +++ b/activity_browser/ui/widgets/cutoff_menu.py @@ -9,7 +9,6 @@ from collections import namedtuple from typing import Union -import numpy as np from qtpy import QtCore from qtpy.QtCore import QLocale, Qt, Signal, Slot from qtpy.QtGui import QDoubleValidator, QIntValidator @@ -413,6 +412,7 @@ def log_value(self) -> Union[int, float]: This function converts the 1-100 values and modifies these to 0.001-100 on a logarithmic scale. Rounding is done based on magnitude. """ + import numpy as np # Logarithmic math refresher: # BOP = Base, Outcome Power; @@ -437,6 +437,8 @@ def log_value(self) -> Union[int, float]: @log_value.setter def log_value(self, value: float) -> None: """Modify value from 0.001-100 to 1-100 logarithmically and set slider to value.""" + import numpy as np + value = int(float(value) * np.power(10, 3)) log_val = np.log10(value).round(3) set_val = log_val * 20 diff --git a/activity_browser/ui/widgets/database_name_edit.py b/activity_browser/ui/widgets/database_name_edit.py index 5cc210a4b..0d6aa05c2 100644 --- a/activity_browser/ui/widgets/database_name_edit.py +++ b/activity_browser/ui/widgets/database_name_edit.py @@ -1,7 +1,5 @@ from qtpy import QtWidgets, QtCore -import bw2data as bd - class DatabaseNameEdit(QtWidgets.QWidget): """ @@ -73,5 +71,6 @@ def setText(self, text: str): self.database_name.setText(text) def willOverwrite(self) -> bool: + import bw2data as bd return self.database_name.text() in bd.databases diff --git a/activity_browser/ui/widgets/drop_overlay.py b/activity_browser/ui/widgets/drop_overlay.py index 326b2e2d8..66ab1ce71 100644 --- a/activity_browser/ui/widgets/drop_overlay.py +++ b/activity_browser/ui/widgets/drop_overlay.py @@ -1,24 +1,60 @@ +from typing import Literal + from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt class ABDropOverlay(QtWidgets.QWidget): - def __init__(self, parent=None): + opacityMap = { + "low": 100, + "medium": 150, + "high": 200, + } + + def __init__(self, parent=None, text="Drop here to create new exchanges"): super().__init__(parent) - self.setAttribute(Qt.WA_TransparentForMouseEvents) - self.setAttribute(Qt.WA_NoSystemBackground) - self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAutoFillBackground(False) self.resize(parent.size()) + self._text = text + self._opacity: Literal["low", "medium", "high"] = "medium" + + def hovering(self) -> bool: + cursor_pos = QtGui.QCursor.pos() + widget_rect = self.rect() + local_pos = self.mapFromGlobal(cursor_pos) + return widget_rect.contains(local_pos) + + def setOpacity(self, level: Literal["low", "medium", "high"]): + if level in self.opacityMap: + self._opacity = level + self.update() + + def opacity(self): + return self._opacity + + def text(self): + return self._text + + def setText(self, text: str): + self._text = text + self.update() + + def showEvent(self, event): + self.resize(self.parent().size()) + super().showEvent(event) + def paintEvent(self, event): painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, 200)) # Semi-transparent blue - painter.setPen(Qt.white) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, self.opacityMap[self.opacity()])) # Semi-transparent blue + painter.setPen(Qt.GlobalColor.white) font = self.font() font.setBold(True) painter.setFont(font) - painter.drawText(self.rect(), Qt.AlignCenter, "Drop here to create new exchanges") + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 0a536ff37..47920f135 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -5,13 +5,13 @@ from asteval import make_symbol_table, Interpreter -from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView, QSizePolicy +from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView from qtpy.QtGui import QPainter, QColor, QFontMetrics, QFontDatabase, QPainterPath, QPen, QFont from qtpy.QtCore import QTimer, Qt, QAbstractTableModel, QModelIndex from activity_browser.static import fonts -QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") + operators = r"+\-*/%=<>!&|^~" pattern = r"\b[a-zA-Z_]\w*\b|[\d.]+|[\"'{}:,+\-*/^()\[\]]| +" @@ -56,7 +56,9 @@ class Colors: class ABFormulaEdit(QWidget): - def __init__(self, parent=None, scope=None, text=None): + def __init__(self, parent=None, scope=None, text=None, simple=False): + QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") + super().__init__(parent) self.scope = scope or {} self.error = False @@ -67,12 +69,16 @@ def __init__(self, parent=None, scope=None, text=None): self.scroll_offset = 0 # Scroll position for long text self.padding = 5 # Left padding for text inside the box self.dragging = False # Track if mouse is dragging + self.text = text or "" # Stores user input font = self.font() font.setFamily("JetBrains Mono") font.setPointSize(9) self.setFont(font) + if simple: + return + self.timer = QTimer(self) self.timer.timeout.connect(self.toggle_cursor) self.timer.start(500) # Blink cursor every 500ms @@ -87,8 +93,6 @@ def __init__(self, parent=None, scope=None, text=None): self.completer.setCompletionColumn(0) self.completer.activated.connect(self.insert_completion) - self.text = text or "" # Stores user input - @property def text(self): return self._text diff --git a/activity_browser/ui/widgets/item.py b/activity_browser/ui/widgets/item.py deleted file mode 100644 index 4ac9c0b66..000000000 --- a/activity_browser/ui/widgets/item.py +++ /dev/null @@ -1,153 +0,0 @@ -import pandas as pd -from qtpy import QtGui, QtCore - - -class ABAbstractItem: - - def __init__(self, key, parent=None): - self._key = key - self._child_keys = [] - self._child_items = {} - self._parent = None - - if parent: - self.set_parent(parent) - - def __getitem__(self, item): - raise NotImplementedError - - def parent(self) -> "ABAbstractItem": - return self._parent - - def key(self): - return self._key - - def children(self): - return self._child_items - - def path(self) -> [str]: - return self.parent().path() + [self.key()] if self.parent() else [] - - def rank(self) -> int: - """Return the rank of the ABItem within the parent. Returns -1 if there is no parent.""" - if self.parent is None: - return -1 - return self.parent()._child_keys.index(self.key()) - - def has_children(self) -> bool: - return bool(self._child_keys) - - def set_parent(self, parent: "ABAbstractItem"): - if self.key() in parent.children(): - raise KeyError(f"Item {self.key()} is already a child of {parent.key()}") - - if self.parent(): - self.parent()._child_keys.remove(self.key()) - del self.parent()._child_items[self.key()] - - parent._child_items[self.key()] = self - parent._child_keys.append(self.key()) - self._parent = parent - - def loc(self, key_or_path: object | list[object], default=None): - key = key_or_path.pop(0) if isinstance(key_or_path, list) else key_or_path - - if isinstance(key_or_path, list) and len(key_or_path) > 0: - return self._child_items[key].loc(key_or_path, default) - - return self._child_items.get(key, default) - - def iloc(self, index: int, default=None): - return self.loc(self._child_keys[index], default) - - def displayData(self, col: int, key: str): - return None - - def decorationData(self, col: int, key: str): - return None - - def fontData(self, col: int, key: str): - return None - - def backgroundData(self, col: int, key: str): - return None - - def foregroundData(self, col: int, key: str): - return None - - def flags(self, col: int, key: str): - return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable - - def setData(self, col: int, key: str, value): - return False - - -class ABBranchItem(ABAbstractItem): - - def __getitem__(self, item): - return None - - def put(self, item: ABAbstractItem, path): - key = path.pop(0) - if path: - sub = self.loc(key) - sub = sub if sub else self.__class__(key, self) - sub.put(item, path) - else: - item.set_parent(self) - - def set_parent(self, parent: "ABAbstractItem"): - if self.key() in parent._child_items: - twin = parent.loc(self.key()) - for child in twin.child_items.values(): - child.set_parent(self) - - if self.parent(): - self.parent()._child_keys.remove(self.key()) - del self.parent()._child_items[self.key()] - - parent._child_items[self.key()] = self - - branches = [isinstance(parent._child_items[key], ABBranchItem) for key in parent._child_keys] - i = branches.index(False) if False in branches else len(branches) - parent._child_keys.insert(i, self.key()) - self._parent = parent - - def displayData(self, col: int, key: str): - if col == 0: - return self.key() - else: - return None - - -class ABDataItem(ABAbstractItem): - def __init__(self, key, data, parent=None): - super().__init__(key, parent) - self.data = data - - def __getitem__(self, item): - return self.data.get(item) - - def displayData(self, col: int, key: str): - data = self[key] - - if isinstance(data, (list, tuple)): - # skip isna check for lists/tuples - pass - elif data is None or pd.isna(data): - return None - - if isinstance(data, str): - # clean up the data to a table-readable format - data = data.replace("\n", " ") - - return data - - def fontData(self, col: int, key: str): - font = QtGui.QFont() - - # set the font to italic if the display value is Undefined - if self.displayData(col, key) == "Undefined": - font.setItalic(True) - - return font diff --git a/activity_browser/ui/widgets/item_model.py b/activity_browser/ui/widgets/item_model.py deleted file mode 100644 index 0ebcccbff..000000000 --- a/activity_browser/ui/widgets/item_model.py +++ /dev/null @@ -1,281 +0,0 @@ -import pandas as pd -from qtpy import QtCore, QtGui -from qtpy.QtCore import Qt, Signal, SignalInstance - -from activity_browser.ui.icons import qicons - -from .item import ABAbstractItem, ABBranchItem, ABDataItem - - -class ABItemModel(QtCore.QAbstractItemModel): - grouped: SignalInstance = Signal(list) - - dataItemClass = ABDataItem - branchItemClass = ABBranchItem - - def __init__(self, parent=None, dataframe=None): - super().__init__(parent) - - if dataframe is None: - dataframe = pd.DataFrame() - - self.dataframe: pd.DataFrame = dataframe # DataFrame containing the visible data - self.root: ABBranchItem = self.branchItemClass("root") # root ABItem for the object tree - self.grouped_columns: list[int] = list() # list of columns that are currently being grouped - self.filtered_columns: set[int] = set() # set of all columns that have filters applied - self.sort_column: int = -1 # column that is currently sorted - self.sort_order: Qt.SortOrder = Qt.SortOrder.AscendingOrder - self._query = "" # Pandas query currently applied to the dataframe - - self.setDataFrame(self.dataframe) - - def columns(self): - return [col for col in self.dataframe.columns if not str(col).startswith("_")] - - def headers(self): - return self.columns() - - def index(self, row: int, column: int, parent: QtCore.QModelIndex = ...) -> QtCore.QModelIndex: - """ - Create a QModelIndex based on a specific row, column and parent. Sets the associated ABItem as - internalPointer. This will be the root ABItem if the parent is invalid. - """ - # get the parent ABItem, or the root ABItem if the parent is invalid - parent = parent.internalPointer() if parent.isValid() else self.root - - # get the child ABItem from the parent with the same rank as the specified row - child = parent.iloc(row) - - # create and return a QModelIndex - return self.createIndex(row, column, child) - - def indexFromPath(self, path: [str]) -> QtCore.QModelIndex: - """ - Create a QModelIndex based on a specific path for the ABItem tree. The index column will be 0. - """ - # get the ABItem for that specific path - child = self.root.loc(path) - if child is None: - return QtCore.QModelIndex() - - # create and return a QModelIndex with the child's rank as row and 0 as column - return self.createIndex(child.rank(), 0, child) - - def parent(self, child: QtCore.QModelIndex) -> QtCore.QModelIndex: - """ - Return the parent of a QModelIndex. - """ - if not child.isValid(): - return QtCore.QModelIndex() - - # get the ABItem from the QModelIndex - child = child.internalPointer() - - # try to get the parent ABItem from the child - try: - parent = child.parent() - # return an invalid/empty QModelIndex if this fails - except: - return QtCore.QModelIndex() - - # if the parent is the root ABItem return an invalid/empty QModelIndex - if parent == self.root: - return QtCore.QModelIndex() - - # create and return a QModelIndex with the child's rank as row and 0 as column - return self.createIndex(parent.rank(), 0, parent) - - def rowCount(self, parent: QtCore.QModelIndex = ...) -> int: - """ - Return the number of rows within the model - """ - # return 0 if there is no DataFrame - if self.dataframe is None: - return 0 - # if the parent is the top of the table, the rowCount is the number of children for the root ABItem - if not parent.isValid(): - value = len(self.root.children()) - # else it's the number of children within the ABItem saved within the internalPointer - elif isinstance(parent.internalPointer(), ABAbstractItem): - value = len(parent.internalPointer().children()) - # this shouldn't happen, but a failsafe - else: - value = 0 - return value - - def columnCount(self, parent: QtCore.QModelIndex = ...) -> int: - """ - Return the number of columns within the model - """ - # return 0 if there is no DataFrame - if self.dataframe is None: - return 0 - return len(self.columns()) - - def data(self, index: QtCore.QModelIndex, role=Qt.ItemDataRole.DisplayRole): - """ - Get the data associated with a specific index and role - """ - if not index.isValid() or not isinstance(index.internalPointer(), ABAbstractItem): - return None - - item: ABAbstractItem = index.internalPointer() - col = index.column() - key = self.columns()[col] - - # redirect to the item's displayData method - if role == Qt.ItemDataRole.DisplayRole: - return item.displayData(col, key) - - # redirect to the item's fontData method - if role == Qt.ItemDataRole.FontRole: - return item.fontData(col, key) - - # redirect to the item's decorationData method - if role == Qt.ItemDataRole.DecorationRole: - return item.decorationData(col, key) - - if role == Qt.ItemDataRole.BackgroundRole: - return item.backgroundData(col, key) - - if role == Qt.ItemDataRole.ForegroundRole: - return item.foregroundData(col, key) - - # else return None - return None - - def setData(self, index: QtCore.QModelIndex, value, role=Qt.ItemDataRole.EditRole) -> bool: - if not index.isValid() or not isinstance(index.internalPointer(), ABAbstractItem): - return False - - if role == Qt.ItemDataRole.EditRole: - success = index.internalPointer().setData(index.column(), self.columns()[index.column()], value) - - if success: - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole]) - return success - - return False - - def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.ItemDataRole.DisplayRole): - if orientation != Qt.Orientation.Horizontal: - return None - - if role == Qt.ItemDataRole.DisplayRole: - if section == 0 and self.grouped_columns: - return " > ".join([self.headers()[column] for column in self.grouped_columns] + [self.headers()[0]]) - return self.headers()[section] - - if role == Qt.ItemDataRole.FontRole and section in self.filtered_columns: - font = QtGui.QFont() - font.setUnderline(True) - return font - - if role == Qt.ItemDataRole.DecorationRole and section in self.filtered_columns: - return qicons.filter - - def flags(self, index): - if not index.isValid() or not isinstance(index.internalPointer(), ABAbstractItem): - return Qt.ItemFlag.NoItemFlags - if index.column() > len(self.columns()) - 1: - return Qt.ItemFlag.NoItemFlags - - return index.internalPointer().flags(index.column(), self.columns()[index.column()]) - - def endResetModel(self): - """ - Reset the model based on dataframe, query and grouped columns. Should be called to reflect the changes of - changing the dataframe, grouped columns or query string. - """ - # if self.dataframe is None or self.dataframe.empty: - # return - - # apply any queries to the dataframe - if q := self.query(): - df = self.dataframe.query(q).reset_index(drop=True).copy() - else: - df = self.dataframe.copy() - - if not self.sort_column > len(self.columns()) - 1 and self.sort_column != -1: - # apply the sorting - df.sort_values( - by=self.columns()[self.sort_column], - ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), - inplace=True, ignore_index=True - ) - - # rebuild the ABItem tree - self.root = self.branchItemClass("root") - items = self.createItems(df) - - # if no grouping of Entries, just append everything as a direct child of the root ABItem - if not self.grouped_columns: - for i, item in enumerate(items): - item.set_parent(self.root) - # else build paths based on the grouped columns and create an ABItem tree - else: - column_names = [self.columns()[column] for column in self.grouped_columns] - - for i, *paths in df[column_names].itertuples(): - joined_path = [] - - for path in paths: - joined_path.extend(path) if isinstance(path, (list, tuple)) else joined_path.append(path) - - joined_path.append(i) - self.root.put(items[df.index.get_loc(i)], joined_path) - - super().endResetModel() - - def createItems(self, dataframe=None) -> list["ABAbstractItem"]: - if dataframe is None: - dataframe = self.dataframe - return [self.dataItemClass(index, data) for index, data in dataframe.to_dict(orient="index").items()] - - def setDataFrame(self, dataframe: pd.DataFrame): - self.beginResetModel() - self.dataframe = dataframe - self.endResetModel() - - def sort(self, column: int, order=Qt.SortOrder.AscendingOrder): - if column + 1 > len(self.columns()): - return - if column == self.sort_column and order == self.sort_order: - return - - self.beginResetModel() - - self.sort_column = column - self.sort_order = order - - self.endResetModel() - - def group(self, column: int): - self.beginResetModel() - self.grouped_columns.append(column) - self.endResetModel() - self.grouped.emit(self.grouped_columns) - - def ungroup(self): - self.beginResetModel() - self.grouped_columns.clear() - self.endResetModel() - self.grouped.emit(self.grouped_columns) - - def query(self) -> str: - return self._query - - def setQuery(self, query: str): - """Apply the query string to the dataframe and rebuild the model""" - self.beginResetModel() - self._query = query - self.endResetModel() - - def hasChildren(self, parent: QtCore.QModelIndex): - item = parent.internalPointer() - if isinstance(item, ABAbstractItem): - return item.has_children() - return super().hasChildren(parent) - - - diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index b009fb742..0a5c8ea3a 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -1,7 +1,6 @@ from qtpy import QtWidgets from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance from qtpy.QtGui import QTextFormat -from qtpy.QtWidgets import QCompleter class ABLineEdit(QtWidgets.QLineEdit): @@ -111,12 +110,3 @@ def focusOutEvent(self, event): self._before = after app.actions.ActivityModify.run(self._key, self._field, after) super(SignalledComboEdit, self).focusOutEvent(event) - - -class AutoCompleteLineEdit(QtWidgets.QLineEdit): - """Line Edit with a completer attached""" - - def __init__(self, items: list[str], parent=None): - super().__init__(parent=parent) - completer = QCompleter(items, self) - self.setCompleter(completer) diff --git a/activity_browser/ui/widgets/menu.py b/activity_browser/ui/widgets/menu.py index 6d7e667fd..c0399b076 100644 --- a/activity_browser/ui/widgets/menu.py +++ b/activity_browser/ui/widgets/menu.py @@ -1,5 +1,5 @@ from qtpy import QtWidgets -from typing import Callable, Optional +from typing import Callable from inspect import signature @@ -19,3 +19,11 @@ def __init__(self, pos=None, parent=None, title: str = None): def add(self, action, *args, enable=True, text=None, **kwargs): qaction = action.get_QAction(*args, parent=self, enabled=enable, text=text, **kwargs) self.addAction(qaction) + + def callback(self, text: str, func: Callable, args: list = None, kwargs: dict = None): + args = args or [] + kwargs = kwargs or {} + + action = QtWidgets.QAction(text, self) + action.triggered.connect(lambda: func(*args, **kwargs)) + self.addAction(action) diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py index b8a1b84cf..b9bdf1f27 100644 --- a/activity_browser/ui/widgets/plot.py +++ b/activity_browser/ui/widgets/plot.py @@ -1,9 +1,9 @@ - from qtpy import QtWidgets from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure + class ABPlot(QtWidgets.QWidget): ALL_FILTER = "All Files (*.*)" PNG_FILTER = "PNG (*.png)" diff --git a/activity_browser/ui/widgets/tab_widget.py b/activity_browser/ui/widgets/tab_widget.py index 590d68fd2..d3a00561b 100644 --- a/activity_browser/ui/widgets/tab_widget.py +++ b/activity_browser/ui/widgets/tab_widget.py @@ -4,7 +4,7 @@ class ABTabWidget(QtWidgets.QTabWidget): - def __init__(self, name: str, *args): + def __init__(self, *args, **kwargs): """ Initialize the GroupTabWidget. @@ -12,7 +12,7 @@ def __init__(self, name: str, *args): name (str): The name of the group, used as the object name for the widget. *args: Additional positional arguments passed to the parent QTabWidget. """ - super().__init__(*args) + super().__init__(*args, **kwargs) self.setMovable(True) # Allow tabs to be rearranged. self.setTabsClosable(True) # Allow tabs to be closed. self.tabBar().setExpanding(False) diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py new file mode 100644 index 000000000..a665376a8 --- /dev/null +++ b/activity_browser/ui/widgets/text_edit.py @@ -0,0 +1,249 @@ +from qtpy import QtWidgets +from qtpy.QtCore import QTimer, Signal, SignalInstance, QStringListModel, Qt +from qtpy.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument, QFont +from qtpy.QtWidgets import QCompleter, QStyledItemDelegate, QStyle + + +class UnknownWordHighlighter(QSyntaxHighlighter): + def __init__(self, parent: QTextDocument, known_words: set): + super().__init__(parent) + self.known_words = known_words + + # define the format for unknown words + self.unknown_format = QTextCharFormat() + self.unknown_format.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + self.unknown_format.setUnderlineColor(Qt.red) + + def highlightBlock(self, text: str): + if text.startswith("="): + return + words = text.split() + index = 0 + for word in words: + word_len = len(word) + if word and word not in self.known_words: + self.setFormat(index, word_len, self.unknown_format) + index += word_len + 1 # +1 for the space + + +class AutoCompleteDelegate(QStyledItemDelegate): + def __init__(self, parent=None): + super().__init__(parent) + self.current_word_index = -1 + + def paint(self, painter, option, index): + text = index.data(Qt.DisplayRole) + + painter.save() + + # Draw selection background if selected + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + painter.setPen(option.palette.highlightedText().color()) + else: + painter.setPen(option.palette.text().color()) + + # Split text into words and draw each with appropriate font + words = text.split(" ") + x = option.rect.x() + y = option.rect.y() + spacing = 4 # space between words + font = option.font + metrics = painter.fontMetrics() + + for i, word in enumerate(words): + word_font = QFont(font) + if i+1 == self.current_word_index: + word_font.setBold(True) + painter.setFont(word_font) + + word_width = metrics.horizontalAdvance(word) + painter.drawText(x, y + metrics.ascent() + (option.rect.height() - metrics.height()) // 2, word) + x += word_width + spacing + painter.restore() + + +class ABTextEdit(QtWidgets.QTextEdit): + textChangedDebounce: SignalInstance = Signal(str) + _debounce_ms = 250 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._debounce_timer = QTimer(self, singleShot=True) + + self.textChanged.connect(self._set_debounce) + self._debounce_timer.timeout.connect(self._emit_debounce) + + def _set_debounce(self): + self._debounce_timer.setInterval(self._debounce_ms) + self._debounce_timer.start() + + def _emit_debounce(self): + self.textChangedDebounce.emit(self.toPlainText()) + + def debounce(self): + return self._debounce_ms + + def setDebounce(self, ms: int): + self._debounce_ms = ms + + +class ABAutoCompleTextEdit(ABTextEdit): + def __init__(self, parent=None, highlight_unknown=False): + from activity_browser.bwutils.metadata import MetaDataStore # avoid circular import, should we refactor? + + self.mds = MetaDataStore() + super().__init__(parent=parent) + + self.auto_complete_word = "" + + # autocompleter settings + self.model = QStringListModel() + self.completer = QCompleter(self.model) + self.completer.setWidget(self) + self.popup = self.completer.popup() + self.delegate = AutoCompleteDelegate(self.popup) # set custom delegate to bold the current word + self.popup.setItemDelegate(self.delegate) + self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.completer.setPopup(self.popup) + self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # allow all items in popup list + self.completer.activated.connect(self._insert_auto_complete) + + self.textChanged.connect(self._sanitize_input) + if highlight_unknown: + self.highlighter = UnknownWordHighlighter(self.document(), set()) + self.cursorPositionChanged.connect(self._set_autocomplete_items) + + def keyPressEvent(self, event): + key = event.key() + + if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab): + # insert an autocomplete item + # capture enter/return/tab key + index = self.popup.currentIndex() + completion_text = index.data(Qt.DisplayRole) + self.completer.activated.emit(completion_text) + return + elif key in (Qt.Key_Space,): + self.popup.close() + + super().keyPressEvent(event) + + # trigger on text input keys + if event.text() or key in (Qt.LeftArrow, Qt.RightArrow): # filters out non-text keys except l/r arrows + self._set_autocomplete_items() + + def _sanitize_input(self): + raise NotImplementedError + + def _set_autocomplete_items(self): + raise NotImplementedError + + def _insert_auto_complete(self, completion): + cursor = self.textCursor() + position = cursor.position() + completion = completion + " " # add space to end of new text + + # find where to put cursor back + new_position = position + while new_position < len(completion) and completion[new_position] != " ": + new_position += 1 + new_position += 1 # add one char for space + + # set new text from completion + self.blockSignals(True) + self.clear() + self.setText(completion) + # set the cursor location + cursor.setPosition(min(new_position, len(completion))) + self.setTextCursor(cursor) + self.blockSignals(False) + + # house keeping + self._emit_debounce() + self.popup.close() + self.auto_complete_word = "" + self.model.setStringList([]) + + +class MetaDataAutoCompleteTextEdit(ABAutoCompleTextEdit): + """TextEdit with MetaDataStore completer attached.""" + def __init__(self, parent=None): + super().__init__(parent=parent, highlight_unknown=True) + self.database_name = "" + + def _sanitize_input(self): + self._debounce_timer.stop() + text = self.toPlainText() + clean_text = self.mds.searcher.ONE_SPACE_PATTERN.sub(" ", text) + + if clean_text != text: + cursor = self.textCursor() + position = cursor.position() + self.blockSignals(True) + self.clear() + self.insertPlainText(clean_text) + self.blockSignals(False) + cursor.setPosition(min(position, len(clean_text))) + self.setTextCursor(cursor) + + known_words = set() + for identifier in self.mds.searcher.database_id_manager(self.database_name): + known_words.update(self.mds.searcher.identifier_to_word[identifier].keys()) + self.highlighter.known_words = known_words + + if len(text) == 0: + self.popup.close() + self._set_debounce() + + def _set_autocomplete_items(self): + text = self.toPlainText() + if text.startswith("="): + self.model.setStringList([]) + self.auto_complete_word = "" + self.popup.close() + return + + # find the start and end of the word under the cursor + cursor = self.textCursor() + position = cursor.position() + start = position + while start > 0 and text[start - 1] != " ": + start -= 1 + end = position + while end < len(text) and text[end] != " ": + end += 1 + current_word = text[start:end] + if not current_word: + self.model.setStringList([]) + self.popup.close() + self.auto_complete_word = "" + return + if self.auto_complete_word == current_word: + # avoid unnecessary auto_complete calls if the current word didnt change + return + self.auto_complete_word = current_word + + context = set((text[:start] + text[end:]).split(" ")) + self.delegate.current_word_index = len(text[:start].split(" ")) # current word index for bolding + # get suggestions for the current word + suggestions = self.mds.searcher.auto_complete(current_word, context=context, database=self.database_name) + suggestions = suggestions[:6] # at most 6, though we should get ~3 usually + # replace the current word with each alternative + items = [] + for alt in suggestions: + new_text = text[:start] + alt + text[end:] + items.append(new_text) + if len(items) == 0: + self.popup.close() + return + + self.model.setStringList(items) + # set correct height now that we have data + max_height = max( + 20, + self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() + ) + self.popup.setMaximumHeight(max_height) + self.completer.complete() diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 32e59bdc4..a94ee9777 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -1,24 +1,20 @@ from loguru import logger -import pandas as pd - from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - -from activity_browser.ui import delegates, core -from .item_model import ABItemModel +from activity_browser.ui import core +from .line_edit import ABLineEdit -class ABNewTreeView(QtWidgets.QTreeView): +class ABTreeView(QtWidgets.QTreeView): # fired when the filter is applied, fires False when an exception happens during querying filtered: QtCore.SignalInstance = QtCore.Signal(bool) defaultColumnDelegates = {} class HeaderMenu(QtWidgets.QMenu): - def __init__(self, pos: QtCore.QPoint, view: "ABNewTreeView"): + def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): super().__init__(view) model = view.model() @@ -26,11 +22,11 @@ def __init__(self, pos: QtCore.QPoint, view: "ABNewTreeView"): col_index = view.columnAt(pos.x()) col_name = model.columns()[col_index] - search_box = QtWidgets.QLineEdit(self) + search_box = ABLineEdit(self) search_box.setText(view.columnFilters.get(col_name, "")) search_box.setPlaceholderText("Search") search_box.selectAll() - search_box.textChanged.connect(lambda query: view.setColumnFilter(col_name, query)) + search_box.textChangedDebounce.connect(lambda query: view.setColumnFilter(col_name, query)) widget_action = QtWidgets.QWidgetAction(self) widget_action.setDefaultWidget(search_box) self.addAction(widget_action) @@ -71,9 +67,10 @@ def __init__(self, pos, view): super().__init__(view) def __init__(self, parent=None): + from activity_browser.ui import delegates + super().__init__(parent) self.setIndentation(10) - self.setUniformRowHeights(True) self.setItemDelegate(delegates.StringDelegate(self)) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) @@ -85,25 +82,30 @@ def __init__(self, parent=None): self.header().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.header().customContextMenuRequested.connect(self.showHeaderMenu) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe self.allFilter: str = "" # filter applied to the entire dataframe def setModel(self, model): super().setModel(model) - self.setColumnWidth(0, 30) - self.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + self.setColumnWidth(0, 20) + self.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) model.modelAboutToBeReset.connect(self.clearColumnDelegates) + model.modelReset.connect(self.updateIndexColumnVisibility) model.modelReset.connect(self.setDefaultColumnDelegates) + model.modelReset.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) model.layoutChanged.connect(self.updateIndexColumnVisibility) - model.layoutChanged.connect(self.updateBranchSpanning) + model.layoutChanged.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) + model.rowsInserted.connect(self.updateBranchSpanningForInsertedRows, QtCore.Qt.ConnectionType.QueuedConnection) self.setDefaultColumnDelegates() self.updateIndexColumnVisibility() self.updateBranchSpanning() - def model(self) -> ABItemModel: + def model(self) -> core.ABTreeModel: return super().model() # === Functionality related to contextmenus @@ -122,10 +124,10 @@ def setColumnFilter(self, column_name: str, query: str): if query: self.columnFilters[column_name] = query - # self.model().filtered_columns.add(col_index) + self.model().filtered_columns.add(col_index) elif column_name in self.columnFilters: del self.columnFilters[column_name] - # self.model().filtered_columns.discard(col_index) + self.model().filtered_columns.discard(col_index) self.applyFilter() @@ -136,7 +138,7 @@ def setAllFilter(self, query: str): self.applyFilter() def buildQuery(self) -> str: - queries = ["(index == index)"] + queries = [] # query for the column filters for col in list(self.columnFilters): @@ -144,7 +146,7 @@ def buildQuery(self) -> str: del self.columnFilters[col] for col, query in self.columnFilters.items(): - q = f"({col}.astype('str').str.contains('{self.format_query(query)}'))" + q = f"({col}.astype('str').str.contains('{self.format_query(query)}', False))" queries.append(q) # query for the all filter @@ -155,7 +157,7 @@ def buildQuery(self) -> str: formatted_filter = self.format_query(self.allFilter) for i, col in enumerate(self.model().columns()): - if self.isColumnHidden(i): + if col == "index" or self.isColumnHidden(i): continue all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") @@ -170,7 +172,7 @@ def buildQuery(self) -> str: def applyFilter(self): query = self.buildQuery() try: - self.model().filter("ABNewTreeView", query) + self.model().filter("ABTreeView", query) self.filtered.emit(True) except Exception as e: logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") @@ -215,6 +217,26 @@ def updateBranchSpanning(self): # Recursively set spanning for all branch nodes self._setSpanningRecursive(QtCore.QModelIndex()) + def updateBranchSpanningForInsertedRows(self, parent: QtCore.QModelIndex, first: int, last: int): + """Update spanning for newly inserted rows during lazy loading.""" + model = self.model() + if model is None or not hasattr(model, 'isBranchNode'): + return + + # Set spanning for the newly inserted rows + for row in range(first, last + 1): + index = model.index(row, 0, parent) + if not index.isValid(): + continue + + # Check if this is a branch node + if model.isBranchNode(index): + self.setFirstColumnSpanned(row, parent, True) + # Recursively process children of this branch node + self._setSpanningRecursive(index) + else: + self.setFirstColumnSpanned(row, parent, False) + def _setSpanningRecursive(self, parent: QtCore.QModelIndex): """Recursively set first column spanning for branch nodes.""" model = self.model() diff --git a/activity_browser/ui/widgets/treeview.py b/activity_browser/ui/widgets/treeview.py deleted file mode 100644 index f6c389514..000000000 --- a/activity_browser/ui/widgets/treeview.py +++ /dev/null @@ -1,269 +0,0 @@ -from loguru import logger - -import pandas as pd - -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - -from .item_model import ABItemModel - - - - -class ABTreeView(QtWidgets.QTreeView): - # fired when the filter is applied, fires False when an exception happens during querying - filtered: QtCore.SignalInstance = QtCore.Signal(bool) - - defaultColumnDelegates = {} - - class HeaderMenu(QtWidgets.QMenu): - def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): - super().__init__(view) - - model = view.model() - - col_index = view.columnAt(pos.x()) - col_name = model.columns()[col_index] - - search_box = QtWidgets.QLineEdit(self) - search_box.setText(view.columnFilters.get(col_name, "")) - search_box.setPlaceholderText("Search") - search_box.selectAll() - search_box.textChanged.connect(lambda query: view.setColumnFilter(col_name, query)) - widget_action = QtWidgets.QWidgetAction(self) - widget_action.setDefaultWidget(search_box) - self.addAction(widget_action) - - self.addAction(QtGui.QIcon(), "Group by column", lambda: model.group(col_index)) - self.addAction(QtGui.QIcon(), "Ungroup", model.ungroup) - self.addAction(QtGui.QIcon(), "Clear column filter", lambda: view.setColumnFilter(col_name, "")) - self.addAction(QtGui.QIcon(), "Clear all filters", - lambda: [view.setColumnFilter(name, "") for name in list(view.columnFilters.keys())], - ) - self.addSeparator() - - def toggle_slot(action: QtWidgets.QAction): - index = action.data() - hidden = view.isColumnHidden(index) - view.setColumnHidden(index, not hidden) - - view_menu = QtWidgets.QMenu(view) - view_menu.setTitle("View") - self.view_actions = [] - - for i in range(1, len(model.columns())): - action = QtWidgets.QAction(model.columns()[i]) - action.setCheckable(True) - action.setChecked(not view.isColumnHidden(i)) - action.setData(i) - view_menu.addAction(action) - self.view_actions.append(action) - - view_menu.triggered.connect(toggle_slot) - - self.addMenu(view_menu) - - search_box.setFocus() - - class ContextMenu(QtWidgets.QMenu): - def __init__(self, pos, view): - super().__init__(view) - - def __init__(self, parent=None): - super().__init__(parent) - - self.setUniformRowHeights(True) - - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.showContextMenu) - - self.setSelectionBehavior(QtWidgets.QTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(QtWidgets.QTreeView.SelectionMode.ExtendedSelection) - - header = self.header() - header.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - header.customContextMenuRequested.connect(self.showHeaderMenu) - - self.expanded_paths = set() - self.expanded.connect(lambda index: self.expanded_paths.add(tuple(index.internalPointer().path()))) - self.collapsed.connect(lambda index: self.expanded_paths.discard(tuple(index.internalPointer().path()))) - - self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe - self.allFilter: str = "" # filter applied to the entire dataframe - - def setModel(self, model): - if not isinstance(model, ABItemModel): - raise TypeError("Model must be an instance of ABItemModel") - super().setModel(model) - - model.modelReset.connect(self.expand_after_reset) - model.modelAboutToBeReset.connect(self.clearColumnDelegates) - model.modelReset.connect(self.setDefaultColumnDelegates) - - self.setDefaultColumnDelegates() - - def model(self) -> ABItemModel: - return super().model() - - # === Functionality related to contextmenus - - def showContextMenu(self, pos): - self.ContextMenu(pos, self).exec_(self.mapToGlobal(pos)) - - def showHeaderMenu(self, pos): - self.HeaderMenu(pos, self).exec_(self.mapToGlobal(pos)) - - def setColumnFilter(self, column_name: str, query: str): - """ - Set a filter for a specific column using a string query. If the query is empty remove the filter from the column - """ - col_index = self.model().columns().index(column_name) - - if query: - self.columnFilters[column_name] = query - self.model().filtered_columns.add(col_index) - elif column_name in self.columnFilters: - del self.columnFilters[column_name] - self.model().filtered_columns.discard(col_index) - - self.applyFilter() - - # === Functionality related to filtering - - def setAllFilter(self, query: str): - self.allFilter = query - self.applyFilter() - - def buildQuery(self) -> str: - queries = ["(index == index)"] - - # query for the column filters - for col in list(self.columnFilters): - if col not in self.model().columns(): - del self.columnFilters[col] - - for col, query in self.columnFilters.items(): - q = f"({col}.astype('str').str.contains('{self.format_query(query)}'))" - queries.append(q) - - # query for the all filter - if self.allFilter.startswith('='): - queries.append(f"({self.allFilter[1:]})") - else: - all_queries = [] - formatted_filter = self.format_query(self.allFilter) - - for i, col in enumerate(self.model().columns()): - if self.isColumnHidden(i) and i not in self.model().grouped_columns: - continue - all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") - - q = f"({' | '.join(all_queries)})" - queries.append(q) - - query = " & ".join(queries) - logger.debug(f"{self.__class__.__name__} built query: {query}") - - return query - - def applyFilter(self): - query = self.buildQuery() - try: - self.model().setQuery(query) - self.filtered.emit(True) - except Exception as e: - logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") - self.filtered.emit(False) - - @staticmethod - def format_query(query: str) -> str: - return query.translate(str.maketrans({'(': '\\(', ')': '\\)', "'": "\\'"})) - - # === Functionality related to setting the column delegates - def clearColumnDelegates(self): - for i in range(self.model().columnCount()): - self.setItemDelegateForColumn(i, None) - - def setDefaultColumnDelegates(self): - columns = self.model().columns() - for i, col_name in enumerate(columns): - if col_name in self.defaultColumnDelegates: - delegate = self.defaultColumnDelegates[col_name](self) - self.setItemDelegateForColumn(i, delegate) - # elif col_name.startswith("property_"): - # self.setItemDelegateForColumn(i, self.propertyDelegate) - - # === Functionality related to saving and restoring the View's state - - def saveState(self) -> dict: - if not self.model(): - return {} - - cols = self.model().columns() - - return { - "columns": cols, - "grouped_columns": [cols[i] for i in self.model().grouped_columns], - "visible_columns": [cols[i] for i in range(len(cols)) if not self.isColumnHidden(i)], - - "expanded_paths": list(self.expanded_paths), - - "filters": self.columnFilters, - "sort_column": cols[self.model().sort_column], - "sort_ascending": self.model().sort_order == Qt.SortOrder.AscendingOrder, - - "header_state": bytearray(self.header().saveState()).hex() - } - - def restoreSate(self, state: dict, dataframe: pd.DataFrame): - if not self.model(): - logger.debug(f"{self.__class__.__name__}: Model must first be set on the treeview before using restoreState") - return - - columns = list(dataframe.columns) - - self.model().beginResetModel() - - self.expanded_paths = set(tuple(p) for p in state.get("expanded_paths", [])) - self.columnFilters = {col: q for col, q in state.get("filters", {}).items() if col in columns} - - self.model().dataframe = dataframe - - self.model().grouped_columns = [columns.index(name) for name in state.get("grouped_columns", []) if name in columns] - self.model().filtered_columns = {columns.index(name) for name in self.columnFilters if name in columns} - - self.model().sort_column = columns.index(state.get("sort_column")) if state.get("sort_column") in columns else 0 - self.model().sort_order = Qt.SortOrder.AscendingOrder if state.get("sort_ascending") else Qt.SortOrder.DescendingOrder - - self.model()._query = self.buildQuery() - - self.model().endResetModel() - - match = True - for i, col in enumerate(state.get("columns", [])): - if i > len(columns) - 1: - match = False - break - if columns[i] != col: - match = False - break - - if match: - self.header().restoreState(bytearray.fromhex(state.get("header_state", ""))) - - for i, col in enumerate(columns): - self.setColumnHidden(i, col not in state.get("visible_columns", [col])) - - self.expand_after_reset() - - def expand_after_reset(self): - indices = [] - for path in self.expanded_paths: - try: - indices.append(self.model().indexFromPath(list(path))) - except KeyError: - continue - - for index in indices: - self.expand(index) - diff --git a/activity_browser/ui/web/webengine_page.py b/activity_browser/ui/widgets/web_engine_page.py similarity index 73% rename from activity_browser/ui/web/webengine_page.py rename to activity_browser/ui/widgets/web_engine_page.py index 7da8506a2..07f0a67ea 100644 --- a/activity_browser/ui/web/webengine_page.py +++ b/activity_browser/ui/widgets/web_engine_page.py @@ -1,14 +1,9 @@ -"""Custom page for debugging javascript code. Without this code, - only console.error messages are printed to python output. - This code will not tell you the javascript file that the error is in.""" from loguru import logger from qtpy.QtWebEngineWidgets import QWebEnginePage - - -class Page(QWebEnginePage): +class ABWebEnginePage(QWebEnginePage): def javaScriptConsoleMessage(self, level: QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): if level == QWebEnginePage.InfoMessageLevel: logger.info(f"JS Info (Line {line}): {message}") diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index aa2834c8f..e5b599998 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -1,17 +1,38 @@ -from typing import TYPE_CHECKING -from qtpy import QtWidgets +from typing import TYPE_CHECKING, Literal +from qtpy import QtWidgets, QtCore if TYPE_CHECKING: from activity_browser.ui.widgets import ABWizardPage +ABWizardButtons = Literal[ + "Stretch", + "BackButton", + "NextButton", + "CancelButton", + "FinishButton", + "HelpButton", + "CommitButton", +] + +ABWizardButtonLayout = list[ABWizardButtons] + + class ABWizard(QtWidgets.QWizard): pages = [] + context = {} + defaultButtonLayout: ABWizardButtonLayout = ["Stretch", "BackButton", "NextButton", "CancelButton"] + finalButtonLayout: ABWizardButtonLayout = ["Stretch", "FinishButton"] def __init__(self, *args, title: str = None, context: dict = None, **kwargs): super().__init__(*args, **kwargs) self.setWizardStyle(QtWidgets.QWizard.WizardStyle.ModernStyle) + self.setWindowFlags( + QtCore.Qt.WindowType.Sheet | + QtCore.Qt.WindowType.CustomizeWindowHint | + QtCore.Qt.WindowType.WindowTitleHint + ) if title: self.setWindowTitle(title) @@ -19,6 +40,18 @@ def __init__(self, *args, title: str = None, context: dict = None, **kwargs): for page in self.pages: self.addPage(page(self)) + text, callback = self.customButtonOne() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton1, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton1).clicked.connect(callback) + + text, callback = self.customButtonTwo() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton2, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton2).clicked.connect(callback) + + text, callback = self.customButtonThree() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton3, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton3).clicked.connect(callback) + self.context = context or {} def page(self, page_id: int) -> "ABWizardPage": @@ -35,3 +68,57 @@ def initializePage(self, page_id): # initialize the next page page = self.page(page_id) page.initializePage(self.context) + + if page.buttonLayout: + if "CommitButton" in page.buttonLayout: + page.setCommitPage(True) + if "FinishButton" in page.buttonLayout: + page.setFinalPage(True) + + self.setButtonLayout(page.buttonLayout) + + elif self.currentId() == self.pageIds()[-1]: + self.setButtonLayout(self.finalButtonLayout) + + else: + self.setButtonLayout(self.defaultButtonLayout) + + def setButtonLayout(self, layout: ABWizardButtonLayout): + button_map = { + "Stretch": QtWidgets.QWizard.WizardButton.Stretch, + "BackButton": QtWidgets.QWizard.WizardButton.BackButton, + "NextButton": QtWidgets.QWizard.WizardButton.NextButton, + "CancelButton": QtWidgets.QWizard.WizardButton.CancelButton, + "FinishButton": QtWidgets.QWizard.WizardButton.FinishButton, + "HelpButton": QtWidgets.QWizard.WizardButton.HelpButton, + "CommitButton": QtWidgets.QWizard.WizardButton.CommitButton, + "CustomButton1": QtWidgets.QWizard.WizardButton.CustomButton1, + "CustomButton2": QtWidgets.QWizard.WizardButton.CustomButton2, + "CustomButton3": QtWidgets.QWizard.WizardButton.CustomButton3, + } + qt_layout = [button_map[item] for item in layout] + super().setButtonLayout(qt_layout) + + default_button = "NextButton" + default_button = "FinishButton" if "FinishButton" in layout else default_button + default_button = "CommitButton" if "CommitButton" in layout else default_button + + # Set the default button after a short delay to ensure the UI is updated + def set_default(): + try: + button = self.button(button_map[default_button]) + button.setFocus() + except RuntimeError: + # Wizard might be closed before the timer fires + pass + + QtCore.QTimer.singleShot(50, set_default) + + def customButtonOne(self): + return "CustomButton1", lambda: None + + def customButtonTwo(self): + return "CustomButton2", lambda: None + + def customButtonThree(self): + return "CustomButton3", lambda: None diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index 295d6288f..446617b9a 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -2,13 +2,14 @@ from qtpy import QtWidgets if TYPE_CHECKING: - from activity_browser.ui.widgets import ABWizard + from .wizard import ABWizard, ABWizardButtonLayout from activity_browser.ui.core.threading import ABThread class ABWizardPage(QtWidgets.QWizardPage): title: str = "" subtitle: str = "" + buttonLayout: "ABWizardButtonLayout" = [] def __init__(self, parent=None): super().__init__(parent) @@ -36,6 +37,9 @@ def initializePage(self, context: dict): def finalize(self, context: dict): pass + def context(self) -> dict: + return self.wizard().context + class ABThreadedWizardPage(ABWizardPage): Thread: type["ABThread"] diff --git a/activity_browser/ui/wizards/__init__.py b/activity_browser/ui/wizards/__init__.py deleted file mode 100644 index 0eb2c33ff..000000000 --- a/activity_browser/ui/wizards/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ..dialogs.uncertainty import UncertaintyWizard diff --git a/activity_browser/ui/wizards/settings_wizard.py b/activity_browser/ui/wizards/settings_wizard.py deleted file mode 100644 index 109a11e88..000000000 --- a/activity_browser/ui/wizards/settings_wizard.py +++ /dev/null @@ -1,316 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from loguru import logger -from pathlib import Path - -from peewee import SqliteDatabase, OperationalError -from qtpy import QtCore, QtWidgets - -from bw2data import projects - -from activity_browser.settings import ab_settings - - - - -class SettingsWizard(QtWidgets.QWizard): - def __init__(self, parent=None): - super().__init__(parent) - self.last_project = projects.current - self.last_bwdir = projects._base_data_dir - - self.setWindowTitle("Activity Browser Settings") - self.settings_page = SettingsPage(self) - self.addPage(self.settings_page) - self.show() - self.button(QtWidgets.QWizard.BackButton).hide() - self.button(QtWidgets.QWizard.FinishButton).clicked.connect(self.save_settings) - self.button(QtWidgets.QWizard.CancelButton).clicked.connect(self.cancel) - - def save_settings(self): - # directory - current_bw_dir = ab_settings.current_bw_dir - field = self.field("current_bw_dir") - if field and field != current_bw_dir: - ab_settings.custom_bw_dir = field - ab_settings.current_bw_dir = field - logger.info(f"Saved startup brightway directory as: {field}") - - # project - field_project = self.field("startup_project") - current_startup_project = ab_settings.startup_project - if field_project and field_project != current_startup_project: - new_startup_project = field_project - ab_settings.startup_project = new_startup_project - logger.info(f"Saved startup project as: {new_startup_project}") - - ab_settings.write_settings() - projects.change_base_directories(Path(field), update=False) - - def cancel(self): - logger.info("Going back to before settings were changed.") - if projects._base_data_dir != self.last_bwdir: - projects.change_base_directories(Path(self.last_bwdir), update=False) - projects.set_current( - self.last_project, update=False, - ) # project changes only if directory is changed - - -class SettingsPage(QtWidgets.QWizardPage): - # TODO Look to add a hover event for switching spaces - def __init__(self, parent=None): - super().__init__(parent) - self.wizard = parent - self.complete = False - - # bw dir - self.bwdir_variables = set() - self.bwdir = QtWidgets.QComboBox() - - self.bwdir_browse_button = QtWidgets.QPushButton("Browse") - self.bwdir_remove_button = QtWidgets.QPushButton("Remove") - self.update_combobox(self.bwdir, ab_settings.custom_bw_dir) - self.restore_defaults_button = QtWidgets.QPushButton("Restore defaults") - self.bwdir_name = QtWidgets.QLineEdit(self.bwdir.currentText()) - self.registerField("current_bw_dir", self.bwdir_name) - - # startup project - self.startup_project_combobox = QtWidgets.QComboBox() - self.update_project_combo() - - self.registerField( - "startup_project", self.startup_project_combobox, "currentText" - ) - - # light/dark theme - self.theme_combo = QtWidgets.QComboBox() - self.theme_combo.addItems([ - "Light theme", - "Dark theme compatibility" - ]) - self.theme_combo.setCurrentText(ab_settings.theme) - self.registerField( - "theme_cbox", self.theme_combo, "currentText" - ) - - # Startup options - self.startup_groupbox = QtWidgets.QGroupBox("Startup Options") - self.startup_layout = QtWidgets.QGridLayout() - self.startup_layout.addWidget(QtWidgets.QLabel("Brightway Dir: "), 0, 0) - self.startup_layout.addWidget(self.bwdir, 0, 1) - self.startup_layout.addWidget(self.bwdir_browse_button, 0, 2) - self.startup_layout.addWidget(self.bwdir_remove_button, 0, 3) - self.startup_layout.addWidget(QtWidgets.QLabel("Startup Project: "), 1, 0) - self.startup_layout.addWidget(self.startup_project_combobox, 1, 1) - self.startup_layout.addWidget(QtWidgets.QLabel("Theme: "), 2, 0) - self.startup_layout.addWidget(self.theme_combo, 2, 1) - self.startup_layout.addWidget(QtWidgets.QLabel("(Requires restart)"), 2, 2) - - self.startup_groupbox.setLayout(self.startup_layout) - - self.layout = QtWidgets.QVBoxLayout() - self.layout.addWidget(self.startup_groupbox) - self.layout.addStretch() - self.layout.addWidget(self.restore_defaults_button) - self.setLayout(self.layout) - self.setFinalPage(True) - self.setButtonText(QtWidgets.QWizard.FinishButton, "Save") - - # signals - self.startup_project_combobox.currentIndexChanged.connect(self.changed) - self.bwdir_browse_button.clicked.connect(self.bwdir_browse) - self.bwdir_remove_button.clicked.connect(self.bwdir_remove) - self.bwdir.currentTextChanged.connect(self.bwdir_change) - self.theme_combo.currentTextChanged.connect(self.theme_change) - self.restore_defaults_button.clicked.connect(self.restore_defaults) - - def bw_projects(self, path: str): - """Finds the bw_projects from the brightway2 environment provided by path""" - # open the project database - database_file = os.path.join(path, "projects.db") - if not os.path.exists(database_file): - return [] - db = SqliteDatabase(database_file) - - # find all project names using sql query and return - try: - cursor = db.execute_sql('SELECT "name" FROM "projectdataset"') - except OperationalError as e: - if "no such table" in str(e): - return [] - raise - return [i[0] for i in cursor.fetchall()] - - def restore_defaults(self): - self.change_bw_dir(ab_settings.get_default_directory()) - self.startup_project_combobox.setCurrentText( - ab_settings.get_default_project_name() - ) - - def bwdir_remove(self): - """ - Removes the project from the AB settings, has additional possiblity of removing data - contained on 'disk'. Provides a warning before execution. - """ - hard_deletion = QtWidgets.QMessageBox.question( - self, - "Delete Brightway2 directory?", - "This action will remove the local information only, click" - "'Yes' to remove\nthe projects. Data on the \"disk\" will remain" - " untouched and needs to be removed manually", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.Cancel, - ) - if hard_deletion == QtWidgets.QMessageBox.Cancel: - return - - removed_dir = self.bwdir.currentText() - removed_index = self.bwdir.currentIndex() - self.bwdir.blockSignals(True) - self.bwdir.setCurrentIndex(-1) - self.bwdir.removeItem(removed_index) - self.bwdir.blockSignals(False) - self.bwdir_variables.remove(removed_dir) - ab_settings.remove_custom_bw_dir(removed_dir) - - def bwdir_change(self, path: str): - """ - Executes on emission of a signal from changes to the QComboBox holding bw2 environments - Scope: Limited to - SettingsPage class - can create new environments and bw2data.projects (exceptions are permitted), will update - contents of the Project QComboBox - settings::ABSettings - uses but doesn't set bw2 variables, sets variables in the settings file - """ - self.change_bw_dir(path) - - def theme_change(self, theme: str): - """Change the theme.""" - if ab_settings.theme != theme: - ab_settings.theme = theme - self.changed() - - def bwdir_browse(self): - """ - Executes on emission of a signal from the browse button - Scope: Limited to - SettingsPage class - provides a file path as a string to the QComboBox holding - bw2data environments - """ - path = QtWidgets.QFileDialog.getExistingDirectory( - self, "Select a brightway2 database folder" - ) - if path: - self.change_bw_dir(os.path.normpath(path)) - - def change_bw_dir(self, path): - """Set startup brightway directory. - Switch to this directory if user wishes (this will update the "projects" combobox correctly). - """ - - # if no projects exist in this directory: ask user if he wants to set up a new brightway data directory here - if not os.path.isfile(os.path.join(path, "projects.db")): - create_new_directory = QtWidgets.QMessageBox.question( - self, - "New brightway data directory?", - 'This directory does not contain any projects. \n Would you like to setup a new brightway data directory here? \n This will close the current project and create a "default" project in the new directory.', - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.Cancel, - ) - if create_new_directory == QtWidgets.QMessageBox.Cancel: - return - else: - self.bwdir_name.setText(path) - self.registerField("current_bw_dir", self.bwdir_name) - self.combobox_add_dir(self.bwdir, path) - # ab_settings.current_bw_dir = path - ab_settings.startup_project = "" - self.bwdir.blockSignals(True) - self.bwdir.setCurrentText(self.bwdir_name.text()) - self.bwdir.blockSignals(False) - self.update_project_combo(path=self.bwdir_name.text()) - self.changed() - else: # a project already exists in this directory - # ask user if to switch directory (which will update the project combobox correctly) - reply = QtWidgets.QMessageBox.question( - self, - "Continue?", - 'Would you like to switch to this directory now? \nThis will close your currently opened project. \nClick "Yes" to be able to choose the startup project.', - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, - ) - if path not in self.bwdir_variables: - self.combobox_add_dir(self.bwdir, path) - if reply == QtWidgets.QMessageBox.Yes: - self.bwdir_name.setText(path) - self.registerField("current_bw_dir", self.bwdir_name) - # ab_settings.current_bw_dir = path - self.update_project_combo(path=self.bwdir_name.text()) - else: - prev_env_index = self.bwdir.findText( - self.bwdir_name.text(), QtCore.Qt.MatchFixedString - ) - self.bwdir.blockSignals(True) - self.bwdir.setCurrentIndex(prev_env_index) - self.bwdir.blockSignals(False) - self.changed() - - def update_project_combo(self, initialization: bool = True, path: str = None): - """ - Updates the project combobox when loading a new brightway environment - """ - self.startup_project_combobox.clear() - if path: - self.project_names = self.bw_projects(path) - else: - self.project_names = self.bw_projects(ab_settings.current_bw_dir) - if self.project_names: - self.startup_project_combobox.addItems(self.project_names) - else: - logger.warning("No projects found in this directory.") - if ab_settings.startup_project in self.project_names: - self.startup_project_combobox.setCurrentText(ab_settings.startup_project) - else: - ab_settings.startup_project = "" - self.startup_project_combobox.setCurrentIndex(-1) - if not initialization: - self.changed() - - def combobox_add_dir(self, box: QtWidgets.QComboBox, path: str) -> None: - """Adds a single directory to the QComboBox.""" - box.blockSignals(True) - box.addItems([path]) - box.blockSignals(False) - if path not in self.bwdir_variables: - self.bwdir_variables.add(path) - ab_settings.custom_bw_dir = path - - def update_combobox(self, box: QtWidgets.QComboBox, labels: list) -> None: - """Update the combobox menu.""" - correct_settings = False - current_dir = ab_settings.current_bw_dir - for i, dir in enumerate(ab_settings.custom_bw_dir): - self.bwdir_variables.add(dir) - if dir == current_dir: - box.blockSignals(True) - box.clear() - box.insertItems(0, labels) - box.blockSignals(False) - box.setCurrentIndex(i) - correct_settings = True - if correct_settings: - return - QtWidgets.QMessageBox.warning( - self, - "Discrepancy in the ABsettings.json file", - "The value provided for the current brightway directory does not exist\n" - "in the available list of directories. Please check the settings file.", - QtWidgets.QMessageBox.Ok, - ) - - def changed(self): - self.wizard.button(QtWidgets.QWizard.BackButton).hide() - self.complete = True - self.completeChanged.emit() - - def isComplete(self): - return self.complete \ No newline at end of file diff --git a/activity_browser_beta/__init__.py b/activity_browser_beta/__init__.py deleted file mode 100644 index 2213aec91..000000000 --- a/activity_browser_beta/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from importlib import metadata -from os import environ -from conda import cli -import requests - - -def check_ab_update() -> bool: - ab_url = "https://api.anaconda.org/package/mrvisscher/activity-browser-beta" - ab_response = requests.get(ab_url) - ab_current = metadata.version("activity_browser") - - if ab_response.status_code != 200: - print("Could not fetch latest activity browser beta version") - return False - - ab_latest = ab_response.json()['latest_version'].replace(".", "") - - print(f"activity_browser_beta: {ab_current} x {ab_latest}") - - if ab_current == "0.0.0" or ab_current == ab_latest: - return False - return True - - -def run(): - from activity_browser import run_activity_browser - print("Launching the Activity Browser") - run_activity_browser() - - -def run_activity_browser(): - print("Activity Browser 3 Beta Release") - print("______________________________________") - ab = check_ab_update() - print("______________________________________") - if ab and environ.get("CONDA_DEFAULT_ENV"): - print("Updating activity-browser-beta") - cli.main("update", "-c", "mrvisscher", "activity-browser-beta",) - run() diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..30a08a194 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,205 @@ +# docs + +Documentation for Activity Browser. + +## Overview + +This directory contains the source files for Activity Browser's documentation website, which is built using Jekyll and hosted on GitHub Pages. + +## Structure + +- **Jekyll Site Configuration** + - `_config.yml` - Jekyll site configuration + - `Gemfile` - Ruby gem dependencies + - `404.html` - Custom 404 error page + - `index.md` - Documentation homepage + +- **`_includes/`** - Reusable HTML/Liquid templates + - `nav_footer_custom.html` - Custom navigation footer + - `search_placeholder_custom.html` - Custom search placeholder + +- **`_sass/`** - SASS/CSS stylesheets + - `custom/` - Custom styling overrides + +- **`getting-started/`** - Getting started guides + - `installation.md` - Installation instructions + - `project-setup.md` - Setting up your first project + - `creating-databases.md` - Creating and managing databases + - `building-models.md` - Building LCA models + - `lca-calculations.md` - Running LCA calculations + - `index.md` - Getting started overview + +- **`user-interface/`** - UI documentation + - `pages/` - Documentation for each page + - `index.md` - UI overview + +- **`advanced-topics/`** - Advanced features + - `project-structure.md` - Understanding project structure + - `scenario-calculations.md` - Scenario analysis + - `brightway-legacy.md` - Working with Brightway legacy versions + - `multifunctional-databases/` - Multi-functionality documentation + - `index.md` - Advanced topics overview + +- **`assets/`** - Images, screenshots, and other assets + +- **`beta.md`** - Beta version information + +## Building Documentation + +### Prerequisites +- Ruby (for Jekyll) +- Bundler gem + +### Local Development + +1. Install dependencies: + ```bash + cd docs + bundle install + ``` + +2. Serve locally: + ```bash + bundle exec jekyll serve + ``` + +3. View at: `http://localhost:4000` + +### Live Documentation + +The documentation is automatically built and deployed to GitHub Pages when changes are pushed to the repository. + +URL: [https://lca-activitybrowser.github.io/activity-browser/](https://lca-activitybrowser.github.io/activity-browser/) + +## Writing Documentation + +### Markdown Files + +Documentation is written in Markdown with Jekyll front matter: + +```markdown +--- +layout: default +title: Page Title +nav_order: 1 +--- + +# Page Title + +Content goes here... +``` + +### Front Matter Options + +- **`layout`** - Page layout template (usually `default`) +- **`title`** - Page title +- **`nav_order`** - Navigation menu order +- **`parent`** - Parent page for nested navigation +- **`has_children`** - Whether page has child pages +- **`permalink`** - Custom URL path + +### Linking Pages + +Use relative links: +```markdown +See [Installation Guide]({% link getting-started/installation.md %}) +``` + +### Including Images + +Place images in `assets/` and reference: +```markdown +![Screenshot](../assets/screenshot.png) +``` + +### Code Blocks + +Use fenced code blocks with language: +```markdown +```python +import bw2data as bd +bd.projects.set_current("my_project") +``` +``` + +## Documentation Structure + +### Getting Started +Target audience: New users +- Installation +- First project +- Basic concepts +- First calculation + +### User Interface +Target audience: All users +- Navigation +- Pages and panes +- Common tasks +- Keyboard shortcuts + +### Advanced Topics +Target audience: Power users +- Scenarios and parameters +- Uncertainty analysis +- Sensitivity analysis +- Multi-functionality +- Integration with Brightway + +## Style Guide + +### Writing Style +- **Clear and concise** - Simple language +- **Task-oriented** - Focus on what users want to do +- **Step-by-step** - Break down complex tasks +- **Visual aids** - Screenshots and diagrams +- **Examples** - Show real examples + +### Formatting +- **Headings** - Use proper hierarchy (H1, H2, H3) +- **Lists** - For steps or multiple items +- **Bold** - For UI elements and important terms +- **Code** - For code, commands, and file paths +- **Notes/Tips** - Use blockquotes for callouts + +### Screenshots +- Use actual application screenshots +- Highlight relevant areas +- Keep up-to-date with current UI +- Crop to show only relevant content +- Use consistent window size + +## Maintenance + +### Keeping Current +- Update screenshots when UI changes +- Verify instructions after code changes +- Add documentation for new features +- Mark deprecated features +- Update version numbers + +### Review Process +- Test instructions on fresh install +- Check all links work +- Verify code examples +- Review for clarity +- Check mobile responsiveness + +## Contributing + +To contribute to documentation: + +1. Fork the repository +2. Create a branch for your changes +3. Edit/add Markdown files in `docs/` +4. Test locally with Jekyll +5. Submit a pull request + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details. + +## Resources + +- [Jekyll Documentation](https://jekyllrb.com/docs/) +- [Just the Docs Theme](https://just-the-docs.github.io/just-the-docs/) +- [Markdown Guide](https://www.markdownguide.org/) +- [GitHub Pages](https://pages.github.com/) diff --git a/recipe/README.md b/recipe/README.md new file mode 100644 index 000000000..3a2bb6547 --- /dev/null +++ b/recipe/README.md @@ -0,0 +1,217 @@ +# recipe + +Conda build recipe for Activity Browser. + +## Overview + +This directory contains the conda-build recipe for packaging and distributing Activity Browser via conda-forge. The recipe defines how to build the conda package from source. + +## Key File + +- **`meta.yaml`** - Conda package metadata and build instructions + +## meta.yaml Structure + +The `meta.yaml` file contains several sections: + +### Package Section +Defines package name and version: +```yaml +package: + name: activity-browser + version: {{ VERSION }} +``` + +### Source Section +Specifies where to get the source code: +```yaml +source: + path: .. # Local path for development + # Or from GitHub release: + # url: https://github.com/LCA-ActivityBrowser/activity-browser/archive/{{ version }}.tar.gz +``` + +### Build Section +Build configuration: +```yaml +build: + number: 0 + noarch: python # Pure Python package + entry_points: + - activity-browser = activity_browser:run_activity_browser +``` + +### Requirements Section +Dependencies for build and runtime: + +```yaml +requirements: + host: + - python >=3.9 + - pip + - setuptools + run: + - python >=3.9 + - brightway2 >=2.4 + - pyside6 >=6.0 + - qtpy >=2.0 + # ... more dependencies +``` + +### Test Section +Basic tests to verify package installation: +```yaml +test: + imports: + - activity_browser + commands: + - activity-browser --help +``` + +### About Section +Package metadata: +```yaml +about: + home: https://github.com/LCA-ActivityBrowser/activity-browser + license: LGPL-3.0 + summary: GUI for Brightway2 LCA framework + description: Activity Browser is a GUI for the Brightway2 LCA framework + doc_url: https://lca-activitybrowser.github.io/activity-browser/ +``` + +## Building Locally + +### Prerequisites +- conda-build installed: `conda install conda-build` +- Conda environment set up + +### Build Command +```bash +conda build recipe/ +``` + +This will: +1. Create a clean build environment +2. Install dependencies +3. Build the package from source +4. Run tests +5. Create a conda package (.tar.bz2) + +### Build Variants +For different Python versions: +```bash +conda build recipe/ --python 3.9 +conda build recipe/ --python 3.10 +conda build recipe/ --python 3.11 +``` + +## conda-forge + +Activity Browser is distributed via conda-forge, the community-led conda package repository. + +### conda-forge Repository +The conda-forge recipe is maintained in a separate repository: +https://github.com/conda-forge/activity-browser-feedstock + +### Update Process +When a new version is released: +1. conda-forge bot detects new GitHub release +2. Opens PR to update version and SHA256 +3. Maintainers review and merge +4. Package is built for all platforms +5. Published to conda-forge channel + +### Maintainers +conda-forge package maintainers can: +- Update the recipe +- Adjust dependencies +- Fix build issues +- Release new versions + +## Installation + +Users install from conda-forge: +```bash +conda install -c conda-forge activity-browser +``` + +Or with mamba (faster): +```bash +mamba install -c conda-forge activity-browser +``` + +## Testing the Package + +After building, test the package: + +```bash +# Install from local build +conda install --use-local activity-browser + +# Test entry point +activity-browser --version + +# Launch application +activity-browser +``` + +## Dependencies + +Keep dependencies in sync: +- `meta.yaml` (conda recipe) +- `pyproject.toml` (pip/setuptools) +- `setup.py` (legacy setup) + +Ensure all three specify the same dependencies and versions. + +## Platform Support + +Activity Browser supports: +- **Linux** - x86_64, aarch64 +- **macOS** - x86_64, arm64 (Apple Silicon) +- **Windows** - x86_64 + +The recipe should specify `noarch: python` if the package is pure Python, or include platform-specific builds if needed. + +## Troubleshooting + +### Build Failures +- Check dependency versions +- Verify source path/URL +- Review build logs +- Test in clean environment + +### Import Errors +- Missing dependencies in run requirements +- Incorrect entry points +- Module import issues + +### Test Failures +- Tests timing out +- Missing test dependencies +- Platform-specific issues + +## Development Workflow + +1. **Local Development** + - Edit source code + - Test locally with `python -m activity_browser` + +2. **Update Recipe** + - Modify `meta.yaml` if dependencies changed + - Update version number + +3. **Build and Test** + - Run `conda build recipe/` + - Install and test locally + +4. **Release** + - Tag release on GitHub + - conda-forge bot updates feedstock + - Package published automatically + +## Resources + +- [conda-build documentation](https://docs.conda.io/projects/conda-build/) +- [conda-forge documentation](https://conda-forge.org/docs/) +- [Activity Browser feedstock](https://github.com/conda-forge/activity-browser-feedstock) diff --git a/setup.py b/setup.py index 63ba3b2b9..407e7b459 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ setup( version=version, - packages=["activity_browser", "activity_browser_beta"], + packages=["activity_browser"], license=open("LICENSE.txt").read(), include_package_data=True, ) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..cb1e925da --- /dev/null +++ b/tests/README.md @@ -0,0 +1,322 @@ +# tests + +Test suite for Activity Browser. + +## Overview + +This directory contains the test suite for Activity Browser using pytest. Tests verify functionality, catch regressions, and ensure code quality across the application. + +## Test Framework + +**pytest** is used as the test runner with extensions: +- **pytest-qt** - Testing Qt applications +- **pytest-cov** - Coverage reporting +- **pytest-mock** - Mocking utilities + +## Directory Structure + +- **`actions/`** - Tests for action classes +- **`fixtures/`** - Test fixtures and mock data +- **`widgets/`** - Tests for UI widgets +- Additional test files for various modules + +## Key Files + +- **`conftest.py`** - Pytest configuration and shared fixtures +- **`test_search.py`** - Search engine tests + +## Running Tests + +### Run All Tests +```bash +pytest +``` + +### Run Specific Test File +```bash +pytest tests/test_search.py +``` + +### Run Specific Test +```bash +pytest tests/test_search.py::test_search_basic +``` + +### Run with Coverage +```bash +pytest --cov=activity_browser --cov-report=html +``` + +### Run in Parallel +```bash +pytest -n auto +``` + +## Test Categories + +### Unit Tests +Test individual functions and classes in isolation: +```python +def test_function(): + result = my_function(input_data) + assert result == expected_output +``` + +### Integration Tests +Test interaction between components: +```python +def test_database_import(): + # Test full import workflow + importer.load_file(test_file) + assert database_exists("test_db") +``` + +### UI Tests +Test Qt widgets and interactions: +```python +def test_button_click(qtbot): + widget = MyWidget() + qtbot.addWidget(widget) + qtbot.mouseClick(widget.button, Qt.LeftButton) + assert widget.clicked is True +``` + +### Action Tests +Test action classes: +```python +def test_delete_action(): + DeleteAction.run(item_key) + assert not item_exists(item_key) +``` + +## Fixtures + +Fixtures provide test data and setup (see `conftest.py` and `fixtures/`): + +### Common Fixtures +```python +@pytest.fixture +def sample_activity(): + """Provide a sample activity for testing.""" + return { + "name": "Test Activity", + "unit": "kg", + "location": "GLO" + } + +def test_with_fixture(sample_activity): + # Use fixture + assert sample_activity["unit"] == "kg" +``` + +### Brightway Fixtures +```python +@pytest.fixture +def bw_project(tmp_path): + """Create temporary Brightway project.""" + bd.projects.set_current("test_project") + yield + bd.projects.delete_project("test_project", delete_dir=True) +``` + +### Qt Fixtures +```python +@pytest.fixture +def qtbot(qtbot): + """Pytest-qt bot for widget testing.""" + return qtbot +``` + +## Writing Tests + +### Test Naming +- Test files: `test_*.py` or `*_test.py` +- Test functions: `test_*` +- Test classes: `Test*` + +### Test Structure +```python +def test_something(): + # Arrange - Set up test data + data = prepare_test_data() + + # Act - Execute the code being tested + result = function_under_test(data) + + # Assert - Verify the result + assert result == expected_value +``` + +### UI Test Example +```python +def test_widget_interaction(qtbot): + # Create widget + widget = MyWidget() + qtbot.addWidget(widget) + + # Simulate user input + qtbot.keyClicks(widget.input_field, "test text") + qtbot.mouseClick(widget.submit_button, Qt.LeftButton) + + # Verify result + assert widget.result_label.text() == "Success" +``` + +### Action Test Example +```python +def test_create_database_action(bw_project): + # Setup + db_name = "test_database" + + # Execute action + CreateDatabaseAction.run(db_name) + + # Verify + assert db_name in bd.databases +``` + +## Mocking + +Use mocks to isolate tests: + +```python +from unittest.mock import Mock, patch + +def test_with_mock(mocker): + # Mock external dependency + mock_api = mocker.patch("module.api_call") + mock_api.return_value = {"status": "success"} + + # Test code + result = my_function() + + # Verify mock was called + mock_api.assert_called_once() + assert result["status"] == "success" +``` + +## Testing Signals + +Test Qt signals and slots: + +```python +def test_signal_emission(qtbot): + widget = MyWidget() + + # Use signal spy + with qtbot.waitSignal(widget.data_changed, timeout=1000): + widget.modify_data() + + # Signal was emitted +``` + +## Testing Threads + +Test background operations: + +```python +def test_threaded_operation(qtbot): + widget = MyWidget() + + # Wait for thread to complete + with qtbot.waitSignal(widget.operation_complete, timeout=5000): + widget.start_operation() + + assert widget.result is not None +``` + +## Test Coverage + +Aim for high coverage: +- **Critical paths** - 100% coverage +- **Business logic** - >90% coverage +- **UI code** - >70% coverage +- **Utilities** - >80% coverage + +View coverage report: +```bash +pytest --cov=activity_browser --cov-report=html +open htmlcov/index.html +``` + +## Continuous Integration + +Tests run automatically on: +- Pull requests +- Commits to main branch +- Scheduled runs + +See `.github/workflows/main.yaml` for CI configuration. + +## Development Guidelines + +When writing tests: + +1. **Test behavior, not implementation** - Test what, not how +2. **One assertion per test** - Or at least one logical check +3. **Descriptive names** - Test names should explain what they test +4. **Independent tests** - Tests should not depend on each other +5. **Fast tests** - Keep tests quick (mock slow operations) +6. **Readable tests** - Tests are documentation +7. **Test edge cases** - Not just happy paths +8. **Use fixtures** - Reuse common setup +9. **Mock external dependencies** - Don't rely on network, files, etc. +10. **Clean up** - Use fixtures or teardown to clean up + +## Debugging Tests + +### Run with output +```bash +pytest -s # Show print statements +pytest -v # Verbose output +pytest -vv # Very verbose +``` + +### Run single test with debugger +```bash +pytest --pdb tests/test_file.py::test_function +``` + +### Show test durations +```bash +pytest --durations=10 # Slowest 10 tests +``` + +## Test Organization + +Group related tests: + +```python +class TestDatabaseOperations: + def test_create_database(self): + pass + + def test_delete_database(self): + pass + + def test_copy_database(self): + pass +``` + +Use parametrize for similar tests: + +```python +@pytest.mark.parametrize("input,expected", [ + (1, 2), + (2, 4), + (3, 6), +]) +def test_double(input, expected): + assert double(input) == expected +``` + +## Best Practices + +- **Test first** - Write tests before or alongside code +- **Small tests** - Each test should verify one thing +- **Clear assertions** - Make expected values obvious +- **No logic in tests** - Tests should be straightforward +- **Fail fast** - Catch issues early in the test +- **Document complex tests** - Add comments for clarity +- **Keep tests updated** - Refactor tests with code +- **Review test failures** - Don't ignore failing tests diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index fb9df0f17..33c57dcf6 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -48,7 +48,7 @@ def test_activity_duplicate(basic_database): # # def test_activity_new(monkeypatch, basic_database): - from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog + from activity_browser.app.actions.activity.activity_new_process import NewNodeDialog monkeypatch.setattr( NewNodeDialog, "exec_", staticmethod(lambda *args, **kwargs: True) diff --git a/tests/conftest.py b/tests/conftest.py index ee4497673..7939d4477 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,20 @@ from copy import deepcopy +from importlib import reload +from loguru import logger +import pandas as pd import pytest +import os import bw2data as bd +from PySide6 import QtCore + import bw_functional as bf from bw2data.tests import bw2test +os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" +os.environ["AB_NO_SEARCHER"] = "1" + @pytest.fixture def no_exception_dialogs(monkeypatch): @@ -17,38 +26,42 @@ def no_exception_dialogs(monkeypatch): # No need to undo the monkeypatch, pytest does it automatically -@pytest.fixture() +@pytest.fixture def main_window(qtbot, monkeypatch, no_exception_dialogs): """Return the main window of the application instance.""" from activity_browser import app - from activity_browser.app import pages - from activity_browser.ui.widgets import CentralTabWidget - - app.MainWindow._instance = None # Reset singleton instance for testing - main_window = app.MainWindow() - central_widget = CentralTabWidget(main_window) - - qtbot.addWidget(main_window) - setattr(app.application, "main_window", main_window) - setattr(app, "main_window", main_window) + from activity_browser.bwutils.metadata import metadata - # central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") + # Reload modules to ensure a clean state for each test + reload(metadata) + reload(app.main) + reload(app) + metadata.dataframe = pd.DataFrame() - main_window.setCentralWidget(central_widget) - main_window.show() + app.main_window.show() yield main_window - # main_window.close() - main_window.deleteLater() + app.main_window.deleteLater() + qtbot.wait(10) @pytest.fixture @bw2test -def basic_database(main_window): +def basic_database(qapp, main_window): + import time + from activity_browser.app import metadata from fixtures.basic import DATABASE, METHOD, CALCULATION_SETUP + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + + i = 0 + while metadata.loader.secondary_status != "done" and i < 60: + logger.warning("Waiting for project load to finish") + time.sleep(1) + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + i += 1 + db = bf.FunctionalSQLiteDatabase("basic") db.write(deepcopy(DATABASE), process=False) db.metadata["dirty"] = True @@ -61,5 +74,15 @@ def basic_database(main_window): bd.calculation_setups["basic_calculation_setup"] = CALCULATION_SETUP bd.calculation_setups.flush() - return db + i = 0 + while metadata.loader.secondary_status != "done" and i < 60: + logger.warning("Waiting for database load to finish...") + time.sleep(1) + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + i += 1 + + if i >= 60: + raise TimeoutError("Metadata loader did not finish in time.") + + yield db diff --git a/tests/test_mds_cross_database.py b/tests/test_mds_cross_database.py new file mode 100644 index 000000000..b183759cd --- /dev/null +++ b/tests/test_mds_cross_database.py @@ -0,0 +1,119 @@ +"""Tests for MDSSearcher cross-database functionality.""" +import pytest +import pandas as pd +from activity_browser.bwutils.metadata.searcher import MDSSearcher +from activity_browser.bwutils.metadata.metadata import MetaDataStore + + +@pytest.fixture +def multi_db_mds(): + """Create a MetaDataStore with multiple databases.""" + test_data = pd.DataFrame([ + [1, "db1", "coal production", "coal", "process", "", "", "", ""], + [2, "db1", "coal mining", "coal", "process", "", "", "", ""], + [3, "db1", "steel production", "steel", "process", "", "", "", ""], + [4, "db2", "coal transport", "transport", "process", "", "", "", ""], + [5, "db2", "electricity from coal", "electricity", "process", "", "", "", ""], + [6, "db3", "coal combustion", "heat", "process", "", "", "", ""], + [7, "db3", "gas production", "gas", "process", "", "", "", ""], + ], columns=["id", "database", "name", "reference product", "type", "location", "unit", "comment", "tags"]) + + mds = MetaDataStore() + mds.dataframe = test_data + return mds + + +def test_search_single_database(multi_db_mds): + """Test searching within a single database.""" + searcher = MDSSearcher(multi_db_mds) + + # Search for "coal" in db1 + results = searcher.search("coal", database="db1") + assert len(results) == 2 + assert set(results) == {1, 2} + + # Search for "coal" in db2 + results = searcher.search("coal", database="db2") + assert len(results) == 2 + assert set(results) == {4, 5} + + # Search for "coal" in db3 + results = searcher.search("coal", database="db3") + assert len(results) == 1 + assert set(results) == {6} + + +def test_search_all_databases(multi_db_mds): + """Test searching across all databases when database=None.""" + searcher = MDSSearcher(multi_db_mds) + + # Search for "coal" across all databases + results = searcher.search("coal", database=None) + assert len(results) == 5 + assert set(results) == {1, 2, 4, 5, 6} + + # Search for "production" across all databases + results = searcher.search("production", database=None) + assert len(results) == 3 + assert set(results) == {1, 3, 7} + + +def test_fuzzy_search_all_databases(multi_db_mds): + """Test fuzzy search across all databases.""" + searcher = MDSSearcher(multi_db_mds) + + # Fuzzy search for "coal" across all databases + results = searcher.fuzzy_search("coal", database=None) + assert len(results) >= 5 + + # Fuzzy search for "production" across all databases + results = searcher.fuzzy_search("production", database=None) + assert len(results) >= 3 + + +def test_search_cache_separation(multi_db_mds): + """Test that search cache properly separates single-db and all-db searches.""" + searcher = MDSSearcher(multi_db_mds) + + # Do searches to populate cache + results_db1 = searcher.search("coal", database="db1") + results_all = searcher.search("coal", database=None) + + # Verify results are different + assert len(results_db1) == 2 + assert len(results_all) == 5 + assert set(results_db1).issubset(set(results_all)) + + # Search again to use cached results + results_3ached = searcher.search("coal", database="db1") + results_all_cached = searcher.search("coal", database=None) + + # Verify cached results match original + assert results_db1 == results_3ached + assert results_all == results_all_cached + + +def test_auto_complete_all_databases(multi_db_mds): + """Test autocomplete across all databases.""" + searcher = MDSSearcher(multi_db_mds) + + # Autocomplete for "coa" across all databases + completions = searcher.auto_complete("coa", database=None) + assert "coal" in completions + + # Autocomplete for "prod" in specific database + completions_db1 = searcher.auto_complete("prod", database="db1") + assert "production" in completions_db1 + + # Autocomplete for "prod" across all databases + completions_all = searcher.auto_complete("prod", database=None) + assert "production" in completions_all + + +def test_empty_search_all_databases(multi_db_mds): + """Test empty search returns all items when database=None.""" + searcher = MDSSearcher(multi_db_mds) + + results = searcher.search("", database=None) + assert len(results) == 7 # All items in all databases + diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 000000000..58e344037 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,239 @@ +import pytest +import pandas as pd +from activity_browser.bwutils.searchengine import SearchEngine + + +def data_for_test(): + return pd.DataFrame([ + ["a", "coal production", "coal"], + ["b", "coal production", "something"], + ["c", "coal production", "coat"], + ["d", "coal hello production", "something"], + ["e", "dont zzfind me", "hello world"], + ["f", "coat", "zzanother word"], + ["g", "coalispartofthisword", "things"], + ["h", "coal", "coal"], + ], + columns = ["id", "col1", "col2"]) + + +# test standard init +def test_search_init(): + """Do initialization tests.""" + df = data_for_test() + + # init search class with non-existent identifier col and fail + with pytest.raises(Exception): + _ = SearchEngine(df, identifier_name="non_existent_col_name") + # init search class with non-unique identifiers and fail + df2 = df.copy() + df2.iloc[0, 0] = "b" + with pytest.raises(Exception): + _ = SearchEngine(df2, identifier_name="id") + # init search class correctly + se = SearchEngine(df, identifier_name="id") + + +# test internals +def test_reverse_dict(): + """Do test to reverse the special Counter dict.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # reverse once and verify + w2i = se.reverse_dict_many_to_one(se.identifier_to_word) + assert w2i == se.word_to_identifier + + # reverse again and verify is same as original + i2w = se.reverse_dict_many_to_one(w2i) + assert i2w == se.identifier_to_word + + +def test_string_distance(): + """Do tests specifically for string distance function.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # same word + assert se.osa_distance("coal", "coal") == 0 + # empty string is length of other word + assert se.osa_distance("coal", "") == 4 + + # insert + assert se.osa_distance("coal", "coa") == 1 + # delete + assert se.osa_distance("coal", "coall") == 1 + # substitute + assert se.osa_distance("coal", "coat") == 1 + # transpose + assert se.osa_distance("coal", "cola") == 1 + + # longer edit distance + assert se.osa_distance("coal", "chocolate") == 6 + # reverse order gives same result + assert se.osa_distance("coal", "chocolate") == se.osa_distance("chocolate", "coal") + # cutoff + assert se.osa_distance("coal", "chocolate", cutoff=5, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=6, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=7, cutoff_return=1000) == 6 + # length cutoff + assert se.osa_distance("coal", "coallongword") == 8 + assert se.osa_distance("coal", "coallongword", cutoff=5, cutoff_return=1000) == 1000 + + # two entirely different words (test of early stopping) + assert se.osa_distance("brown", "jumped") == 6 + assert se.osa_distance("brown", "jumped", cutoff=6, cutoff_return=1000) == 1000 + assert se.osa_distance("brown", "jumped", cutoff=7, cutoff_return=1000) == 6 + + +# test functionality +def test_in_index(): + """Do checks for checking if word is in the index.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # use string with space + with pytest.raises(Exception): + se.word_in_index("coal and space") + + assert se.word_in_index("coal") + assert not se.word_in_index("coa") + + +def test_spellcheck(): + """Do checks spell checking.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + checked = se.spell_check("coa productions something flintstones") + # coal HAS to be first, it is found more often in the data + assert checked["coa"] == ["coal", "coat"] + # find production + assert checked["productions"] == ["production"] + # should be empty as there is no alternative (but this word occurs) + assert checked["something"] == [] + # should be empty as there is no alternative (does not exist) + assert checked["flintstones"] == [] + + +def test_search_base(): + """Do checks for correct search ranking.""" + + df = data_for_test() + + # init search class and two searches + se = SearchEngine(df, identifier_name="id") + # do search on specific term + assert se.search("coal") == ["a", "h", "c", "b", "d", "g", "f"] + # do search on other term + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + # do search on typo + assert se.search("cola") == ["a", "c", "h", "b", "d", "f", "g"] + # do search on longer typo + assert se.search("cola production") == ["c", "a", "b", "d", "h", "f", "g"] + # do search on something we will definitely not find + assert se.search("dontFindThis") == [] + + # init search class with 1 col searchable + se = SearchEngine(df, identifier_name="id", searchable_columns=["col2"]) + assert se.search("coal") == ["a", "h", "c"] + + +def test_search_add_identifier(): + """Do tests for adding identifier.""" + df = data_for_test() + + # create base item to add + new_base_item = pd.DataFrame([ + ["i", "coal production", "coal production"], + ], + columns=["id", "col1", "col2"]) + + # use existing identifier and fail + se = SearchEngine(df, identifier_name="id") + wrong_id = new_base_item.copy() + wrong_id.iloc[0, 0] = "a" + with pytest.raises(Exception): + se.add_identifier(wrong_id) + + # add data without identifier column + se = SearchEngine(df, identifier_name="id") + no_id = new_base_item.copy() + del no_id["id"] + with pytest.raises(Exception): + se.add_identifier(no_id) + + # use column more (and find data in new col) + se = SearchEngine(df, identifier_name="id") + col_more = new_base_item.copy() + col_more["col3"] = ["potatoes"] + se.add_identifier(col_more) + assert se.search("potatoes") == ["i"] + + # use column less (should be filled with empty string) + se = SearchEngine(df, identifier_name="id") + col_less = new_base_item.copy() + del col_less["col2"] + se.add_identifier(col_less) + assert se.df.loc["i", "col2"] == "" + + # do search, add item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.add_identifier(new_base_item) + assert se.search("coal production") == ["i", "a", "c", "b", "d", "h", "f", "g"] + + +def test_search_remove_identifier(caplog): + """Do tests for removing identifier.""" + caplog.set_level("WARNING") + df = data_for_test() + + # do search, remove item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.remove_identifier(identifier="a") + assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] + + # now search on something only in a column we later remove + assert se.search("find") == ["e"] + se.remove_identifier(identifier="e") + assert se.search("find") == [] + + +def test_search_change_identifier(): + """Do tests for changing identifier.""" + df = data_for_test() + + # create base item to add + edit_data = pd.DataFrame([ + ["a", "cant find me anymore", "something different"], + ], + columns=["id", "col1", "col2"]) + + # use non-existent identifier and fail + se = SearchEngine(df, identifier_name="id") + missing_id = edit_data.copy() + missing_id["id"] = ["i"] + with pytest.raises(Exception): + se.change_identifier(identifier="i", data=missing_id) + + # use mismatched identifier and fail + se = SearchEngine(df, identifier_name="id") + wrong_id = edit_data.copy() + wrong_id["id"] = ["i"] + with pytest.raises(Exception): + se.change_identifier(identifier="a", data=wrong_id) + + # do search, change item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.change_identifier(identifier="a", data=edit_data) + assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] + # now change the same item partially and verify results are different + new_edit_data = pd.DataFrame([ + ["a", "coal"], + ], + columns=["id", "col1"]) + se.change_identifier(identifier="a", data=new_edit_data) + assert se.search("coal production") == ["c", "b", "d", "h", "a", "f", "g"]