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/build-executable.yml b/.github/workflows/build-executable.yml new file mode 100644 index 000000000..688a9011a --- /dev/null +++ b/.github/workflows/build-executable.yml @@ -0,0 +1,45 @@ +name: Build Executable +on: + push: + branches: [ major ] + tags: '*' + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest, macos-15] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install UV + uses: astral-sh/setup-uv@v2 + + - name: Sync dependencies + shell: bash + run: | + uv add pyinstaller + uv sync --prerelease=allow + + - name: Build executable with PyInstaller + shell: bash + run: | + uv run pyinstaller pyinstaller.spec + + - uses: actions/upload-artifact@v4 + with: + name: activity-browser-${{ matrix.os }} + path: dist/* \ No newline at end of file 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..6339d5cd5 --- /dev/null +++ b/activity_browser/README.md @@ -0,0 +1,35 @@ +# 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`. + +## Development Notes + +- See `CONTRIBUTING.md` for guidelines on contributing to the project +- Check out the Development notes specific to each submodule for more details on implementation diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 1e67a74f5..e04ee0533 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,8 +14,26 @@ except ImportError: import qtpy -from .ui.application import application -from .signals import signals +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("SIGNAL", no=19, 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 418718791..0e7073f61 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -1,6 +1,11 @@ +# Divert the program flow in worker sub-process as soon as possible, +# before importing heavy-weight modules. +if __name__ == '__main__': + import multiprocessing + multiprocessing.freeze_support() + import sys import os -from logging import getLogger from importlib import metadata import requests @@ -13,13 +18,11 @@ import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("activity.browser.1") -from activity_browser import application -from activity_browser.ui import icons - -from .logger import setup_ab_logging +from loguru import logger +import platformdirs from .static.icons import main -log = getLogger(__name__) + class SpecialProgressBar(QtWidgets.QWidget): @@ -88,29 +91,14 @@ def load_modules(self): thread.start() def load_layout(self): - from .ui.widgets import MainWindow, CentralTabWidget - from .layouts import panes, pages - from activity_browser.bwutils import AB_metadata - from activity_browser import signals + self.load_finished() - application.main_window = MainWindow() - central_widget = CentralTabWidget(application.main_window) - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") - - application.main_window.setCentralWidget(central_widget) + def load_finished(self): + from activity_browser import app - self.load_settings() + load_plugins() - def load_settings(self): - self.text_label.setText("Loading project") - thread = SettingsThread(self) - thread.finished.connect(self.load_finished) - thread.start() - - def load_finished(self): - application.main_window.sync() - application.main_window.show() + app.main_window.show() self.deleteLater() @@ -119,70 +107,36 @@ class ModuleThread(QtCore.QThread): def run(self): self.status.emit("Loading Numpy") - log.debug("ABLoader: Importing numpy") + logger.debug("ABLoader: Importing numpy") import numpy, pandas self.status.emit("Loading Brightway25") - log.debug("ABLoader: Importing brightway modules") + logger.debug("ABLoader: Importing brightway modules") import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils - self.status.emit("Loading Activity Browser") - log.debug("ABLoader: Importing activity_browser") - from activity_browser import actions, layouts, mod, settings, ui, signals - from activity_browser.layouts import panes, pages - from activity_browser.ui import core, widgets, web, wizards - - -class SettingsThread(QtCore.QThread): - def run(self): - import bw2data as bd - from activity_browser import settings, actions - - if settings.ab_settings.settings: - from pathlib import Path - - base_dir = Path(settings.ab_settings.current_bw_dir) - project_name = settings.ab_settings.startup_project - bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) - - if not bd.projects.twofive: - log.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") - actions.ProjectSwitch.set_warning_bar() - - log.info(f"Brightway2 data directory: {bd.projects._base_data_dir}") - log.info(f"Brightway2 current project: {bd.projects.current}") def run_activity_browser(): + from activity_browser.ui.core.application import ABApplication + app = ABApplication() + pre_flight_checks() - setup_ab_logging() loader = ABLoader() loader.show() - application.set_icon() # setting this here seems to fix the icon not showing sometimes - sys.exit(application.exec_()) + + app.set_icon() # setting this here seems to fix the icon not showing sometimes + sys.exit(app.exec_()) def run_activity_browser_no_launcher(): pre_flight_checks() - setup_ab_logging() modules = ModuleThread() modules.run() - from .ui.widgets import MainWindow, CentralTabWidget - from .layouts import panes, pages - from activity_browser.bwutils import AB_metadata - from activity_browser import signals - - application.main_window = MainWindow() - central_widget = CentralTabWidget(application.main_window) - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") + from .ui.widgets import CentralTabWidget + from .app import panes, pages, application, metadata - application.main_window.setCentralWidget(central_widget) + load_plugins() - settings = SettingsThread() - settings.run() - - application.main_window.sync() application.main_window.show() application.set_icon() # setting this here seems to fix the icon not showing sometimes @@ -248,11 +202,22 @@ def check_pypi_update(): "pip install --upgrade activity-browser\n\n" "Press any key to continue without updating...\033[0m") +def load_plugins(): + from activity_browser.bwutils.settings import Settings + settings = Settings() + plugins = settings["plugins"].get("enabled_plugins", []) + for plugin in plugins: + try: + __import__(plugin) + logger.info(f"Successfully loaded plugin: {plugin}") + except ImportError: + logger.warning(f"Could not load plugin: {plugin}") + if "--no-launcher" in sys.argv: run_activity_browser_no_launcher() elif sys.version_info[1] == 10: - log.info("Running Activity Browser without launcher for Python 3.10") + logger.info("Running Activity Browser without launcher for Python 3.10") run_activity_browser_no_launcher() else: run_activity_browser() diff --git a/activity_browser/actions/activity/activity_duplicate_to_loc.py b/activity_browser/actions/activity/activity_duplicate_to_loc.py deleted file mode 100644 index 26209d973..000000000 --- a/activity_browser/actions/activity/activity_duplicate_to_loc.py +++ /dev/null @@ -1,276 +0,0 @@ -import pandas as pd -from qtpy import QtWidgets - -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import AB_metadata, commontasks -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class ActivityDuplicateToLoc(ABAction): - """ - ABAction to duplicate an activity and possibly their exchanges to a new location. - """ - - icon = qicons.copy - text = "Duplicate node to new location" - - @classmethod - @exception_dialogs - def run(cls, activity_key: tuple): - activity = bd.get_activity(activity_key) - db_name = activity["database"] - - # get list of dependent databases for activity and load to MetaDataStore - databases = [] - for exchange in activity.technosphere(): - databases.append(exchange.input[0]) - if db_name not in databases: # add own database if it wasn't added already - databases.append(db_name) - - # load all dependent databases to MetaDataStore - dbs = {db: AB_metadata.get_database_metadata(db) for db in databases} - # get list of all unique locations in the dependent databases (sorted alphabetically) - locations = [] - for db in dbs.values(): - locations += db["location"].to_list() # add all locations to one list - locations = list(set(locations)) # reduce the list to only unique items - locations.sort() - - # get the location to relink - db = dbs[db_name] - old_location = db.loc[db["key"] == activity.key]["location"].iloc[0] - - # trigger dialog with autocomplete-writeable-dropdown-list - options = (old_location, locations) - dialog = LocationLinkingDialog.relink_location( - activity["name"], options, application.main_window - ) - - if dialog.exec_() != LocationLinkingDialog.Accepted: - return - - # read the data from the dialog - for old, new in dialog.relink.items(): - alternatives = [] - new_location = new - if dialog.use_rer.isChecked(): # RER - alternatives.append(dialog.use_rer.text()) - if dialog.use_ews.isChecked(): # Europe without Switzerland - alternatives.append(dialog.use_ews.text()) - if dialog.use_row.isChecked(): # RoW - alternatives.append(dialog.use_row.text()) - # the order we add alternatives is important, they are checked in this order! - if len(alternatives) > 0: - use_alternatives = True - else: - use_alternatives = False - - # successful_links = {} # dict of dicts, key of new exch : {new values} <-- see 'values' below - # in the future, 'alternatives' could be improved by making use of some location hierarchy. From that we could - # get things like if the new location is NL but there is no NL, but RER exists, we use that. However, for that - # we need some hierarchical structure to the location data, which may be available from ecoinvent, but we need - # to look for that. - - # get exchanges that we want to relink - # for exch in activity.technosphere(): - # candidate = self.find_candidate(dbs, exch, old_location, new_location, use_alternatives, alternatives) - # if candidate is None: - # continue # no suitable candidate was found, try the next exchange - # - # # at this point, we have found 1 suitable candidate, whether that is new_location or alternative location - # values = { - # 'amount': exch.get('amount', False), - # 'comment': exch.get('comment', False), - # 'formula': exch.get('formula', False), - # 'uncertainty': exch.get('uncertainty', False) - # } - # successful_links[candidate['key'].iloc[0]] = values - - # now, create a new activity by copying the old one - new_code = commontasks.generate_copy_code(activity.key) - new_act = activity.copy(new_code) - - # update production exchanges - # TODO: check if this is even necessary (I think BW takes care of this) - for exc in new_act.production(): - if exc.input.key == activity.key: - exc.input = new_act - exc.save() - - # update 'products' - for product in new_act.get("products", []): - if product.get("input") == activity.key: - product.input = new_act.key - - # save the new location to the activity - new_act["location"] = new_location - - new_act.save() - - # get exchanges that we want to delete - # del_exch = [] # delete these exchanges - for exch in new_act.technosphere(): - candidate = cls.find_candidate( - db_name, - dbs, - exch, - old_location, - new_location, - use_alternatives, - alternatives, - ) - if candidate is None: - continue # no suitable candidate was found, try the next exchange - exch.input = candidate["key"][0] - exch.save() - # del_exch.append(exch) - # delete exchanges with old locations - # exchange_controller.delete_exchanges(del_exch) - - # add the new exchanges with all values carried over from last exchange - # exchange_controller.add_exchanges(list(successful_links.keys()), new_act.key, successful_links) - - # update the MetaDataStore and open new activity - AB_metadata.update_metadata(new_act.key) - signals.safe_open_activity_tab.emit(new_act.key) - - @staticmethod - def find_candidate( - db_name, dbs, exch, old_location, new_location, use_alternatives, alternatives - ): - """Find a candidate to replace the exchange with.""" - current_db = exch.input[0] - if current_db == db_name: - db = dbs[current_db] - else: # if the exchange is not from the current database, also check the current - # (user may have added their own alternative dependents already) - db = pd.concat([dbs[current_db], dbs[db_name]]) - - if db.loc[db["key"] == exch.input]["location"].iloc[0] != old_location: - return # this exchange has a location we're not trying to re-link - - # get relevant data to match on - row = db.loc[db["key"] == exch.input] - name = row["name"].iloc[0] - prod = row["reference product"].iloc[0] - unit = row["unit"].iloc[0] - - # get candidates to match (must have same name, product and unit) - candidates = db.loc[ - (db["name"] == name) - & (db["reference product"] == prod) - & (db["unit"] == unit) - ] - if len(candidates) <= 1: - return # this activity does not exist in this database with another location (1 is self) - - # check candidates for new_location - candidate = candidates.loc[candidates["location"] == new_location] - if len(candidate) == 0 and not use_alternatives: - return # there is no candidate - elif len(candidate) > 1: - return # there is more than one candidate, we can't know what to use - elif len(candidate) == 0: - # there are no candidates, but we can try alternatives - for alt in alternatives: - candidate = candidates.loc[candidates["location"] == alt] - if len(candidate) == 1: - break # found an alternative in with this alternative location, stop looking - if len(candidate) != 1: - return # there are either no or multiple matches with alternative locations - return candidate - - -class LocationLinkingDialog(QtWidgets.QDialog): - """Display all of the possible location links in a single dialog for the user. - - Allow users to select alternate location links and an option to link to generic alternatives (GLO, RoW). - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Activity Location linking") - - self.loc_label = QtWidgets.QLabel() - self.label_choices = [] - self.grid_box = QtWidgets.QGroupBox("Location link:") - self.grid = QtWidgets.QGridLayout() - self.grid_box.setLayout(self.grid) - - self.use_alternatives_label = QtWidgets.QLabel( - "Use generic alternatives as fallback:" - ) - self.use_alternatives_label.setToolTip( - "If the chosen location is not found, try matching the selected " - "locations below too" - ) - self.use_row = QtWidgets.QCheckBox("RoW") - self.use_row.setChecked(True) - self.use_rer = QtWidgets.QCheckBox("RER") - self.use_ews = QtWidgets.QCheckBox("Europe without Switzerland") - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.loc_label) - layout.addWidget(self.grid_box) - layout.addWidget(self.use_alternatives_label) - layout.addWidget(self.use_row) - layout.addWidget(self.use_rer) - layout.addWidget(self.use_ews) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def relink(self) -> dict: - """Returns a dictionary of str -> str key/values, showing which keys - should be linked to which values. - - Only returns key/value pairs if they differ. - """ - return { - label.text(): combo.currentText() - for label, combo in self.label_choices - if label.text() != combo.currentText() - } - - @classmethod - def construct_dialog( - cls, - label: str, - options: list, - parent: QtWidgets.QWidget = None, - ) -> "LocationLinkingDialog": - loc, locs = options - - obj = cls(parent) - obj.loc_label.setText(label) - - label = QtWidgets.QLabel(loc) - combo = QtWidgets.QComboBox() - combo.addItems(locs) - combo.setCurrentText(loc) - obj.label_choices.append((label, combo)) - # Start at 1 because row 0 is taken up by the loc_label - obj.grid.addWidget(label, 0, 0, 1, 2) - obj.grid.addWidget(combo, 0, 2, 1, 2) - - obj.updateGeometry() - return obj - - @classmethod - def relink_location( - cls, act_name: str, options: list, parent=None - ) -> "LocationLinkingDialog": - label = "Relinking exchanges from activity '{}' to a new location.".format( - act_name - ) - return cls.construct_dialog(label, options, parent) - - diff --git a/activity_browser/actions/activity/activity_graph.py b/activity_browser/actions/activity/activity_graph.py deleted file mode 100644 index 9c852b211..000000000 --- a/activity_browser/actions/activity/activity_graph.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import List - -from activity_browser import signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ActivityGraph(ABAction): - """ - ABAction to open one or multiple activities in the graph explorer - """ - - icon = qicons.graph_explorer - text = "Open in Graph Explorer" - - @staticmethod - @exception_dialogs - def run(activity_keys: List[tuple]): - for key in activity_keys: - signals.open_activity_graph_tab.emit(key) diff --git a/activity_browser/actions/activity/activity_new_process.py b/activity_browser/actions/activity/activity_new_process.py deleted file mode 100644 index 425ccbb56..000000000 --- a/activity_browser/actions/activity/activity_new_process.py +++ /dev/null @@ -1,73 +0,0 @@ -from uuid import uuid4 - -from qtpy.QtWidgets import QDialog -import bw2data as bd - -from activity_browser import application, bwutils -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.widgets.new_node_dialog import NewNodeDialog - -from .activity_open import ActivityOpen - - -class ActivityNewProcess(ABAction): - """ - ABAction to create a new activity. Prompts the user to supply a name. Returns if no name is supplied or if the user - cancels. Otherwise, instructs the ActivityController to create a new activity. - """ - - icon = qicons.add - text = "New process" - - @staticmethod - @exception_dialogs - def run(database_name: str): - # ask the user to provide a name for the new activity - dialog = NewNodeDialog(application.main_window) - # if the user cancels, return - if dialog.exec_() != QDialog.Accepted: - return - name, ref_product, unit, location = dialog.get_new_process_data() - # if no name is provided, return - if not name: - return - if ref_product == "": - ref_product = name - - database = bd.Database(database_name) - legacy_backend = bwutils.database_is_legacy(database_name) - - # create process - new_proc_data = { - "name": name, - "location": location, - "type": "process" if not legacy_backend else "processwithreferenceproduct", - } - - if legacy_backend: - new_proc_data["reference product"] = ref_product - new_proc_data["unit"] = unit - - new_process: bd.Node = database.new_activity(code=uuid4().hex, **new_proc_data) - new_process.save() - - if legacy_backend: - new_process.new_exchange( - input=new_process.key, - type="production", - amount=1.0, - ).save() - - if not legacy_backend: - # create reference product - new_ref_prod_data = { - "product": ref_product, - "unit": unit, - "location": location, - "type": "product", - } - prod = new_process.new_product(code=uuid4().hex, **new_ref_prod_data) - prod.save() - - ActivityOpen.run([new_process.key]) diff --git a/activity_browser/actions/activity/activity_redo_allocation.py b/activity_browser/actions/activity/activity_redo_allocation.py deleted file mode 100644 index 621d4d19d..000000000 --- a/activity_browser/actions/activity/activity_redo_allocation.py +++ /dev/null @@ -1,35 +0,0 @@ -from qtpy import QtGui -from logging import getLogger - -from activity_browser import signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - -log = getLogger(__name__) - -class MultifunctionalProcessRedoAllocation(ABAction): - """ - ABAction to redo the allocation calculation for a specific process. - """ - - icon = QtGui.QIcon() - text = "Redo allocation for multifunctional process" - tool_tip = "Redo the allocation calculations for this process" - - @staticmethod - @exception_dialogs - def run(node: bd.Node): - if not getattr(node, "multifunctional", None): - return - try: - node.allocate() - - signals.new_statusbar_message.emit(f"Allocation values for process {node} updated.") - except KeyError as exc: - signals.new_statusbar_message.emit("A property for the allocation calculation was not found!") - log.error(f"A property for the allocation calculation was not found: {node}") - raise exc - except ZeroDivisionError as exc: - signals.new_statusbar_message.emit(str(exc)) - log.error(f"Zero division in allocation calculation: {exc}") - raise exc diff --git a/activity_browser/actions/calculation_setup/cs_open.py b/activity_browser/actions/calculation_setup/cs_open.py deleted file mode 100644 index 131cc3347..000000000 --- a/activity_browser/actions/calculation_setup/cs_open.py +++ /dev/null @@ -1,32 +0,0 @@ -from logging import getLogger - -from qtpy import QtWidgets - -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - -log = getLogger(__name__) - - -class CSOpen(ABAction): - text = "Open" - - @staticmethod - @exception_dialogs - def run(cs_names: str | list[str]): - from activity_browser.layouts import pages - - if isinstance(cs_names, str): - cs_names = [cs_names] - - for cs_name in cs_names: - if cs_name not in bd.calculation_setups: - log.warning(f"Calculation setup {cs_name} not found") - continue - - page = pages.CalculationSetupPage(cs_name) - central = application.main_window.centralWidget() - - central.addToGroup("LCA Setup", page) diff --git a/activity_browser/actions/database/database_open.py b/activity_browser/actions/database/database_open.py deleted file mode 100644 index 14b1b4710..000000000 --- a/activity_browser/actions/database/database_open.py +++ /dev/null @@ -1,66 +0,0 @@ -from logging import getLogger - -from qtpy.QtCore import Qt, QEventLoop - -from activity_browser import application -from activity_browser.ui import widgets -from activity_browser.actions.base import ABAction, exception_dialogs - -log = getLogger(__name__) - - -class DatabaseOpen(ABAction): - text = "Open Database" - - @staticmethod - @exception_dialogs - def run(database_names: list[str]): - from activity_browser.layouts import panes - - sibling = DatabaseOpen.find_sibling() - - for db_name in database_names: - db_pane = panes.DatabaseProductsPane(application.main_window, db_name) - dock_widget = db_pane.getDockWidget(application.main_window) - dock_widget.resize(dock_widget.width(), application.main_window.height() // 2) - - application.main_window.addDockWidget(DatabaseOpen.get_area(), dock_widget) - - if sibling: - application.main_window.tabifyDockWidget(sibling, dock_widget) - - application.thread().eventDispatcher().processEvents(QEventLoop.ProcessEventsFlags.AllEvents) - dock_widget.raise_() - dock_widget.show() - else: - dock_widget.show() - application.main_window.resizeDocks( - [dock_widget], - [1000], - Qt.Vertical - ) - - @staticmethod - def find_sibling(): - """ - Find the dockwidget location where the database pane should be opened. - """ - from activity_browser.layouts import panes - - all_dws = application.main_window.findChildren(widgets.ABDockWidget) - databases_dw = application.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") - - products_dws = [w for w in all_dws if - isinstance(w.widget(), panes.DatabaseProductsPane) and - application.main_window.dockWidgetArea(w) == application.main_window.dockWidgetArea(databases_dw) and - not w.visibleRegion().isNull() - ] - return products_dws[0] if products_dws else None - - @staticmethod - def get_area(): - """ - Find the dockwidget location where the database pane should be opened. - """ - databases_dw = application.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") - return application.main_window.dockWidgetArea(databases_dw) diff --git a/activity_browser/actions/database/database_redo_allocation.py b/activity_browser/actions/database/database_redo_allocation.py deleted file mode 100644 index 78c9adbc1..000000000 --- a/activity_browser/actions/database/database_redo_allocation.py +++ /dev/null @@ -1,37 +0,0 @@ -from logging import getLogger - -from qtpy import QtGui - -from activity_browser import signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - -log = getLogger(__name__) - - -class DatabaseRedoAllocation(ABAction): - """ - ABAction to redo the allocation calculation. - """ - - icon = QtGui.QIcon() - text = "Redo allocation for database" - tool_tip = "Redo the allocation calculations for this database" - - @staticmethod - @exception_dialogs - def run(db_name: str): - if bd.databases[db_name].get("backend") == "multifunctional": - try: - db = bd.Database(db_name) - - for node in filter(lambda x: x.multifunctional, db): - node.allocate() - - signals.new_statusbar_message.emit(f"Allocation values for database {db_name} updated.") - except KeyError as exc: - signals.new_statusbar_message.emit("A property for the allocation calculation was not found!") - log.error(f"A property for the allocation calculation was not found: {exc}") - except ZeroDivisionError as exc: - signals.new_statusbar_message.emit(str(exc)) - log.error(f"Zero division in allocation calculation: {exc}") diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py deleted file mode 100644 index f938fb083..000000000 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Any, List - -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards import UncertaintyWizard - - -class ExchangeUncertaintyModify(ABAction): - """ - ABAction to open the UncertaintyWizard for an exchange - """ - - icon = qicons.edit - text = "Modify uncertainty" - - @staticmethod - @exception_dialogs - def run(exchanges: List[Any]): - UncertaintyWizard(exchanges[0], application.main_window).show() diff --git a/activity_browser/actions/metadatastore_open.py b/activity_browser/actions/metadatastore_open.py deleted file mode 100644 index ad59cc35a..000000000 --- a/activity_browser/actions/metadatastore_open.py +++ /dev/null @@ -1,23 +0,0 @@ -from logging import getLogger - -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.application import global_shortcut - -log = getLogger(__name__) - - -class MetaDataStoreOpen(ABAction): - - icon = qicons.right - text = "Open activity / activities" - - @staticmethod - @global_shortcut("Ctrl+Shift+M") - @exception_dialogs - def run(): - from activity_browser.layouts import pages - page = pages.MetaDataStorePage() - central = application.main_window.centralWidget() - central.addToGroup("DEBUG", page) diff --git a/activity_browser/actions/method/cf_uncertainty_modify.py b/activity_browser/actions/method/cf_uncertainty_modify.py deleted file mode 100644 index 16d6bc735..000000000 --- a/activity_browser/actions/method/cf_uncertainty_modify.py +++ /dev/null @@ -1,42 +0,0 @@ -from functools import partial -from typing import List - -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards import UncertaintyWizard - - -class CFUncertaintyModify(ABAction): - """ - ABAction to launch the UncertaintyWizard for Characterization Factor and handles the output by writing the - uncertainty data using the ImpactCategoryController to the Characterization Factor in question. - """ - - icon = qicons.edit - text = "Modify uncertainty" - - @classmethod - @exception_dialogs - def run(cls, method_name: tuple, char_factors: List[tuple]): - wizard = UncertaintyWizard(char_factors[0], application.main_window) - wizard.complete.connect(partial(cls.wizard_done, method_name)) - wizard.show() - - @staticmethod - def wizard_done(method_name: tuple, cf: tuple, uncertainty: dict): - """Update the CF with new uncertainty information, possibly converting - the second item in the tuple to a dictionary without losing information. - """ - method = bd.Method(method_name) - method_dict = {cf[0]: cf[1] for cf in method.load()} - - if isinstance(cf[1], dict): - cf[1].update(uncertainty) - method_dict[cf[0]] = cf[1] - else: - uncertainty["amount"] = cf[1] - method_dict[cf[0]] = uncertainty - - method.write(list(method_dict.items())) diff --git a/activity_browser/actions/parameter/parameter_delete.py b/activity_browser/actions/parameter/parameter_delete.py deleted file mode 100644 index f46f4259d..000000000 --- a/activity_browser/actions/parameter/parameter_delete.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Any - -from activity_browser import signals -from activity_browser.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 - - -class ParameterDelete(ABAction): - """ - ABAction to delete an existing parameter. - """ - - icon = qicons.delete - text = "Delete parameter..." - - @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) - ) - .count() - ) - - 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: - parameter.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. - signals.parameter.recalculated.emit() diff --git a/activity_browser/actions/parameter/parameter_uncertainty_modify.py b/activity_browser/actions/parameter/parameter_uncertainty_modify.py deleted file mode 100644 index b18598976..000000000 --- a/activity_browser/actions/parameter/parameter_uncertainty_modify.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Any - -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class ParameterUncertaintyModify(ABAction): - """ - ABAction to modify the uncertainty of an existing parameter. - """ - - icon = qicons.edit - text = "Modify parameter uncertainty" - - @staticmethod - @exception_dialogs - def run(parameter: Any, uncertainty_dict: dict): - parameter.data.update(uncertainty_dict) - parameter.save() - bd.parameters.recalculate() diff --git a/activity_browser/actions/settings_wizard_open.py b/activity_browser/actions/settings_wizard_open.py deleted file mode 100644 index 0b3e39713..000000000 --- a/activity_browser/actions/settings_wizard_open.py +++ /dev/null @@ -1,16 +0,0 @@ -from activity_browser import application -from activity_browser.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(application.main_window).show() diff --git a/activity_browser/app/README.md b/activity_browser/app/README.md new file mode 100644 index 000000000..7e24d9129 --- /dev/null +++ b/activity_browser/app/README.md @@ -0,0 +1,67 @@ +# 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 model to UI communication +3. **Main Window** (`MainWindow`) - Main application window with pages and panes +4. **Actions** - Command pattern implementation for menu items and toolbar actions. Modifying Brightway2 happens here. +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 +``` + +## Development Notes + +- See `CONTRIBUTING.md` for guidelines on contributing to the project +- This module is the place to add components that depend on the application having been initialized (e.g., actions, panes) + - If the logic you want to add can only depend on brightway2, consider placing it in the `bwutils` submodule instead + - If the widget you want to add does not depend on the application, consider placing it in the `ui` submodule instead diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py new file mode 100644 index 000000000..275cdb6b2 --- /dev/null +++ b/activity_browser/app/__init__.py @@ -0,0 +1,31 @@ +# -*- 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 import MainWindow + +application = ABApplication() +metadata = MetaDataStore(application) +settings = Settings() + +# modules dependent on application instance +from .signalling import ABSignals + +signals = ABSignals() + +# modules dependent on application and signals +from . import actions +from . import panes +from . import pages +from . import dialogs + +main_window = MainWindow() +application.main_window = main_window + +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..028d435c3 --- /dev/null +++ b/activity_browser/app/actions/README.md @@ -0,0 +1,92 @@ +# 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 + +## 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** - Always add `@exception_dialogs` decorator for user-facing errors +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 not emit signals themselves** That being said, actions should only emit signals when they modify state in a way that Brightway2 does not automatically notify the UI about. See e.g. parameter_group_delete.py for an example of emitting a signal after deleting a parameter group. diff --git a/activity_browser/actions/__init__.py b/activity_browser/app/actions/__init__.py similarity index 93% rename from activity_browser/actions/__init__.py rename to activity_browser/app/actions/__init__.py index 7c842a5e2..b7ccc729d 100644 --- a/activity_browser/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -1,8 +1,6 @@ from .activity.activity_relink import ActivityRelink from .activity.activity_duplicate import ActivityDuplicate from .activity.activity_open import ActivityOpen -from .activity.activity_graph import ActivityGraph -from .activity.activity_duplicate_to_loc import ActivityDuplicateToLoc from .activity.activity_delete import ActivityDelete from .activity.activity_duplicate_to_db import ActivityDuplicateToDB from .activity.activity_modify import ActivityModify @@ -11,7 +9,6 @@ from .activity.activity_open import ActivityOpen from .activity.activity_relink import ActivityRelink from .activity.activity_sdf_to_clipboard import ActivitySDFToClipboard -from .activity.activity_redo_allocation import MultifunctionalProcessRedoAllocation from .activity.process_property_modify import ProcessPropertyModify from .activity.process_property_remove import ProcessPropertyRemove @@ -34,7 +31,6 @@ from .database.database_delete import DatabaseDelete from .database.database_duplicate import DatabaseDuplicate from .database.database_relink import DatabaseRelink -from .database.database_redo_allocation import DatabaseRedoAllocation from .database.database_explorer_open import DatabaseExplorerOpen from .database.database_process import DatabaseProcess from .database.database_import_from_ecoinvent import DatabaseImportFromEcoinvent @@ -78,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 @@ -95,7 +92,9 @@ 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 +from .metadatastore_cache_clear import MetaDataStoreCacheClear diff --git a/activity_browser/actions/activity/activity_delete.py b/activity_browser/app/actions/activity/activity_delete.py similarity index 96% rename from activity_browser/actions/activity/activity_delete.py rename to activity_browser/app/actions/activity/activity_delete.py index 80354457b..213e1186a 100644 --- a/activity_browser/actions/activity/activity_delete.py +++ b/activity_browser/app/actions/activity/activity_delete.py @@ -9,8 +9,8 @@ GroupDependency, parameters) -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_duplicate.py b/activity_browser/app/actions/activity/activity_duplicate.py similarity index 90% rename from activity_browser/actions/activity/activity_duplicate.py rename to activity_browser/app/actions/activity/activity_duplicate.py index ae19411e6..1197a994d 100644 --- a/activity_browser/actions/activity/activity_duplicate.py +++ b/activity_browser/app/actions/activity/activity_duplicate.py @@ -2,7 +2,7 @@ from qtpy import QtCore -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks from bw2data import get_activity from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_duplicate_to_db.py b/activity_browser/app/actions/activity/activity_duplicate_to_db.py similarity index 94% rename from activity_browser/actions/activity/activity_duplicate_to_db.py rename to activity_browser/app/actions/activity/activity_duplicate_to_db.py index 3489b5239..d677590a1 100644 --- a/activity_browser/actions/activity/activity_duplicate_to_db.py +++ b/activity_browser/app/actions/activity/activity_duplicate_to_db.py @@ -5,9 +5,9 @@ import bw2data as bd import bw_functional as bf -from activity_browser import application -from activity_browser.bwutils import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app import application +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from bw_functional import Product @@ -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/actions/activity/activity_modify.py b/activity_browser/app/actions/activity/activity_modify.py similarity index 76% rename from activity_browser/actions/activity/activity_modify.py rename to activity_browser/app/actions/activity/activity_modify.py index e48f8a516..4eb54fa98 100644 --- a/activity_browser/actions/activity/activity_modify.py +++ b/activity_browser/app/actions/activity/activity_modify.py @@ -1,9 +1,7 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from bw2data import get_node, Node from activity_browser.ui.icons import qicons -from activity_browser import bwutils - -from .activity_redo_allocation import MultifunctionalProcessRedoAllocation +from activity_browser.bwutils.commontasks import refresh_node class ActivityModify(ABAction): @@ -19,7 +17,7 @@ class ActivityModify(ABAction): @staticmethod @exception_dialogs def run(activity: tuple | int | Node, field: str, value: any): - activity = bwutils.refresh_node(activity) + activity = refresh_node(activity) if field == "product": # for some reason product needs to be set like this diff --git a/activity_browser/app/actions/activity/activity_new_process.py b/activity_browser/app/actions/activity/activity_new_process.py new file mode 100644 index 000000000..79725152c --- /dev/null +++ b/activity_browser/app/actions/activity/activity_new_process.py @@ -0,0 +1,130 @@ +from uuid import uuid4 + +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_open import ActivityOpen + + +class ActivityNewProcess(ABAction): + """ + ABAction to create a new activity. Prompts the user to supply a name. Returns if no name is supplied or if the user + cancels. Otherwise, instructs the ActivityController to create a new activity. + """ + + icon = qicons.add + text = "New process" + + @staticmethod + @exception_dialogs + 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_() != QtWidgets.QDialog.DialogCode.Accepted: + return + name, ref_product, unit, location = dialog.get_new_process_data() + # if no name is provided, return + if not name: + return + if ref_product == "": + ref_product = name + + database = bd.Database(database_name) + legacy_backend = database_is_legacy(database_name) + + # create process + new_proc_data = { + "name": name, + "location": location, + "type": "process" if not legacy_backend else "processwithreferenceproduct", + } + + if legacy_backend: + new_proc_data["reference product"] = ref_product + new_proc_data["unit"] = unit + + new_process: bd.Node = database.new_activity(code=uuid4().hex, **new_proc_data) + new_process.save() + + if legacy_backend: + new_process.new_exchange( + input=new_process.key, + type="production", + amount=1.0, + ).save() + + if not legacy_backend: + # create reference product + new_ref_prod_data = { + "product": ref_product, + "unit": unit, + "location": location, + "type": "product", + } + prod = new_process.new_product(code=uuid4().hex, **new_ref_prod_data) + 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/actions/activity/activity_new_product.py b/activity_browser/app/actions/activity/activity_new_product.py similarity index 94% rename from activity_browser/actions/activity/activity_new_product.py rename to activity_browser/app/actions/activity/activity_new_product.py index 52879a67e..305ebb39d 100644 --- a/activity_browser/actions/activity/activity_new_product.py +++ b/activity_browser/app/actions/activity/activity_new_product.py @@ -6,8 +6,9 @@ from bw_functional import Process -from activity_browser import application, bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -44,12 +45,12 @@ def run(activities: list[tuple | int | bd.Node], product_type: str = "product"): Raises: AssertionError: If an activity is not of type `Process`. """ - activities = [bwutils.refresh_node(activity) for activity in activities] + activities = [refresh_node(activity) for activity in activities] for act in activities: assert isinstance(act, Process), "Cannot create new product for non-process type" # Ask the user to provide a name for the new product - dialog = NewProductDialog(act, product_type, application.main_window) + dialog = NewProductDialog(act, product_type, app.main_window) # If the user cancels, skip to the next activity if dialog.exec_() != QtWidgets.QDialog.Accepted: continue diff --git a/activity_browser/actions/activity/activity_open.py b/activity_browser/app/actions/activity/activity_open.py similarity index 70% rename from activity_browser/actions/activity/activity_open.py rename to activity_browser/app/actions/activity/activity_open.py index 6806636c5..fe270d1dd 100644 --- a/activity_browser/actions/activity/activity_open.py +++ b/activity_browser/app/actions/activity/activity_open.py @@ -1,13 +1,14 @@ -from logging import getLogger +from loguru import logger import bw2data as bd import bw_functional as bf -from activity_browser import signals, bwutils, application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, is_node_process +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class ActivityOpen(ABAction): @@ -41,22 +42,22 @@ def run(activities: list[tuple | int | bd.Node]): Logs: Warning: If an activity type is not supported. """ - from activity_browser.layouts import pages + from activity_browser.app import pages # Refresh the activity nodes to ensure they are up-to-date - activities = [bwutils.refresh_node(activity) for activity in activities] - processes = [bwutils.refresh_node(function["processor"]) for function in activities if isinstance(function, bf.Product)] + activities = [refresh_node(activity) for activity in activities] + processes = [refresh_node(function["processor"]) for function in activities if isinstance(function, bf.Product)] activities = list(set(activities + processes)) for act in activities: # Check if the activity type is supported - if not bwutils.is_node_process(act): - log.warning(f"Can't open activity {act.key} - opening type: `{act.get('type')}` not supported") + if not is_node_process(act): + logger.warning(f"Can't open activity {act.key} - opening type: `{act.get('type')}` not supported") continue # Create a details page for the activity page = pages.ActivityDetailsPage(act) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() # Add the details page to the "Activity Details" group in the central widget central.addToGroup("Activity Details", page) diff --git a/activity_browser/actions/activity/activity_relink.py b/activity_browser/app/actions/activity/activity_relink.py similarity index 95% rename from activity_browser/actions/activity/activity_relink.py rename to activity_browser/app/actions/activity/activity_relink.py index c09e4fe3e..69038ff09 100644 --- a/activity_browser/actions/activity/activity_relink.py +++ b/activity_browser/app/actions/activity/activity_relink.py @@ -2,8 +2,8 @@ from qtpy import QtCore, QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.strategies import relink_activity_exchanges from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -35,7 +35,7 @@ def run(activity_keys: List[tuple]): # present the alternatives to the user in a linking dialog dialog = ActivityLinkingDialog.relink_sqlite( - activity["name"], options, application.main_window + activity["name"], options, app.main_window ) # return if the user cancels @@ -60,7 +60,7 @@ def run(activity_keys: List[tuple]): # if any relinks failed present them to the user if failed > 0: relinking_dialog = ActivityLinkingResultsDialog.present_relinking_results( - application.main_window, relinking_results, examples + app.main_window, relinking_results, examples ) relinking_dialog.exec_() @@ -177,7 +177,7 @@ def construct_results_dialog( link_results: dict = None, unlinked_exchanges: dict = None, ) -> "ActivityLinkingResultsDialog": - from activity_browser import actions + from activity_browser import app obj = cls(parent) for k, results in link_results.items(): @@ -194,7 +194,7 @@ def construct_results_dialog( for act, key in unlinked_exchanges.items(): button = QtWidgets.QPushButton(act.as_dict()["name"]) button.clicked.connect( - lambda: actions.ActivityOpen.run([act.key]) + lambda: app.actions.ActivityOpen.run([act.key]) ) obj.exchangesUnlinked.addWidget(button) obj.updateGeometry() diff --git a/activity_browser/actions/activity/activity_sdf_to_clipboard.py b/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py similarity index 79% rename from activity_browser/actions/activity/activity_sdf_to_clipboard.py rename to activity_browser/app/actions/activity/activity_sdf_to_clipboard.py index e35568cf5..f74a5c690 100644 --- a/activity_browser/actions/activity/activity_sdf_to_clipboard.py +++ b/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py @@ -3,8 +3,8 @@ import bw2data as bd import bw_functional as bf -from activity_browser import bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.commontasks import refresh_node, exchanges_to_sdf +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -21,7 +21,7 @@ class ActivitySDFToClipboard(ABAction): @staticmethod @exception_dialogs def run(activities: List[tuple | int | bd.Node]): - activities = [bwutils.refresh_node(node) for node in activities] + activities = [refresh_node(node) for node in activities] exchanges = [] for activity in activities: @@ -33,5 +33,5 @@ def run(activities: List[tuple | int | bd.Node]): else: exchanges += [exc.as_dict() for exc in activity.exchanges()] - df = bwutils.exchanges_to_sdf(exchanges) + df = exchanges_to_sdf(exchanges) df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/actions/activity/process_property_modify.py b/activity_browser/app/actions/activity/process_property_modify.py similarity index 93% rename from activity_browser/actions/activity/process_property_modify.py rename to activity_browser/app/actions/activity/process_property_modify.py index 35b123a62..26e60b30c 100644 --- a/activity_browser/actions/activity/process_property_modify.py +++ b/activity_browser/app/actions/activity/process_property_modify.py @@ -1,7 +1,8 @@ from qtpy import QtWidgets, QtCore -from activity_browser import application, bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from bw_functional import Process @@ -18,7 +19,7 @@ class ProcessPropertyModify(ABAction): Args: process (tuple | int | Process): The process to modify. Can be a tuple, integer, or Process object. - property_name (str, optional): The name of the property to modify. Defaults to None. + property_name (str, optional): The name of the property to modify. Defaults to None. Raises: ValueError: If the provided process is not of type Process. @@ -33,7 +34,7 @@ def run(process: tuple | int | Process, property_name: str = None ): - process = bwutils.refresh_node(process) + process = refresh_node(process) if not isinstance(process, Process): raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") @@ -78,7 +79,7 @@ class PropertyDialog(QtWidgets.QDialog): prop: dict | None = None def __init__(self, process: Process): - super().__init__(application.main_window) + super().__init__(app.main_window) self.process = process self.setWindowTitle("Add Property") diff --git a/activity_browser/actions/activity/process_property_remove.py b/activity_browser/app/actions/activity/process_property_remove.py similarity index 85% rename from activity_browser/actions/activity/process_property_remove.py rename to activity_browser/app/actions/activity/process_property_remove.py index b42ebcc3e..c3c731569 100644 --- a/activity_browser/actions/activity/process_property_remove.py +++ b/activity_browser/app/actions/activity/process_property_remove.py @@ -1,13 +1,13 @@ -from logging import getLogger +from loguru import logger -from activity_browser import bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from bw_functional import Process from bw2data import databases -log = getLogger(__name__) + class ProcessPropertyRemove(ABAction): @@ -36,14 +36,14 @@ class ProcessPropertyRemove(ABAction): @staticmethod @exception_dialogs def run(process: tuple | int | Process, property_name: str): - process = bwutils.refresh_node(process) + process = refresh_node(process) if not isinstance(process, Process): raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") allocate = property_name == process.get("allocation") if property_name not in process.available_properties(): - log.warning(f"Property '{property_name}' not found in process {process.key}.") + logger.warning(f"Property '{property_name}' not found in process {process.key}.") return if allocate: diff --git a/activity_browser/actions/base.py b/activity_browser/app/actions/base.py similarity index 92% rename from activity_browser/actions/base.py rename to activity_browser/app/actions/base.py index 4894ff20a..1872a2cf5 100644 --- a/activity_browser/actions/base.py +++ b/activity_browser/app/actions/base.py @@ -1,9 +1,9 @@ -from logging import getLogger +from loguru import logger from qtpy import QtCore, QtGui, QtWidgets -from activity_browser import application +from activity_browser import app + -log = getLogger(__name__) class ABAction: @@ -51,7 +51,7 @@ def wrapper(*args, **kwargs): if not hasattr(e, "dialog_flag"): setattr(e, "dialog_flag", True) QtWidgets.QMessageBox.critical( - application.main_window, + app.main_window, f"An error occurred: {type(e).__name__}", f"An error occurred, check the logs for more information \n\n {str(e)}", QtWidgets.QMessageBox.Ok, diff --git a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py similarity index 69% rename from activity_browser/actions/calculation_setup/cs_add_functional_unit.py rename to activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py index 7e71b6351..2171ee4f7 100644 --- a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py +++ b/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger -from activity_browser import bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + class CSAddFunctionalUnit(ABAction): @@ -13,7 +13,7 @@ class CSAddFunctionalUnit(ABAction): @staticmethod @exception_dialogs def run(cs_name: str, activities: list[tuple | int | bd.Node]): - activities = [bwutils.refresh_node(node) for node in activities] + activities = [refresh_node(node) for node in activities] calculation_setup = bd.calculation_setups[cs_name] fus = [{act.key: -1.0 if act.get("type") == "waste" else 1.0} for act in activities] diff --git a/activity_browser/actions/calculation_setup/cs_add_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py similarity index 76% rename from activity_browser/actions/calculation_setup/cs_add_impact_category.py rename to activity_browser/app/actions/calculation_setup/cs_add_impact_category.py index 059546551..69a5a34c5 100644 --- a/activity_browser/actions/calculation_setup/cs_add_impact_category.py +++ b/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs + -log = getLogger(__name__) class CSAddImpactCategory(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_calculate.py b/activity_browser/app/actions/calculation_setup/cs_calculate.py similarity index 66% rename from activity_browser/actions/calculation_setup/cs_calculate.py rename to activity_browser/app/actions/calculation_setup/cs_calculate.py index 2e3b08c61..f5e8830eb 100644 --- a/activity_browser/actions/calculation_setup/cs_calculate.py +++ b/activity_browser/app/actions/calculation_setup/cs_calculate.py @@ -1,15 +1,18 @@ -from logging import getLogger +from loguru import logger import pandas as pd import bw2data as bd from qtpy import QtCore, QtWidgets -from activity_browser import application, bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.bwutils.multilca import MLCA, Contributions +from activity_browser.bwutils.superstructure import SuperstructureMLCA, SuperstructureContributions +from activity_browser.bwutils.montecarlo import MonteCarloLCA +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSCalculate(ABAction): @@ -24,7 +27,7 @@ class CSCalculate(ABAction): @staticmethod @exception_dialogs def run(cs_name: str, scenario_data: pd.DataFrame = None): - from activity_browser.layouts import pages + from activity_browser.app import pages # Check if the calculation setup is complete if cs_name not in bd.calculation_setups: @@ -35,23 +38,23 @@ def run(cs_name: str, scenario_data: pd.DataFrame = None): if not cs.get("ia"): raise Exception(f"Calculation setup '{cs_name}' has no impact assessment methods.") - dialog = CalculationDialog(cs_name, application.main_window) + dialog = CalculationDialog(cs_name, app.main_window) dialog.show() - application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + app.application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) try: if scenario_data is None: - mlca = bwutils.MLCA(cs_name) - contributions = bwutils.Contributions(mlca) + mlca = MLCA(cs_name) + contributions = Contributions(mlca) else: - mlca = bwutils.SuperstructureMLCA(cs_name, scenario_data) - contributions = bwutils.SuperstructureContributions(mlca) + mlca = SuperstructureMLCA(cs_name, scenario_data) + contributions = SuperstructureContributions(mlca) mlca.calculate() - mc = bwutils.MonteCarloLCA(cs_name) + mc = MonteCarloLCA(cs_name) page = pages.LCAResultsPage(cs_name, mlca, contributions, mc) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() except: dialog.close() raise diff --git a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py similarity index 88% rename from activity_browser/actions/calculation_setup/cs_change_functional_unit.py rename to activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py index 5111c23b8..f7029170a 100644 --- a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py +++ b/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py @@ -1,10 +1,9 @@ -from logging import getLogger +from loguru import logger -from activity_browser import bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + class CSChangeFunctionalUnit(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_delete.py b/activity_browser/app/actions/calculation_setup/cs_delete.py similarity index 80% rename from activity_browser/actions/calculation_setup/cs_delete.py rename to activity_browser/app/actions/calculation_setup/cs_delete.py index f1575354c..47b88ab29 100644 --- a/activity_browser/actions/calculation_setup/cs_delete.py +++ b/activity_browser/app/actions/calculation_setup/cs_delete.py @@ -1,13 +1,13 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs +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 -log = getLogger(__name__) + class CSDelete(ABAction): @@ -40,8 +40,8 @@ def run(cs_names: str | list[str]): for cs_name in cs_names: if cs_name not in bd.calculation_setups: - log.warning(f"Calculation setup {cs_name} not found") + logger.warning(f"Calculation setup {cs_name} not found") continue del bd.calculation_setups[cs_name] - log.info(f"Deleted calculation setup: {cs_name}") + logger.info(f"Deleted calculation setup: {cs_name}") diff --git a/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py similarity index 78% rename from activity_browser/actions/calculation_setup/cs_delete_functional_unit.py rename to activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py index 2d1f4b972..654f311e2 100644 --- a/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py +++ b/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs + -log = getLogger(__name__) class CSDeleteFunctionalUnit(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_delete_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py similarity index 78% rename from activity_browser/actions/calculation_setup/cs_delete_impact_category.py rename to activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py index de58fcfd0..fa4ad031a 100644 --- a/activity_browser/actions/calculation_setup/cs_delete_impact_category.py +++ b/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs + -log = getLogger(__name__) class CSDeleteImpactCategory(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_duplicate.py b/activity_browser/app/actions/calculation_setup/cs_duplicate.py similarity index 81% rename from activity_browser/actions/calculation_setup/cs_duplicate.py rename to activity_browser/app/actions/calculation_setup/cs_duplicate.py index 69a675a2e..aa49be61a 100644 --- a/activity_browser/actions/calculation_setup/cs_duplicate.py +++ b/activity_browser/app/actions/calculation_setup/cs_duplicate.py @@ -1,13 +1,14 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs +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 -log = getLogger(__name__) +from .cs_open import CSOpen + class CSDuplicate(ABAction): @@ -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) - log.info(f"Copied calculation setup {cs_name} as {new_name}") + logger.info(f"Copied calculation setup {cs_name} as {new_name}") + CSOpen.run(new_name) diff --git a/activity_browser/actions/calculation_setup/cs_new.py b/activity_browser/app/actions/calculation_setup/cs_new.py similarity index 87% rename from activity_browser/actions/calculation_setup/cs_new.py rename to activity_browser/app/actions/calculation_setup/cs_new.py index 3e0d9878e..c5d3cedd7 100644 --- a/activity_browser/actions/calculation_setup/cs_new.py +++ b/activity_browser/app/actions/calculation_setup/cs_new.py @@ -1,15 +1,15 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets import bw2data as bd -from activity_browser import application, actions -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import refresh_node +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSNew(ABAction): @@ -51,7 +51,7 @@ def run(name: str = None, # throw error if the name is already present, and return if name in bd.calculation_setups: QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Not possible", "A calculation setup with this name already exists.", ) @@ -69,9 +69,9 @@ def run(name: str = None, # instruct the CalculationSetupController to create a CS with the new name bd.calculation_setups[name] = {"inv": inv, "ia": ia} - log.info(f"New calculation setup: {name}") + logger.info(f"New calculation setup: {name}") - actions.CSOpen.run(name) + app.actions.CSOpen.run(name) @staticmethod def get_cs_name() -> str | None: @@ -80,7 +80,7 @@ def get_cs_name() -> str | None: """ # prompt the user to give a name for the new calculation setup name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create new calculation setup", "Name of new calculation setup:" + " " * 10, ) diff --git a/activity_browser/app/actions/calculation_setup/cs_open.py b/activity_browser/app/actions/calculation_setup/cs_open.py new file mode 100644 index 000000000..688707b93 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_open.py @@ -0,0 +1,25 @@ +from loguru import logger + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd + + +class CSOpen(ABAction): + text = "Open" + + @staticmethod + @exception_dialogs + def run(cs_names: str | list[str]): + if isinstance(cs_names, str): + cs_names = [cs_names] + + for cs_name in cs_names: + if cs_name not in bd.calculation_setups: + logger.warning(f"Calculation setup {cs_name} not found") + continue + + page = app.pages.CalculationSetupPage(cs_name) + central = app.main_window.centralWidget() + + central.addToGroup("LCA Setup", page) diff --git a/activity_browser/actions/calculation_setup/cs_rename.py b/activity_browser/app/actions/calculation_setup/cs_rename.py similarity index 82% rename from activity_browser/actions/calculation_setup/cs_rename.py rename to activity_browser/app/actions/calculation_setup/cs_rename.py index 95a638810..72ad71041 100644 --- a/activity_browser/actions/calculation_setup/cs_rename.py +++ b/activity_browser/app/actions/calculation_setup/cs_rename.py @@ -1,13 +1,13 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app import application, signals +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 -log = getLogger(__name__) + class CSRename(ABAction): @@ -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) - log.info(f"Renamed calculation setup from {cs_name} to {new_name}") + logger.info(f"Renamed calculation setup from {cs_name} to {new_name}") diff --git a/activity_browser/actions/database/database_delete.py b/activity_browser/app/actions/database/database_delete.py similarity index 88% rename from activity_browser/actions/database/database_delete.py rename to activity_browser/app/actions/database/database_delete.py index 2e88505ac..81af59e79 100644 --- a/activity_browser/actions/database/database_delete.py +++ b/activity_browser/app/actions/database/database_delete.py @@ -6,9 +6,8 @@ from bw2data.parameters import Group from bw2data.backends.proxies import ExchangeDataset, Exchanges -from activity_browser import application, settings -from activity_browser.bwutils import AB_metadata -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -51,7 +50,7 @@ def run(db_names: List[str]): # get the total record count from all databases total_records = 0 for db_name in db_names: - n_records = AB_metadata.dataframe[AB_metadata.dataframe["database"] == db_name].shape[0] + n_records = app.metadata.dataframe[app.metadata.dataframe["database"] == db_name].shape[0] total_records += n_records # construct warning text @@ -67,7 +66,7 @@ def run(db_names: List[str]): # ask the user for confirmation QtWidgets.QApplication.restoreOverrideCursor() response = QtWidgets.QMessageBox.question( - application.main_window, build_title(db_names), text + app.main_window, build_title(db_names), text ) # return if the user cancels @@ -87,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/actions/database/database_duplicate.py b/activity_browser/app/actions/database/database_duplicate.py similarity index 94% rename from activity_browser/actions/database/database_duplicate.py rename to activity_browser/app/actions/database/database_duplicate.py index d02858470..3ca59654b 100644 --- a/activity_browser/actions/database/database_duplicate.py +++ b/activity_browser/app/actions/database/database_duplicate.py @@ -5,8 +5,8 @@ import bw2data as bd import bw_functional as bf -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread @@ -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/actions/database/database_explorer_open.py b/activity_browser/app/actions/database/database_explorer_open.py similarity index 64% rename from activity_browser/actions/database/database_explorer_open.py rename to activity_browser/app/actions/database/database_explorer_open.py index fc9dc4a14..219c0d32e 100644 --- a/activity_browser/actions/database/database_explorer_open.py +++ b/activity_browser/app/actions/database/database_explorer_open.py @@ -1,5 +1,5 @@ -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -16,6 +16,6 @@ class DatabaseExplorerOpen(ABAction): @staticmethod @exception_dialogs def run(db_name: str): - from activity_browser.layouts.panes import DatabaseExplorerPane - db_explorer = DatabaseExplorerPane(db_name, application.main_window) + from activity_browser.app.panes import DatabaseExplorerPane + db_explorer = DatabaseExplorerPane(db_name, app.main_window) db_explorer.show() diff --git a/activity_browser/actions/database/database_export_bw2package.py b/activity_browser/app/actions/database/database_export_bw2package.py similarity index 86% rename from activity_browser/actions/database/database_export_bw2package.py rename to activity_browser/app/actions/database/database_export_bw2package.py index 92e475457..db5fdd166 100644 --- a/activity_browser/actions/database/database_export_bw2package.py +++ b/activity_browser/app/actions/database/database_export_bw2package.py @@ -1,16 +1,14 @@ -from logging import getLogger +from loguru import logger from typing import List from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +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 -log = getLogger(__name__) - class DatabaseExportBW2Package(ABAction): """ @@ -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" @@ -86,12 +84,12 @@ def run_safely(self, db_names: List[str], path: str): try: success = exporters.store_database_as_package(db_name, path) if success: - log.info(f"Successfully exported database '{db_name}' to BW2Package") + logger.info(f"Successfully exported database '{db_name}' to BW2Package") else: - log.error(f"Failed to export database '{db_name}'") + logger.error(f"Failed to export database '{db_name}'") raise RuntimeError(f"Database '{db_name}' not found") except Exception as e: - log.error(f"Failed to export database '{db_name}': {e}") + logger.error(f"Failed to export database '{db_name}': {e}") raise def initializePage(self, context: dict): diff --git a/activity_browser/actions/database/database_export_excel.py b/activity_browser/app/actions/database/database_export_excel.py similarity index 88% rename from activity_browser/actions/database/database_export_excel.py rename to activity_browser/app/actions/database/database_export_excel.py index 199391750..319c83e90 100644 --- a/activity_browser/actions/database/database_export_excel.py +++ b/activity_browser/app/actions/database/database_export_excel.py @@ -1,18 +1,15 @@ -from logging import getLogger +from loguru import logger from typing import List from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +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 -log = getLogger(__name__) - - 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" @@ -86,9 +83,9 @@ def run_safely(self, db_names: List[str], path: str): for db_name in db_names: try: exporters.write_lci_excel(db_name, path) - log.info(f"Successfully exported database '{db_name}' to Excel") + logger.info(f"Successfully exported database '{db_name}' to Excel") except Exception as e: - log.error(f"Failed to export database '{db_name}': {e}") + logger.error(f"Failed to export database '{db_name}': {e}") raise def initializePage(self, context: dict): diff --git a/activity_browser/actions/database/database_import_from_ecoinvent.py b/activity_browser/app/actions/database/database_import_from_ecoinvent.py similarity index 95% rename from activity_browser/actions/database/database_import_from_ecoinvent.py rename to activity_browser/app/actions/database/database_import_from_ecoinvent.py index d2e75d0a7..10e89eb5e 100644 --- a/activity_browser/actions/database/database_import_from_ecoinvent.py +++ b/activity_browser/app/actions/database/database_import_from_ecoinvent.py @@ -1,6 +1,6 @@ import re import os -from logging import getLogger +from loguru import logger from copy import deepcopy import requests @@ -12,15 +12,15 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Signal, SignalInstance -from activity_browser import application, signals +from activity_browser.app import application, signals from activity_browser.ui import widgets, icons -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.io.ecoinvent_importer import Ecoinvent7zImporter from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter from activity_browser.mod.bw2io.migrations import ab_create_core_migrations from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseImportFromEcoinvent(ABAction): @@ -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/actions/database/database_importer_bw2package.py b/activity_browser/app/actions/database/database_importer_bw2package.py similarity index 89% rename from activity_browser/actions/database/database_importer_bw2package.py rename to activity_browser/app/actions/database/database_importer_bw2package.py index 3386d2e4f..d42cbaa28 100644 --- a/activity_browser/actions/database/database_importer_bw2package.py +++ b/activity_browser/app/actions/database/database_importer_bw2package.py @@ -1,15 +1,15 @@ import os -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons, widgets from activity_browser.bwutils.importers import ABPackage from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseImporterBW2Package(ABAction): @@ -24,7 +24,7 @@ class DatabaseImporterBW2Package(ABAction): def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose .bw2package to import', filter='Brightway2 Database Package (*.bw2package);; All files (*.*)' ) @@ -38,7 +38,7 @@ def run(cls): } # show the import setup dialog - import_dialog = ImportSetup(parent=application.main_window, title="Import Database", context=context) + import_dialog = ImportSetup(parent=app.main_window, title="Import Database", context=context) import_dialog.exec_() diff --git a/activity_browser/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py similarity index 58% rename from activity_browser/actions/database/database_importer_excel.py rename to activity_browser/app/actions/database/database_importer_excel.py index 5226aef93..6604add2b 100644 --- a/activity_browser/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -1,17 +1,17 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets from qtpy.QtCore import Signal, SignalInstance import bw2data as bd -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils.importers import ABExcelImporter from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseImporterExcel(ABAction): @@ -25,7 +25,7 @@ class DatabaseImporterExcel(ABAction): def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose brightway excel database to import', filter='excel spreadsheet (*.xlsx);; All files (*.*)' ) @@ -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/actions/database/database_new.py b/activity_browser/app/actions/database/database_new.py similarity index 93% rename from activity_browser/actions/database/database_new.py rename to activity_browser/app/actions/database/database_new.py index a00758b7c..2933e9d45 100644 --- a/activity_browser/actions/database/database_new.py +++ b/activity_browser/app/actions/database/database_new.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets, QtCore -from activity_browser import application, settings, signals -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 @@ -41,7 +41,7 @@ def run(): if name in bd.databases: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible", "A database with this name already exists.", ) @@ -88,7 +88,7 @@ def get_new_database_data(cls, window_title="New Database", backend="functional_ - The selected backend type (str). - A boolean indicating whether the dialog was accepted (True) or canceled (False). """ - dialog = cls(window_title, backend, application.main_window) + dialog = cls(window_title, backend, app.main_window) result = dialog.exec_() return dialog.name_input.text(), dialog.backend_dropdown.currentText(), result == QtWidgets.QDialog.Accepted diff --git a/activity_browser/app/actions/database/database_open.py b/activity_browser/app/actions/database/database_open.py new file mode 100644 index 000000000..3a8ef5623 --- /dev/null +++ b/activity_browser/app/actions/database/database_open.py @@ -0,0 +1,64 @@ +from qtpy.QtCore import Qt, QEventLoop + +from activity_browser import app +from activity_browser.ui import widgets +from activity_browser.app.actions.base import ABAction, exception_dialogs + + + + +class DatabaseOpen(ABAction): + text = "Open Database" + + @staticmethod + @exception_dialogs + def run(database_names: list[str]): + from activity_browser.app import panes + + sibling = DatabaseOpen.find_sibling() + + for db_name in database_names: + db_pane = panes.DatabaseProductsPane(app.main_window, db_name) + dock_widget = db_pane.getDockWidget(app.main_window) + dock_widget.resize(dock_widget.width(), app.main_window.height() // 2) + + app.main_window.addDockWidget(DatabaseOpen.get_area(), dock_widget) + + if sibling: + app.main_window.tabifyDockWidget(sibling, dock_widget) + + app.application.thread().eventDispatcher().processEvents(QEventLoop.ProcessEventsFlags.AllEvents) + dock_widget.raise_() + dock_widget.show() + else: + dock_widget.show() + app.main_window.resizeDocks( + [dock_widget], + [1000], + Qt.Vertical + ) + + @staticmethod + def find_sibling(): + """ + Find the dockwidget location where the database pane should be opened. + """ + from activity_browser.app import panes + + all_dws = app.main_window.findChildren(widgets.ABDockWidget) + databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") + + products_dws = [w for w in all_dws if + isinstance(w.widget(), panes.DatabaseProductsPane) and + app.main_window.dockWidgetArea(w) == app.main_window.dockWidgetArea(databases_dw) and + not w.visibleRegion().isNull() + ] + return products_dws[0] if products_dws else None + + @staticmethod + def get_area(): + """ + Find the dockwidget location where the database pane should be opened. + """ + databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") + return app.main_window.dockWidgetArea(databases_dw) diff --git a/activity_browser/actions/database/database_process.py b/activity_browser/app/actions/database/database_process.py similarity index 83% rename from activity_browser/actions/database/database_process.py rename to activity_browser/app/actions/database/database_process.py index 81ffa73ab..0f5245829 100644 --- a/activity_browser/actions/database/database_process.py +++ b/activity_browser/app/actions/database/database_process.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +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 diff --git a/activity_browser/actions/database/database_relink.py b/activity_browser/app/actions/database/database_relink.py similarity index 71% rename from activity_browser/actions/database/database_relink.py rename to activity_browser/app/actions/database/database_relink.py index aee645795..ba6325b92 100644 --- a/activity_browser/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 import application -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils.strategies import relink_exchanges_existing_db -from activity_browser.mod import bw2data as bd +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.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): @@ -187,7 +230,7 @@ def construct_results_dialog( link_results: dict = None, unlinked_exchanges: dict = None, ) -> "DatabaseLinkingResultsDialog": - from activity_browser import actions + from activity_browser import app obj = cls(parent) for k, results in link_results.items(): @@ -204,7 +247,7 @@ def construct_results_dialog( for act, key in unlinked_exchanges.items(): button = QtWidgets.QPushButton(act.as_dict()["name"]) button.clicked.connect( - lambda: actions.ActivityOpen.run([act.key]) + lambda: app.actions.ActivityOpen.run([act.key]) ) obj.exchangesUnlinked.addWidget(button) obj.updateGeometry() diff --git a/activity_browser/actions/database/database_set_readonly.py b/activity_browser/app/actions/database/database_set_readonly.py similarity index 93% rename from activity_browser/actions/database/database_set_readonly.py rename to activity_browser/app/actions/database/database_set_readonly.py index a5aa48d5b..b54696285 100644 --- a/activity_browser/actions/database/database_set_readonly.py +++ b/activity_browser/app/actions/database/database_set_readonly.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/exchange/exchange_copy_sdf.py b/activity_browser/app/actions/exchange/exchange_copy_sdf.py similarity index 89% rename from activity_browser/actions/exchange/exchange_copy_sdf.py rename to activity_browser/app/actions/exchange/exchange_copy_sdf.py index e9b0d1ae7..f7d2c0b8b 100644 --- a/activity_browser/actions/exchange/exchange_copy_sdf.py +++ b/activity_browser/app/actions/exchange/exchange_copy_sdf.py @@ -2,7 +2,7 @@ import pandas as pd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_delete.py b/activity_browser/app/actions/exchange/exchange_delete.py similarity index 83% rename from activity_browser/actions/exchange/exchange_delete.py rename to activity_browser/app/actions/exchange/exchange_delete.py index e0fc1c023..2a22a2f02 100644 --- a/activity_browser/actions/exchange/exchange_delete.py +++ b/activity_browser/app/actions/exchange/exchange_delete.py @@ -1,6 +1,6 @@ from typing import Any, List -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_formula_remove.py b/activity_browser/app/actions/exchange/exchange_formula_remove.py similarity index 89% rename from activity_browser/actions/exchange/exchange_formula_remove.py rename to activity_browser/app/actions/exchange/exchange_formula_remove.py index 2b32680bb..4afdbcbb7 100644 --- a/activity_browser/actions/exchange/exchange_formula_remove.py +++ b/activity_browser/app/actions/exchange/exchange_formula_remove.py @@ -2,7 +2,7 @@ from bw2data.parameters import ParameterizedExchange -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_modify.py b/activity_browser/app/actions/exchange/exchange_modify.py similarity index 89% rename from activity_browser/actions/exchange/exchange_modify.py rename to activity_browser/app/actions/exchange/exchange_modify.py index a7b89c753..ae52995f4 100644 --- a/activity_browser/actions/exchange/exchange_modify.py +++ b/activity_browser/app/actions/exchange/exchange_modify.py @@ -1,10 +1,6 @@ -from logging import getLogger - -from qtpy.QtWidgets import QMessageBox from bw2data.proxies import ExchangeProxyBase -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter from activity_browser.ui.icons import qicons @@ -12,7 +8,7 @@ from ..parameter.parameter_new_automatic import ParameterNewAutomatic from .exchange_formula_remove import ExchangeFormulaRemove -log = getLogger(__name__) + class ExchangeModify(ABAction): diff --git a/activity_browser/actions/exchange/exchange_new.py b/activity_browser/app/actions/exchange/exchange_new.py similarity index 74% rename from activity_browser/actions/exchange/exchange_new.py rename to activity_browser/app/actions/exchange/exchange_new.py index e5e05d7f5..454ce537a 100644 --- a/activity_browser/actions/exchange/exchange_new.py +++ b/activity_browser/app/actions/exchange/exchange_new.py @@ -1,6 +1,6 @@ from typing import List -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -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/actions/exchange/exchange_sdf_to_clipboard.py b/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py similarity index 76% rename from activity_browser/actions/exchange/exchange_sdf_to_clipboard.py rename to activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py index cf7c4d9cf..b775d5aad 100644 --- a/activity_browser/actions/exchange/exchange_sdf_to_clipboard.py +++ b/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py @@ -3,8 +3,8 @@ import bw2data as bd import bw_functional as bf -from activity_browser import bwutils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.commontasks import refresh_edge, exchanges_to_sdf +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -21,7 +21,7 @@ class ExchangeSDFToClipboard(ABAction): @staticmethod @exception_dialogs def run(exchanges: List[int | bd.Edge]): - exchanges = [bwutils.refresh_edge(edge) for edge in exchanges] + exchanges = [refresh_edge(edge) for edge in exchanges] virtual_exchanges = [] for exchange in exchanges: @@ -30,5 +30,5 @@ def run(exchanges: List[int | bd.Edge]): else: virtual_exchanges.append(exchange.as_dict()) - df = bwutils.exchanges_to_sdf(virtual_exchanges) + df = exchanges_to_sdf(virtual_exchanges) df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py new file mode 100644 index 000000000..cc105def1 --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py @@ -0,0 +1,34 @@ +from typing import Any, List + +import bw2data as bd + +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.dialogs import UncertaintyDialog + + +class ExchangeUncertaintyModify(ABAction): + """ + ABAction to open the UncertaintyWizard for an exchange + """ + + icon = qicons.edit + text = "Modify uncertainty" + + @staticmethod + @exception_dialogs + def run(exchanges: List[bd.Edge]): + + ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( + parent=app.main_window, + initial=exchanges[0].uncertainty, + ) + + if not ok: + return + + for exchange in exchanges: + for key, value in uc_dict.items(): + exchange[key] = value + exchange.save() diff --git a/activity_browser/actions/exchange/exchange_uncertainty_remove.py b/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py similarity index 88% rename from activity_browser/actions/exchange/exchange_uncertainty_remove.py rename to activity_browser/app/actions/exchange/exchange_uncertainty_remove.py index db96f2625..22ecbd12c 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_remove.py +++ b/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py @@ -1,6 +1,6 @@ from typing import Any, List -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import uncertainty from activity_browser.ui.icons import qicons diff --git a/activity_browser/app/actions/metadatastore_cache_clear.py b/activity_browser/app/actions/metadatastore_cache_clear.py new file mode 100644 index 000000000..30fd619c1 --- /dev/null +++ b/activity_browser/app/actions/metadatastore_cache_clear.py @@ -0,0 +1,20 @@ +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + +import bw2data as bd + +from .project.project_switch import ProjectSwitch + + +class MetaDataStoreCacheClear(ABAction): + + icon = qicons.right + text = "Clear Metadata Store Cache" + tool_tip = "Clear the Metadata Store cache and reload the current project" + + @staticmethod + @exception_dialogs + def run(): + app.metadata.clear_cache() + ProjectSwitch.run(bd.projects.current, reload=True) diff --git a/activity_browser/app/actions/metadatastore_open.py b/activity_browser/app/actions/metadatastore_open.py new file mode 100644 index 000000000..51d6b93ca --- /dev/null +++ b/activity_browser/app/actions/metadatastore_open.py @@ -0,0 +1,21 @@ +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 + + + + +class MetaDataStoreOpen(ABAction): + + icon = qicons.right + text = "Open activity / activities" + + @staticmethod + @global_shortcut("Ctrl+Shift+M") + @exception_dialogs + def run(): + from activity_browser.app import pages + page = pages.MetaDataStorePage() + central = app.main_window.centralWidget() + central.addToGroup("DEBUG", page) diff --git a/activity_browser/actions/method/cf_amount_modify.py b/activity_browser/app/actions/method/cf_amount_modify.py similarity index 92% rename from activity_browser/actions/method/cf_amount_modify.py rename to activity_browser/app/actions/method/cf_amount_modify.py index 8db541b5c..2c5b1cb22 100644 --- a/activity_browser/actions/method/cf_amount_modify.py +++ b/activity_browser/app/actions/method/cf_amount_modify.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +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 diff --git a/activity_browser/actions/method/cf_new.py b/activity_browser/app/actions/method/cf_new.py similarity index 90% rename from activity_browser/actions/method/cf_new.py rename to activity_browser/app/actions/method/cf_new.py index a49950a55..305062496 100644 --- a/activity_browser/actions/method/cf_new.py +++ b/activity_browser/app/actions/method/cf_new.py @@ -2,8 +2,8 @@ from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 @@ -28,7 +28,7 @@ def run(method_name: tuple, keys: List[tuple]): # if there are non-unique keys warn the user that these won't be added if len(unique_keys) < len(keys): QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Duplicate characterization factors", "One or more of these elementary flows already exist within this method. Duplicate flows will not be " "added", diff --git a/activity_browser/actions/method/cf_remove.py b/activity_browser/app/actions/method/cf_remove.py similarity index 89% rename from activity_browser/actions/method/cf_remove.py rename to activity_browser/app/actions/method/cf_remove.py index 8ba90820e..5b79b7a83 100644 --- a/activity_browser/actions/method/cf_remove.py +++ b/activity_browser/app/actions/method/cf_remove.py @@ -2,8 +2,8 @@ from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 @@ -22,7 +22,7 @@ class CFRemove(ABAction): def run(method_name: tuple, char_factors: List[tuple]): # ask the user whether they are sure to delete the calculation setup warning = QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Deleting Characterization Factors", f"Are you sure you want to delete {len(char_factors)} CF('s)?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, diff --git a/activity_browser/app/actions/method/cf_uncertainty_modify.py b/activity_browser/app/actions/method/cf_uncertainty_modify.py new file mode 100644 index 000000000..2670650bd --- /dev/null +++ b/activity_browser/app/actions/method/cf_uncertainty_modify.py @@ -0,0 +1,47 @@ +from functools import partial +from typing import List + +from activity_browser import app +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 activity_browser.ui.dialogs import UncertaintyDialog + + +class CFUncertaintyModify(ABAction): + """ + ABAction to launch the UncertaintyDialog for Characterization Factor and handles the output by writing the + uncertainty data using the ImpactCategoryController to the Characterization Factor in question. + """ + + icon = qicons.edit + text = "Modify uncertainty" + + @classmethod + @exception_dialogs + def run(cls, method_name: tuple, char_factors: List[tuple], uncertainty_dict: dict = None): + + 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(uncertainty_dict) + method_dict[cf[0]] = cf[1] + else: + uncertainty_dict["amount"] = cf[1] + method_dict[cf[0]] = uncertainty_dict + + method.write(list(method_dict.items())) diff --git a/activity_browser/actions/method/cf_uncertainty_remove.py b/activity_browser/app/actions/method/cf_uncertainty_remove.py similarity index 94% rename from activity_browser/actions/method/cf_uncertainty_remove.py rename to activity_browser/app/actions/method/cf_uncertainty_remove.py index 7389d8011..c26a4ad6d 100644 --- a/activity_browser/actions/method/cf_uncertainty_remove.py +++ b/activity_browser/app/actions/method/cf_uncertainty_remove.py @@ -1,6 +1,6 @@ from typing import List -from activity_browser.actions.base import ABAction, exception_dialogs +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 diff --git a/activity_browser/actions/method/importer/method_importer_bw2io.py b/activity_browser/app/actions/method/importer/method_importer_bw2io.py similarity index 88% rename from activity_browser/actions/method/importer/method_importer_bw2io.py rename to activity_browser/app/actions/method/importer/method_importer_bw2io.py index 5c13de8b6..cc2fc9c29 100644 --- a/activity_browser/actions/method/importer/method_importer_bw2io.py +++ b/activity_browser/app/actions/method/importer/method_importer_bw2io.py @@ -1,17 +1,17 @@ import os.path -from logging import getLogger +from loguru import logger from qtpy.QtCore import Signal, SignalInstance -from activity_browser import application -from activity_browser.actions.base import exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import exception_dialogs from activity_browser.ui import icons, widgets from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter from activity_browser.ui.core import threading from .method_importer_ecoinvent import ExtractExcelThread, MethodImporterEcoinvent -log = getLogger(__name__) + class MethodImporterBW2IO(MethodImporterEcoinvent): @@ -25,7 +25,7 @@ class MethodImporterBW2IO(MethodImporterEcoinvent): @exception_dialogs def run(cls): # initialize the import thread, setting needed attributes - extract_thread = ExtractMethodsThread(application) + extract_thread = ExtractMethodsThread(app.application) extract_thread.loaded.connect(cls.write_database) # show progress dialog for importing the excel diff --git a/activity_browser/actions/method/importer/method_importer_ecoinvent.py b/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py similarity index 92% rename from activity_browser/actions/method/importer/method_importer_ecoinvent.py rename to activity_browser/app/actions/method/importer/method_importer_ecoinvent.py index c9d3c151e..a847e3b1b 100644 --- a/activity_browser/actions/method/importer/method_importer_ecoinvent.py +++ b/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py @@ -1,16 +1,16 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore from qtpy.QtCore import Signal, SignalInstance -from activity_browser import application +from activity_browser import app from activity_browser.mod import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons, widgets from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter from activity_browser.ui.core import threading -log = getLogger(__name__) + class MethodImporterEcoinvent(ABAction): @@ -25,7 +25,7 @@ class MethodImporterEcoinvent(ABAction): def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose ecoinvent methods excel to import', filter='excel spreadsheet (*.xlsx);; All files (*.*)' ) @@ -33,7 +33,7 @@ def run(cls): return # initialize the import thread, setting needed attributes - extract_thread = ExtractExcelThread(application) + extract_thread = ExtractExcelThread(app.application) extract_thread.path = path extract_thread.loaded.connect(cls.write_database) @@ -46,12 +46,12 @@ def run(cls): @staticmethod def write_database(importer: EcoinventLCIAImporter): # show the import setup dialog - import_dialog = ImportSetupDialog(importer, application.main_window) + import_dialog = ImportSetupDialog(importer, app.main_window) if import_dialog.exec_() == QtWidgets.QDialog.Rejected: return # setup the importer thread - importer_thread = ImportExcelThread(application) + importer_thread = ImportExcelThread(app.application) importer_thread.importer = importer importer_thread.biosphere_name = import_dialog.biosphere_name importer_thread.prepend = import_dialog.prepend diff --git a/activity_browser/actions/method/method_delete.py b/activity_browser/app/actions/method/method_delete.py similarity index 88% rename from activity_browser/actions/method/method_delete.py rename to activity_browser/app/actions/method/method_delete.py index c46a2c08d..dac6cd4b0 100644 --- a/activity_browser/actions/method/method_delete.py +++ b/activity_browser/app/actions/method/method_delete.py @@ -1,15 +1,15 @@ from os import name from typing import List -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 -log = getLogger(__name__) + class MethodDelete(ABAction): @@ -43,7 +43,7 @@ def run(methods: List[tuple]): # warn the user about the pending deletion warning = QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Deleting Method", warning_text, QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, @@ -59,7 +59,7 @@ def run(methods: List[tuple]): # delete all methods by deregistering them for method in all_methods: method.deregister() - log.info(f"Deleted method {method.name}") + logger.info(f"Deleted method {method.name}") # remove deleted methods from all calculation setups MethodDelete.remove_methods_from_calculation_setups(to_remove) @@ -82,7 +82,7 @@ def remove_methods_from_calculation_setups(method_names: set[tuple]) -> None: ia.remove(name) changed_any = True - log.info( + logger.info( f"Updated calculation setup '{cs_name}': removed impact category {name}" ) @@ -90,4 +90,4 @@ def remove_methods_from_calculation_setups(method_names: set[tuple]) -> None: if changed_any: bd.calculation_setups.serialize() except Exception: - log.exception("Failed to update calculation setups after method rename") + logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/actions/method/method_duplicate.py b/activity_browser/app/actions/method/method_duplicate.py similarity index 94% rename from activity_browser/actions/method/method_duplicate.py rename to activity_browser/app/actions/method/method_duplicate.py index f59ee5d51..fb63a406a 100644 --- a/activity_browser/actions/method/method_duplicate.py +++ b/activity_browser/app/actions/method/method_duplicate.py @@ -1,16 +1,16 @@ from typing import List -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 .method_open import MethodOpen -log = getLogger(__name__) + class MethodDuplicate(ABAction): @@ -39,7 +39,7 @@ def run(methods: List[tuple], level: str = None): # retrieve the new name(s) from the user and return if canceled dialog = TupleNameDialog.get_combined_name( - application.main_window, + app.main_window, "Impact category name", "Combined name:", selected_method, @@ -57,7 +57,7 @@ def run(methods: List[tuple], level: str = None): if new_name in methods: raise Exception("New method name already in use") method.copy(new_name) - log.info(f"Copied method {method.name} into {new_name}") + logger.info(f"Copied method {method.name} into {new_name}") MethodOpen.run(new_names) diff --git a/activity_browser/actions/method/method_meta_modify.py b/activity_browser/app/actions/method/method_meta_modify.py similarity index 59% rename from activity_browser/actions/method/method_meta_modify.py rename to activity_browser/app/actions/method/method_meta_modify.py index d62d6e35b..f917f82ff 100644 --- a/activity_browser/actions/method/method_meta_modify.py +++ b/activity_browser/app/actions/method/method_meta_modify.py @@ -1,14 +1,10 @@ -from typing import List -from logging import getLogger +from loguru import logger -from qtpy import QtWidgets - -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +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 -log = getLogger(__name__) + class MethodMetaModify(ABAction): @@ -22,7 +18,7 @@ class MethodMetaModify(ABAction): @exception_dialogs def run(method_name: tuple[str], key: str, value: str): if method_name not in bd.methods: - log.warning(f"Can't modify metadata for method {method_name} - method not found") + logger.warning(f"Can't modify metadata for method {method_name} - method not found") return bd.methods[method_name][key] = value diff --git a/activity_browser/actions/method/method_new.py b/activity_browser/app/actions/method/method_new.py similarity index 78% rename from activity_browser/actions/method/method_new.py rename to activity_browser/app/actions/method/method_new.py index ed6d6ee24..8fa03e688 100644 --- a/activity_browser/actions/method/method_new.py +++ b/activity_browser/app/actions/method/method_new.py @@ -1,16 +1,12 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 activity_browser.ui import widgets - -from .method_open import MethodOpen - -log = getLogger(__name__) +from activity_browser.ui import dialogs class MethodNew(ABAction): @@ -37,7 +33,7 @@ class MethodNew(ABAction): @exception_dialogs def run(): # Open dialog to get new method name - dialog = widgets.ABListEditDialog(("New Impact Category",), parent=application.main_window) + dialog = dialogs.ABListEditDialog(("New Impact Category",), parent=app.main_window) dialog.setWindowTitle("New Impact Category") if dialog.exec_() != QtWidgets.QDialog.Accepted: @@ -48,7 +44,7 @@ def run(): # Validate new name if len(new_name) == 0: QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Invalid Name", "Impact category name cannot be empty.", ) @@ -56,7 +52,7 @@ def run(): if new_name in bd.methods: QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Name Already Exists", f"An impact category with the name '{' | '.join(new_name)}' already exists.", ) @@ -67,13 +63,13 @@ def run(): method.register() method.write([]) # Write empty list of characterization factors - log.info(f"Created new impact category: {new_name}") + logger.info(f"Created new impact category: {new_name}") # Open the method in the ImpactCategoryDetails page - from activity_browser.layouts import pages + from activity_browser.app import pages page = pages.ImpactCategoryDetailsPage(new_name) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() central.addToGroup("Characterization Factors", page) # Set the page to edit mode diff --git a/activity_browser/actions/method/method_open.py b/activity_browser/app/actions/method/method_open.py similarity index 69% rename from activity_browser/actions/method/method_open.py rename to activity_browser/app/actions/method/method_open.py index a0580d8d7..c6f9cc49d 100644 --- a/activity_browser/actions/method/method_open.py +++ b/activity_browser/app/actions/method/method_open.py @@ -1,9 +1,7 @@ from typing import List -from qtpy import QtWidgets, QtCore - -from activity_browser import signals, application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -20,11 +18,11 @@ class MethodOpen(ABAction): @staticmethod @exception_dialogs def run(method_names: List[tuple]): - from activity_browser.layouts import pages + from activity_browser.app import pages for name in method_names: page = pages.ImpactCategoryDetailsPage(name) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() central.addToGroup("Characterization Factors", page) diff --git a/activity_browser/actions/method/method_rename.py b/activity_browser/app/actions/method/method_rename.py similarity index 87% rename from activity_browser/actions/method/method_rename.py rename to activity_browser/app/actions/method/method_rename.py index 5e2705166..7942dd8a6 100644 --- a/activity_browser/actions/method/method_rename.py +++ b/activity_browser/app/actions/method/method_rename.py @@ -1,15 +1,15 @@ from typing import List -from logging import getLogger +from loguru import logger from qtpy import QtWidgets import bw2data as bd -from activity_browser import application, signals -from activity_browser.ui import widgets -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.ui import dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs + -log = getLogger(__name__) class MethodRename(ABAction): @@ -47,10 +47,10 @@ def run(method_name: tuple[str] | list[tuple[str]]): method = bd.Method(method_name) # open dialog to get new name - dialog = widgets.ABListEditDialog( + dialog = dialogs.ABListEditDialog( method_name, title="Rename Impact Category", - parent=application.main_window, + parent=app.main_window, ) # execute the dialog and check for acceptance @@ -77,7 +77,7 @@ def run(method_name: tuple[str] | list[tuple[str]]): # this should not happen like this, as the model and therefore signals should be handled declaritavely, # but since method renaming is not native to bw2data we have to do it manually here - signals.method.renamed.emit(method_name, new_name) + app.signals.method.renamed.emit(method_name, new_name) # deregister old method method.deregister() @@ -102,11 +102,11 @@ def rename_method_in_calculation_setups(old_name: tuple, new_name: tuple) -> Non ia[i] = new_name changed_any = True - log.info( + logger.info( f"Updated calculation setup '{cs_name}': renamed impact category {old_name} -> {new_name}" ) if changed_any: bd.calculation_setups.serialize() except Exception: - log.exception("Failed to update calculation setups after method rename") + logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/actions/migrations_install.py b/activity_browser/app/actions/migrations_install.py similarity index 79% rename from activity_browser/actions/migrations_install.py rename to activity_browser/app/actions/migrations_install.py index 32f1d8c7c..753445abb 100644 --- a/activity_browser/actions/migrations_install.py +++ b/activity_browser/app/actions/migrations_install.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons from activity_browser.mod.bw2io.migrations import ab_create_core_migrations from activity_browser.ui.core import threading @@ -23,12 +23,12 @@ def update_dialog_slot(progress: int, label: str): dialog.setLabelText(label) - dialog = QtWidgets.QProgressDialog(application.main_window) + dialog = QtWidgets.QProgressDialog(app.main_window) dialog.setWindowTitle("Installing migrations") dialog.setMaximum(100) dialog.setCancelButton(None) - thread = MigrationsInstallThread(application) + thread = MigrationsInstallThread(app.application) thread.status.connect(update_dialog_slot) 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/actions/parameter/parameter_clear_broken.py b/activity_browser/app/actions/parameter/parameter_clear_broken.py similarity index 94% rename from activity_browser/actions/parameter/parameter_clear_broken.py rename to activity_browser/app/actions/parameter/parameter_clear_broken.py index 4c7b1879e..f020bb355 100644 --- a/activity_browser/actions/parameter/parameter_clear_broken.py +++ b/activity_browser/app/actions/parameter/parameter_clear_broken.py @@ -1,6 +1,6 @@ from typing import Any -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from bw2data.parameters import (ActivityParameter, Group, GroupDependency, parameters) diff --git a/activity_browser/app/actions/parameter/parameter_delete.py b/activity_browser/app/actions/parameter/parameter_delete.py new file mode 100644 index 000000000..d29ae2297 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_delete.py @@ -0,0 +1,67 @@ +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 ParameterDelete(ABAction): + """ + ABAction to delete an existing parameter. + """ + + icon = qicons.delete + text = "Delete parameter..." + + @staticmethod + @exception_dialogs + 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() + ) + + 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: + parameter.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/parameter/parameter_group_delete.py b/activity_browser/app/actions/parameter/parameter_group_delete.py new file mode 100644 index 000000000..b438b747a --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_group_delete.py @@ -0,0 +1,50 @@ +from loguru import logger + +import bw2data as bd +from bw2data.parameters import (ActivityParameter, Group, + GroupDependency, + parameters) + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +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: + if group in ["project"] + list(bd.databases): + logger.warning(f"Cannot delete built-in parameter group '{group}'. Skipping.") + continue + + 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/actions/parameter/parameter_modify.py b/activity_browser/app/actions/parameter/parameter_modify.py similarity index 83% rename from activity_browser/actions/parameter/parameter_modify.py rename to activity_browser/app/actions/parameter/parameter_modify.py index ea29800eb..85528c687 100644 --- a/activity_browser/actions/parameter/parameter_modify.py +++ b/activity_browser/app/actions/parameter/parameter_modify.py @@ -1,16 +1,17 @@ -from logging import getLogger +from loguru import logger import bw2data as bd from bw2data.parameters import ParameterBase, parameters, ActivityParameter, Group, GroupDependency from peewee import DoesNotExist from activity_browser.ui.icons import qicons -from activity_browser.bwutils import refresh_parameter, Parameter -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.commontasks import refresh_parameter +from activity_browser.bwutils.utils import Parameter +from activity_browser.app.actions.base import ABAction, exception_dialogs from .parameter_rename import ParameterRename -log = getLogger(__name__) + class ParameterModify(ABAction): @@ -51,6 +52,6 @@ def fix_broken_groups(): try: ActivityParameter._static_dependencies(group.name) except DoesNotExist: - log.warning(f"Removing broken parameter group {group.name}") + logger.warning(f"Removing broken parameter group {group.name}") GroupDependency.get(GroupDependency.group == group.name).delete_instance() group.delete_instance() diff --git a/activity_browser/actions/parameter/parameter_new.py b/activity_browser/app/actions/parameter/parameter_new.py similarity index 96% rename from activity_browser/actions/parameter/parameter_new.py rename to activity_browser/app/actions/parameter/parameter_new.py index 7aaf132a8..fea852592 100644 --- a/activity_browser/actions/parameter/parameter_new.py +++ b/activity_browser/app/actions/parameter/parameter_new.py @@ -2,8 +2,8 @@ from qtpy import QtCore, QtGui, QtWidgets -from activity_browser import actions, application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks as bc from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter @@ -35,7 +35,7 @@ class ParameterNew(ABAction): @exception_dialogs def run(activity_key: Tuple[str, str]): # instantiate the ParameterWizard - wizard = ParameterWizard(activity_key, application.main_window) + wizard = ParameterWizard(activity_key, app.main_window) # return if the wizard is canceled if wizard.exec_() != QtWidgets.QWizard.Accepted: @@ -99,7 +99,7 @@ def _get_group(self): ) if not ActivityParameter.select().where(query).count(): - actions.ParameterNewAutomatic.run([self.key]) + app.actions.ParameterNewAutomatic.run([self.key]) return ActivityParameter.get(query).group diff --git a/activity_browser/actions/parameter/parameter_new_automatic.py b/activity_browser/app/actions/parameter/parameter_new_automatic.py similarity index 88% rename from activity_browser/actions/parameter/parameter_new_automatic.py rename to activity_browser/app/actions/parameter/parameter_new_automatic.py index bc7ac671c..4fd69599d 100644 --- a/activity_browser/actions/parameter/parameter_new_automatic.py +++ b/activity_browser/app/actions/parameter/parameter_new_automatic.py @@ -3,9 +3,9 @@ from peewee import IntegrityError from qtpy import QtWidgets -from activity_browser import application -from activity_browser.bwutils import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter from activity_browser.ui.icons import qicons @@ -31,7 +31,7 @@ def run(activities: List[tuple | int | bd.Node]): if act.get("type", "process") not in bd.labels.lci_node_types: issue = f"Activity must be 'process' type, '{act.get('name')}' is type '{act.get('type')}'." QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Not allowed", issue, QtWidgets.QMessageBox.Ok, diff --git a/activity_browser/actions/parameter/parameter_new_from_parameter.py b/activity_browser/app/actions/parameter/parameter_new_from_parameter.py similarity index 94% rename from activity_browser/actions/parameter/parameter_new_from_parameter.py rename to activity_browser/app/actions/parameter/parameter_new_from_parameter.py index fcbb1ac14..0ba0757b0 100644 --- a/activity_browser/actions/parameter/parameter_new_from_parameter.py +++ b/activity_browser/app/actions/parameter/parameter_new_from_parameter.py @@ -1,7 +1,7 @@ from ast import literal_eval -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import Parameter +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.utils import Parameter from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, parameters from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/parameter/parameter_rename.py b/activity_browser/app/actions/parameter/parameter_rename.py similarity index 84% rename from activity_browser/actions/parameter/parameter_rename.py rename to activity_browser/app/actions/parameter/parameter_rename.py index 8328bf024..8fa768498 100644 --- a/activity_browser/actions/parameter/parameter_rename.py +++ b/activity_browser/app/actions/parameter/parameter_rename.py @@ -2,10 +2,11 @@ from bw2data.parameters import ParameterBase, parameters -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +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.bwutils import Parameter, refresh_parameter +from activity_browser.bwutils.utils import Parameter +from activity_browser.bwutils.commontasks import refresh_parameter class ParameterRename(ABAction): @@ -38,7 +39,7 @@ def run(parameter: tuple | Parameter | ParameterBase, new_name: str = None): @staticmethod def get_new_name(parameter: Parameter): new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Rename parameter", f"Rename parameter '{parameter.name}' to:", ) diff --git a/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py b/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py new file mode 100644 index 000000000..ea8ba7e87 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py @@ -0,0 +1,36 @@ +from typing import Any + +import bw2data as bd + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.ui.dialogs import UncertaintyDialog +from activity_browser.ui.icons import qicons + + +class ParameterUncertaintyModify(ABAction): + """ + ABAction to modify the uncertainty of an existing parameter. + """ + + icon = qicons.edit + text = "Modify parameter uncertainty" + + @staticmethod + @exception_dialogs + def run(parameter: Any, uncertainty_dict: dict=None) -> None: + + if not uncertainty_dict: + initial = parameter.dict.copy() if "uncertainty type" in parameter.dict else None + + ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( + parent=app.main_window, + initial=initial, + ) + + if not ok: + return + + parameter.data.update(uncertainty_dict) + parameter.save() + bd.parameters.recalculate() diff --git a/activity_browser/actions/parameter/parameter_uncertainty_remove.py b/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py similarity index 88% rename from activity_browser/actions/parameter/parameter_uncertainty_remove.py rename to activity_browser/app/actions/parameter/parameter_uncertainty_remove.py index 2ffe04ebd..7210bc8d2 100644 --- a/activity_browser/actions/parameter/parameter_uncertainty_remove.py +++ b/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py @@ -1,6 +1,6 @@ from typing import Any -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import uncertainty from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/project/project_create_template.py b/activity_browser/app/actions/project/project_create_template.py similarity index 81% rename from activity_browser/actions/project/project_create_template.py rename to activity_browser/app/actions/project/project_create_template.py index a32dbeb92..937767f4b 100644 --- a/activity_browser/actions/project/project_create_template.py +++ b/activity_browser/app/actions/project/project_create_template.py @@ -1,17 +1,17 @@ import os import json import tarfile -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore import platformdirs -from activity_browser import application +from activity_browser import app from activity_browser.mod import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread -log = getLogger(__name__) + class ProjectCreateTemplate(ABAction): @@ -19,8 +19,8 @@ class ProjectCreateTemplate(ABAction): ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to package the project and save it there. Saving code copied from bw2data.backup. """ - icon = application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) - text = "Create template for project" + icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + text = "Create template from project" tool_tip = "Export project to file" @staticmethod @@ -32,7 +32,7 @@ def run(project_name: str = None, parent=None): # get target path from the user template_name, ok = QtWidgets.QInputDialog.getText( - parent if parent else application.main_window, + parent if parent else app.main_window, "Create template from project", f"Creating new template from project ({project_name}):" + " " * 10, @@ -49,7 +49,7 @@ def run(project_name: str = None, parent=None): if os.path.exists(template_path): QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A template with this name already exists.", ) @@ -57,7 +57,7 @@ def run(project_name: str = None, parent=None): # setup dialog progress = QtWidgets.QProgressDialog( - parent=parent if parent else application.main_window, + parent=parent if parent else app.main_window, labelText="Creating template", maximum=0 ) @@ -69,7 +69,7 @@ def run(project_name: str = None, parent=None): progress.resize(400, 100) progress.show() - thread = TemplateThread(application) + thread = TemplateThread(app.application) setattr(thread, "save_path", template_path) setattr(thread, "project_name", project_name) thread.finished.connect(lambda: progress.deleteLater()) @@ -86,8 +86,8 @@ def run_safely(self): with open(os.path.join(project_dir, ".project-name.json"), "w") as f: json.dump({"name": self.project_name}, f) - log.info("Creating project template - this could take a few minutes...") + logger.info("Creating project template - this could take a few minutes...") with tarfile.open(self.save_path, "w:gz") as tar: tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) - log.info(f"Created template from `{self.project_name}`.") + logger.info(f"Created template from `{self.project_name}`.") diff --git a/activity_browser/actions/project/project_delete.py b/activity_browser/app/actions/project/project_delete.py similarity index 88% rename from activity_browser/actions/project/project_delete.py rename to activity_browser/app/actions/project/project_delete.py index b87ab3598..3552f3825 100644 --- a/activity_browser/actions/project/project_delete.py +++ b/activity_browser/app/actions/project/project_delete.py @@ -6,8 +6,8 @@ from bw2data.project import ProjectDataset from bw2data.utils import safe_filename -from activity_browser import settings, application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from .project_switch import ProjectSwitch @@ -45,7 +45,7 @@ class ProjectDelete(ABAction): @staticmethod @exception_dialogs - def run(project_names: [str] = None): + def run(project_names: list[str] = None): if project_names is None: # get the current project project_names = [bd.projects.current] @@ -54,16 +54,16 @@ def run(project_names: [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( - application.main_window, + app.main_window, "Not possible", "Can't delete the startup project. Please select another startup project in the settings first.", ) return # open a delete dialog for the user to confirm, return if user rejects - delete_dialog = ProjectDeletionDialog(project_names, application.main_window) + delete_dialog = ProjectDeletionDialog(project_names, app.main_window) if delete_dialog.exec_() != ProjectDeletionDialog.Accepted: return @@ -76,7 +76,7 @@ def run(project_names: [str] = None): # inform the user of successful deletion QtWidgets.QMessageBox.information( - application.main_window, "Project(s) deleted", "Project(s) successfully deleted" + app.main_window, "Project(s) deleted", "Project(s) successfully deleted" ) @staticmethod @@ -91,10 +91,13 @@ def delete_project(name: str, delete_dir: bool): ds.delete_instance() + # THIS SHOULD NOT HAPPEN HERE BUT bw2data HAS NO SIGNALS FOR PROJECT DELETION + app.signals.project.deleted.emit(name) + class ProjectDeletionDialog(QtWidgets.QDialog): - def __init__(self, projects: [str], parent=None): + def __init__(self, projects: list[str], parent=None): super().__init__(parent) self.title = "Confirm project deletion" diff --git a/activity_browser/actions/project/project_duplicate.py b/activity_browser/app/actions/project/project_duplicate.py similarity index 92% rename from activity_browser/actions/project/project_duplicate.py rename to activity_browser/app/actions/project/project_duplicate.py index d22b19542..c6701491b 100644 --- a/activity_browser/actions/project/project_duplicate.py +++ b/activity_browser/app/actions/project/project_duplicate.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 @@ -42,7 +42,7 @@ def run(name: str = None): name = bd.projects.current new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Duplicate current project", f"Duplicate project ({name}) to new name:" + " " * 10, @@ -53,7 +53,7 @@ def run(name: str = None): if new_name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) diff --git a/activity_browser/actions/project/project_export.py b/activity_browser/app/actions/project/project_export.py similarity index 82% rename from activity_browser/actions/project/project_export.py rename to activity_browser/app/actions/project/project_export.py index 96f27663a..8b9a83f28 100644 --- a/activity_browser/actions/project/project_export.py +++ b/activity_browser/app/actions/project/project_export.py @@ -1,18 +1,18 @@ import os import json import tarfile -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore import bw2data as bd from bw2data.project import ProjectDataset -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread -log = getLogger(__name__) + class ProjectExport(ABAction): @@ -20,7 +20,7 @@ class ProjectExport(ABAction): ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to package the project and save it there. Saving code copied from bw2data.backup. """ - icon = application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) text = "&Export this project..." tool_tip = "Export project to file" @@ -33,7 +33,7 @@ def run(project_name: str = None): # get target path from the user save_path, save_type = QtWidgets.QFileDialog.getSaveFileName( - parent=application.main_window, + parent=app.main_window, caption="Choose where", dir=os.path.expanduser(f"~/{project_name}.tar.gz"), filter="Tar-file (*.tar.gz)" @@ -43,7 +43,7 @@ def run(project_name: str = None): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Exporting project", maximum=0 ) @@ -55,7 +55,7 @@ def run(project_name: str = None): progress.resize(400, 100) progress.show() - thread = ExportThread(application) + thread = ExportThread(app.application) setattr(thread, "save_path", save_path) setattr(thread, "project_name", project_name) thread.finished.connect(lambda: progress.deleteLater()) @@ -74,8 +74,8 @@ def run_safely(self): with open(os.path.join(project_dir, ".project-name.json"), "w") as f: json.dump({"name": self.project_name}, f) - log.info("Creating project backup archive - this could take a few minutes...") + logger.info("Creating project backup archive - this could take a few minutes...") with tarfile.open(self.save_path, "w:gz") as tar: tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) - log.info(f"Project `{self.project_name}` exported.") + logger.info(f"Project `{self.project_name}` exported.") diff --git a/activity_browser/actions/project/project_import.py b/activity_browser/app/actions/project/project_import.py similarity index 88% rename from activity_browser/actions/project/project_import.py rename to activity_browser/app/actions/project/project_import.py index b1b840e50..002797b9a 100644 --- a/activity_browser/actions/project/project_import.py +++ b/activity_browser/app/actions/project/project_import.py @@ -1,19 +1,19 @@ import codecs import json import tarfile -from logging import getLogger +from loguru import logger import bw2data as bd from qtpy import QtWidgets, QtCore from bw2io import backup -from activity_browser import application +from activity_browser import app from activity_browser.mod import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread -log = getLogger(__name__) + class ProjectImport(ABAction): @@ -34,7 +34,7 @@ def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose project file to import', filter='Tar archive (*.tar.gz);; All files (*.*)' ) @@ -46,7 +46,7 @@ def run(cls): # get a new project name from the user: while True: project_name, _ = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, 'Choose project name', 'Choose a name for your project', text=suggestion @@ -57,7 +57,7 @@ def run(cls): if project_name in bd.projects: # this name already exists, inform user and ask again. QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists." ) @@ -65,7 +65,7 @@ def run(cls): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Importing project", maximum=0 ) @@ -78,7 +78,7 @@ def run(cls): progress.show() # setup the import - thread = ImportThread(application) + thread = ImportThread(app.application) setattr(thread, "path", path) setattr(thread, "project_name", project_name) @@ -102,7 +102,7 @@ def get_project_name(fp): class ImportThread(ABThread): def run_safely(self): - log.debug('Starting project import:' + logger.debug('Starting project import:' f'\nPATH: {self.path}' f'\nNAME: {self.project_name}') backup.restore_project_directory(fp=self.path, project_name=self.project_name) @@ -112,5 +112,5 @@ def run_safely(self): ds.full_hash = False ds.save() - log.info(f"Project `{self.project_name}` imported.") + logger.info(f"Project `{self.project_name}` imported.") diff --git a/activity_browser/actions/project/project_local_import.py b/activity_browser/app/actions/project/project_local_import.py similarity index 98% rename from activity_browser/actions/project/project_local_import.py rename to activity_browser/app/actions/project/project_local_import.py index 20304bb66..b4029806f 100644 --- a/activity_browser/actions/project/project_local_import.py +++ b/activity_browser/app/actions/project/project_local_import.py @@ -1,15 +1,15 @@ import json from tarfile import open as tar_open, TarFile, TarError -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore from bw2io import restore_project_directory -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui import icons, widgets -log = getLogger(__name__) + class ProjectLocalImportWindow(QtWidgets.QDialog): @@ -237,7 +237,7 @@ def _import_project(self): original_name = self._selected_project_name() new_name = self._project_name() if original_name and new_name: - log.info(f"Importing project with name {new_name} " + logger.info(f"Importing project with name {new_name} " f"(original name {original_name})") self.import_button.setText("Creating project...") self.import_button.setEnabled(False) @@ -254,7 +254,7 @@ def _import_project(self): self.setCursor(QtCore.Qt.ArrowCursor) self.accept() else: - log.error( + logger.error( f"Project name ({new_name}) or " f"import name ({original_name}) is not valid." ) diff --git a/activity_browser/actions/project/project_manager_open.py b/activity_browser/app/actions/project/project_manager_open.py similarity index 55% rename from activity_browser/actions/project/project_manager_open.py rename to activity_browser/app/actions/project/project_manager_open.py index 2d70a4e66..5bada520f 100644 --- a/activity_browser/actions/project/project_manager_open.py +++ b/activity_browser/app/actions/project/project_manager_open.py @@ -1,7 +1,7 @@ from qtpy import QtCore -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -18,9 +18,9 @@ class ProjectManagerOpen(ABAction): @staticmethod @exception_dialogs def run(): - from activity_browser.layouts.panes import ProjectManagerPane + from activity_browser.app.panes import ProjectManagerPane - project_manager = ProjectManagerPane(application.main_window) - application.main_window.addDockWidget( + project_manager = ProjectManagerPane(app.main_window) + app.main_window.addDockWidget( QtCore.Qt.LeftDockWidgetArea, - project_manager.getDockWidget(application.main_window)) + project_manager.getDockWidget(app.main_window)) diff --git a/activity_browser/actions/project/project_migrate25.py b/activity_browser/app/actions/project/project_migrate25.py similarity index 85% rename from activity_browser/actions/project/project_migrate25.py rename to activity_browser/app/actions/project/project_migrate25.py index a522c70ea..209bd4364 100644 --- a/activity_browser/actions/project/project_migrate25.py +++ b/activity_browser/app/actions/project/project_migrate25.py @@ -1,17 +1,16 @@ from tqdm import tqdm -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtGui, QtCore import bw2data as bd import pandas as pd -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import AB_metadata +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.threading import ABThread -log = getLogger(__name__) + class ProjectMigrate25(ABAction): @@ -31,7 +30,7 @@ def run(name: str = None): if name is None: name = bd.projects.current - dialog = MigrateDialog(name, application.main_window) + dialog = MigrateDialog(name, app.main_window) dialog.exec_() if dialog.result() == dialog.DialogCode.Rejected: @@ -42,7 +41,7 @@ def run(name: str = None): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Migrating project, this may take a while...", maximum=0 ) @@ -54,7 +53,7 @@ def run(name: str = None): progress.resize(400, 100) progress.show() - thread = MigrateThread(application) + thread = MigrateThread(app.application) thread.finished.connect(lambda: progress.deleteLater()) thread.start() thread.connect_progress_dialog(progress) @@ -94,7 +93,7 @@ class MigrateThread(ABThread): def run_safely(self): self.pre_process_methods() - log.info("Updating and processing all datasets in the project") + logger.info("Updating and processing all datasets in the project") bd.projects.set_current(bd.projects.current) for db_name in bd.databases: @@ -109,25 +108,25 @@ def run_safely(self): @classmethod def pre_process_methods(cls): - log.info("Pre-processing methods for migration to bw25") + logger.info("Pre-processing methods for migration to bw25") data = {m: bd.Method(m).load() for m in bd.methods} df = pd.DataFrame([(k, v[0][0], v[0][1], v[1]) for k, values in data.items() for v in values if isinstance(v[0], tuple) and len(v) == 2 and len(v[0]) == 2], columns=["method", "database", "code", "value"]) - df = df.merge(AB_metadata.dataframe["id"], left_on=["database", "code"], right_index=True) + df = df.merge(app.metadata.dataframe["id"], left_on=["database", "code"], right_index=True) - signals.method.blockSignals(True) - signals.meta.blockSignals(True) + app.signals.method.blockSignals(True) + app.signals.meta.blockSignals(True) for name in tqdm(df["method"].unique(), desc="Pre-processing methods", unit="method", total=len(df["method"].unique())): method_df = df[df["method"] == name][["id", "value"]] method_list = list(method_df.itertuples(index=False, name=None)) bd.Method(name).write(method_list, process=False) - signals.method.blockSignals(False) - signals.meta.blockSignals(False) + app.signals.method.blockSignals(False) + app.signals.meta.blockSignals(False) return @@ -139,7 +138,7 @@ def update_database_activity_types(cls, db_name: str): if not isinstance(database, bd.backends.SQLiteBackend): return - log.info(f"Updating activity types in {db_name}") + logger.info(f"Updating activity types in {db_name}") raw = database.load() for key, ds in tqdm(raw.items(), desc=f"Updating activity types in {db_name}", unit="activity", total=len(raw)): diff --git a/activity_browser/actions/project/project_new.py b/activity_browser/app/actions/project/project_new.py similarity index 87% rename from activity_browser/actions/project/project_new.py rename to activity_browser/app/actions/project/project_new.py index 0d87b8953..52ad7d26a 100644 --- a/activity_browser/actions/project/project_new.py +++ b/activity_browser/app/actions/project/project_new.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +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 @@ -29,7 +29,7 @@ class ProjectNew(ABAction): @exception_dialogs def run(): name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create new project", "Name of new project:" + " " * 25, ) @@ -39,7 +39,7 @@ def run(): if name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) diff --git a/activity_browser/actions/project/project_new_remote.py b/activity_browser/app/actions/project/project_new_remote.py similarity index 84% rename from activity_browser/actions/project/project_new_remote.py rename to activity_browser/app/actions/project/project_new_remote.py index 90eebb0d4..d61923592 100644 --- a/activity_browser/actions/project/project_new_remote.py +++ b/activity_browser/app/actions/project/project_new_remote.py @@ -2,8 +2,8 @@ import bw2data as bd -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod.bw2io import remote from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread @@ -24,7 +24,7 @@ class ProjectNewRemote(ABAction): @exception_dialogs def run(project_key: str): name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create project from remote", "Name of new project:" + " " * 25, ) @@ -34,16 +34,16 @@ def run(project_key: str): if name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) return - thread = InstallThread(application) + thread = InstallThread(app.application) thread.start(project_key, name) - dialog = MigrateDialog(application.main_window) + dialog = MigrateDialog(app.main_window) dialog.show() thread.finished.connect(dialog.close) diff --git a/activity_browser/actions/project/project_new_template.py b/activity_browser/app/actions/project/project_new_template.py similarity index 78% rename from activity_browser/actions/project/project_new_template.py rename to activity_browser/app/actions/project/project_new_template.py index b6dd52ea1..936d2d8db 100644 --- a/activity_browser/actions/project/project_new_template.py +++ b/activity_browser/app/actions/project/project_new_template.py @@ -1,16 +1,17 @@ from qtpy import QtWidgets, QtCore -from logging import getLogger +from loguru import logger import bw2data as bd from bw2io import backup -from activity_browser import application, utils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.bwutils.commontasks import get_templates +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class ProjectNewFromTemplate(ABAction): @@ -26,11 +27,11 @@ class ProjectNewFromTemplate(ABAction): @exception_dialogs def run(template_key: str): - if template_key not in utils.get_templates(): + if template_key not in get_templates(): raise ValueError(f"Template key '{template_key}' not found.") name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create project from template", "Name of new project:" + " " * 25, ) @@ -40,7 +41,7 @@ def run(template_key: str): if name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) @@ -48,7 +49,7 @@ def run(template_key: str): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Creating project from template", maximum=0 ) @@ -61,8 +62,8 @@ def run(template_key: str): progress.show() # setup the import - thread = ImportThread(application) - setattr(thread, "path", utils.get_templates()[template_key]) + thread = ImportThread(app.application) + setattr(thread, "path", get_templates()[template_key]) setattr(thread, "project_name", name) thread.finished.connect(lambda: progress.deleteLater()) @@ -77,10 +78,10 @@ class ImportThread(ABThread): project_name: str def run_safely(self): - log.debug('Creating project from template:' + logger.debug('Creating project from template:' f'\nPATH: {self.path}' f'\nNAME: {self.project_name}') backup.restore_project_directory(fp=self.path, project_name=self.project_name) - log.info(f"Project `{self.project_name}` created.") + logger.info(f"Project `{self.project_name}` created.") diff --git a/activity_browser/actions/project/project_remote_import.py b/activity_browser/app/actions/project/project_remote_import.py similarity index 97% rename from activity_browser/actions/project/project_remote_import.py rename to activity_browser/app/actions/project/project_remote_import.py index 5397e4284..924b8cc16 100644 --- a/activity_browser/actions/project/project_remote_import.py +++ b/activity_browser/app/actions/project/project_remote_import.py @@ -1,17 +1,17 @@ from typing import Any from urllib.parse import urljoin -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore from bw2io import install_project import requests -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui import icons, widgets -log = getLogger(__name__) + class CatalogueModel(QtCore.QAbstractTableModel): @@ -278,7 +278,7 @@ def _import_project(self): original_name = self._selected_project_name() new_name = self._project_name() if original_name and new_name: - log.info(f"Importing project with name {new_name} " + logger.info(f"Importing project with name {new_name} " f"(original name {original_name})") self.import_button.setText("Creating project...") self.import_button.setEnabled(False) @@ -296,7 +296,7 @@ def _import_project(self): self.setCursor(QtCore.Qt.ArrowCursor) self.accept() else: - log.error(f"Project name ({new_name}) or import name ({original_name}) is not valid.") + logger.error(f"Project name ({new_name}) or import name ({original_name}) is not valid.") diff --git a/activity_browser/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py similarity index 65% rename from activity_browser/actions/project/project_switch.py rename to activity_browser/app/actions/project/project_switch.py index 944d0b433..9e3268b59 100644 --- a/activity_browser/actions/project/project_switch.py +++ b/activity_browser/app/actions/project/project_switch.py @@ -1,16 +1,17 @@ import datetime -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore import bw2data as bd -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.core.application import global_shortcut from .project_migrate25 import ProjectMigrate25 -log = getLogger(__name__) + class ProjectSwitch(ABAction): @@ -37,43 +38,53 @@ class ProjectSwitch(ABAction): @staticmethod @exception_dialogs - def run(project_name: str): + def run(project_name: str, reload: bool = False): # compare the new to the current project name and switch to the new one if the two are not the same - if project_name == bd.projects.current: - log.debug(f"Brightway2 already selected: {project_name}") + if project_name == bd.projects.current and not reload: + logger.debug(f"Brightway2 already selected: {project_name}") return - dialog = ProjectChangeDialog(project_name, application.main_window) + dialog = ProjectChangeDialog(project_name, reload, app.main_window) dialog.show() - 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: - log.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") ProjectSwitch.set_warning_bar() - log.info(f"Brightway2 current project: {project_name}") + logger.info(f"Brightway2 current project: {project_name}") # update the last opened timestamp bd.projects.dataset.data["last_opened"] = datetime.datetime.now().isoformat() bd.projects.dataset.save() + app.application.processEvents() + dialog.close() + @staticmethod def set_warning_bar(): - application.main_window.addToolBar(ProjectWarningBar()) + app.main_window.addToolBar(ProjectWarningBar()) + + @global_shortcut("F5") + @staticmethod + def reload_project(): + ProjectSwitch.run(bd.projects.current, reload=True) class ProjectChangeDialog(QtWidgets.QDialog): - def __init__(self, project_name: str, parent=None): + def __init__(self, project_name: str, reload: bool, parent=None): super().__init__(parent, QtCore.Qt.WindowTitleHint) - self.setWindowTitle(f"Switching project") + + title = "Reloading project" if reload else "Switching project" + subtitle = f"Reloading project: {project_name}" if reload else f"Switching to project: {project_name}" + + self.setWindowTitle(title) self.setModal(True) - self.label = QtWidgets.QLabel(f"Switching to project: {project_name}", self) + self.label = QtWidgets.QLabel(subtitle, self) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.label) @@ -89,7 +100,7 @@ def __init__(self, parent=None): height = warning_label.minimumSizeHint().height() warning_icon = QtWidgets.QLabel(self) - qicon = application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) pixmap = qicon.pixmap(height, height) warning_icon.setPixmap(pixmap) @@ -100,7 +111,7 @@ def __init__(self, parent=None): self.addWidget(warning_label) self.addWidget(migrate_label) - signals.project.changed.connect(self.deleteLater) + app.signals.project.changed.connect(self.deleteLater) def contextMenuEvent(self, event): return None diff --git a/activity_browser/actions/pyside_upgrade.py b/activity_browser/app/actions/pyside_upgrade.py similarity index 93% rename from activity_browser/actions/pyside_upgrade.py rename to activity_browser/app/actions/pyside_upgrade.py index 124f056fd..1ed892b77 100644 --- a/activity_browser/actions/pyside_upgrade.py +++ b/activity_browser/app/actions/pyside_upgrade.py @@ -4,8 +4,8 @@ import subprocess import time -from activity_browser import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons from qtpy import QtWidgets @@ -36,7 +36,7 @@ def update_dialog_slot(progress: int, label: str): assert cls.in_conda(), "Not inside a Conda environment" # setup a progress dialog to show the user we're doing something - dialog = QtWidgets.QProgressDialog(application.main_window) + dialog = QtWidgets.QProgressDialog(app.main_window) dialog.setWindowTitle("Upgrading GUI back-end") dialog.setMaximum(0) dialog.setCancelButton(None) @@ -46,7 +46,7 @@ def update_dialog_slot(progress: int, label: str): lbl.setWordWrap(True) # initialize thread and connect signals - thread = PySideUpgradeThread(application) + thread = PySideUpgradeThread(app.application) thread.status.connect(update_dialog_slot) thread.exit.connect(sys.exit) 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/actions/tools/bw2io/tools_bw2io_migrations.py b/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py similarity index 84% rename from activity_browser/actions/tools/bw2io/tools_bw2io_migrations.py rename to activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py index b027a0c33..70e214027 100644 --- a/activity_browser/actions/tools/bw2io/tools_bw2io_migrations.py +++ b/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/app/dialogs/README.md b/activity_browser/app/dialogs/README.md new file mode 100644 index 000000000..95087a8d4 --- /dev/null +++ b/activity_browser/app/dialogs/README.md @@ -0,0 +1,13 @@ +# 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. Dialogs in the app directory are there because they are tightly integrated with Brightway2 or depend on the application for other reasons. + +- Generally, action specific dialogs are located alongside the corresponding action in the `actions/` directory. +- Dialogs than can be applied more widely and are not intimately tied with either actions or Brightway2 are located in the `ui/dialogs/` directory. +- Only if the above two locations are not appropriate should a dialog be placed here. + +What qualifies to be put in this directory is somewhat subjective, but the guiding principle is that these dialogs are core to the functioning of Activity Browser and are not easily reusable outside of it. \ No newline at end of file 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/widgets/database_selection_dialog.py b/activity_browser/app/dialogs/database_select_dialog.py similarity index 95% rename from activity_browser/ui/widgets/database_selection_dialog.py rename to activity_browser/app/dialogs/database_select_dialog.py index ab3c0af26..8639ef2c2 100644 --- a/activity_browser/ui/widgets/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.py b/activity_browser/app/main.py new file mode 100644 index 000000000..3c0e99629 --- /dev/null +++ b/activity_browser/app/main.py @@ -0,0 +1,282 @@ +from pathlib import Path +from loguru import logger + +from qtpy import QtCore, QtWidgets + +import bw2data as bd +from activity_browser import app +from activity_browser.ui import widgets + + +class MainWindow(QtWidgets.QMainWindow): + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + + def __init__(self, parent=None): + from activity_browser.app.menu_bar import MenuBar + + if self._initialized: + return + self._initialized = True + + super().__init__(parent) + + self.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.setWindowTitle("Activity Browser") + self.setDockNestingEnabled(True) + + # Layout: extra items outside main layout + self.menu_bar = MenuBar(self) + self.setMenuBar(self.menu_bar) + + self.central_widget = widgets.CentralTabWidget(self) + self.central_widget.setTabsClosable(True) + self.setCentralWidget(self.central_widget) + + # Initialize all base pages upfront (name -> widget instance) + self.base_pages = {} + for page_name, page_class in app.pages.base_pages.items(): + page_instance = page_class() + page_instance.setObjectName(page_name) + self.base_pages[page_name] = page_instance + + # Connect tab close signal + 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() + + self.setWindowTitle(f"Activity Browser - {bd.projects.current}") + + def sync_panes(self): + self.clearPanes() + + dws = [] + + # Iterate through the default panes and add them as dock widgets + for pane_name, pane_class in app.panes.base_panes.items(): + pane = pane_class(parent=self) + dockwidget = pane.getDockWidget(self) + dws.append(dockwidget) + + # Add the dock widget to the left dock area + self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dockwidget) + # Add the toggle view action to the menu bar + self.menu_bar.view_menu.addAction(dockwidget.toggleViewAction()) + + # Hide the dock widget if it is marked as hidden + if pane_name not in app.settings["startup"]["shown_panes"]: + dockwidget.hide() + + # Synchronize the pane + pane.sync() + + # Tabify the dock widgets for better organization + for dw in dws: + if dw == dws[0]: + continue + self.tabifyDockWidget(dws[0], dw) + + # Raise the first dock widget to the top + dws[0].raise_() + + def sync_pages(self): + """ + Synchronizes the central widget pages with the shown_pages setting. + + This method shows only those pages that are configured to be shown at startup. + Pages are pre-initialized and just added/removed from tabs. + """ + # Get shown pages from settings + shown_pages = app.settings["startup"].get("shown_pages", []) + + # Remove all pages from tabs first + while self.central_widget.count() > 0: + self.central_widget.removeTab(0) + + # Add only the pages that should be shown + for page_name in shown_pages: + if page_name in self.base_pages: + page_instance = self.base_pages[page_name] + # Base pages should show minimize button instead of close + self.central_widget.addTab(page_instance, page_name, show_minimize=True) + + def show_page(self, page_name: str): + """ + Show a page by adding it to the tabs. + + Args: + page_name: The name of the page to show + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + + # Check if page is already in tabs + index = self.central_widget.indexOf(page_widget) + if index >= 0: + # Already shown, just switch to it + self.central_widget.setCurrentIndex(index) + else: + # Add to tabs with minimize button + self.central_widget.addTab(page_widget, page_name, show_minimize=True) + self.central_widget.setCurrentWidget(page_widget) + + def hide_page(self, page_name: str): + """ + Hide a page by removing it from the tabs (but not destroying it). + + Args: + page_name: The name of the page to hide + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + index = self.central_widget.indexOf(page_widget) + if index >= 0: + self.central_widget.removeTab(index) + + def toggle_page(self, page_name: str): + """ + Toggle a page shown/hidden. + + Args: + page_name: The name of the page to toggle + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + index = self.central_widget.indexOf(page_widget) + + if index >= 0: + # Page is shown, hide it + self.hide_page(page_name) + else: + # Page is hidden, show it + self.show_page(page_name) + + def is_page_visible(self, page_name: str) -> bool: + """ + Check if a page is currently visible in the tabs. + + Args: + page_name: The name of the page to check + + Returns: + bool: True if the page is visible, False otherwise + """ + if page_name not in self.base_pages: + return False + + page_widget = self.base_pages[page_name] + return self.central_widget.indexOf(page_widget) >= 0 + + def _on_tab_close_requested(self, index: int): + """ + Handle when user clicks the close button on a tab. + For base pages, we just hide them instead of destroying them. + + Args: + index: The index of the tab to close + """ + widget = self.central_widget.widget(index) + if widget is None: + return + + # Check if this is a base page + page_name = widget.objectName() + if page_name in self.base_pages: + # Just remove from tabs, don't destroy + self.central_widget.removeTab(index) + else: + # For non-base pages, remove and destroy + self.central_widget.removeTab(index) + widget.deleteLater() + + def apply_settings(self, load=False): + + base_dir = Path(app.settings["startup"]["brightway_directory"]) + + if load or base_dir != bd.projects._base_data_dir: + project_name = app.settings["startup"]["startup_project"] + bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) + + if not bd.projects.twofive: + logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + app.actions.ProjectSwitch.set_warning_bar() + + # Apply color scheme settings + if app.settings["appearance"]["theme"] == "dark": + hint = QtCore.Qt.ColorScheme.Dark + elif app.settings["appearance"]["theme"] == "light": + hint = QtCore.Qt.ColorScheme.Light + else: + hint = QtCore.Qt.ColorScheme.Unknown + + app.application.styleHints().setColorScheme(hint) + + # apply pane tab position + position = app.settings["appearance"]["pane_tab_position"] + if position == "top": + qt_position = QtWidgets.QTabWidget.North + if position == "bottom": + qt_position = QtWidgets.QTabWidget.South + if position == "left": + qt_position = QtWidgets.QTabWidget.West + if position == "right": + qt_position = QtWidgets.QTabWidget.East + self.setTabPosition(QtCore.Qt.DockWidgetArea.AllDockWidgetAreas, qt_position) + + def connect_signals(self): + app.signals.project.changed.connect(self.sync) + app.signals.settings.changed.connect(self.apply_settings) + + def clearPanes(self): + for pane in self.panes(): + logger.debug(f"Clearing pane {pane.__class__.__name__}: {id(pane)}") + pane.deleteLater() + + 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): + self.setWindowTitle(f"Activity Browser - {bd.projects.current}") + + def dialog_on_exception(self, exception: Exception): + QtWidgets.QMessageBox.critical( + self, + f"An error occurred: {type(exception).__name__}", + f"An error occurred, check the logs for more information \n\n {str(exception)}", + QtWidgets.QMessageBox.Ok, + ) + diff --git a/activity_browser/ui/menu_bar.py b/activity_browser/app/menu_bar.py similarity index 72% rename from activity_browser/ui/menu_bar.py rename to activity_browser/app/menu_bar.py index a70eafb3d..46f25507f 100644 --- a/activity_browser/ui/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -1,13 +1,15 @@ 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 actions, signals, utils, application +from activity_browser import app +from activity_browser.bwutils.commontasks import get_templates -from .icons import qicons +from ..ui.icons import qicons class MenuBar(QtWidgets.QMenuBar): @@ -20,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): """ @@ -40,14 +49,11 @@ def __init__(self, parent=None) -> None: self.setTitle("&Project") - self.dup_proj_action = actions.ProjectDuplicate.get_QAction() - self.delete_proj_action = actions.ProjectDelete.get_QAction() - - self.import_proj_action = actions.ProjectImport.get_QAction() - self.export_proj_action = actions.ProjectExport.get_QAction() + self.dup_proj_action = app.actions.ProjectDuplicate.get_QAction() + self.delete_proj_action = app.actions.ProjectDelete.get_QAction() - self.manage_settings_action = actions.SettingsWizardOpen.get_QAction() - self.manage_projects_action = actions.ProjectManagerOpen.get_QAction() + self.import_proj_action = app.actions.ProjectImport.get_QAction() + self.export_proj_action = app.actions.ProjectExport.get_QAction() self.addMenu(ProjectSelectionMenu(self)) self.addMenu(ProjectNewMenu(self)) @@ -61,9 +67,6 @@ def __init__(self, parent=None) -> None: self.addMenu(ExportDatabaseMenu(self)) self.addSeparator() self.addMenu(ImportICMenu(self)) - self.addSeparator() - self.addAction(self.manage_settings_action) - self.addAction(self.manage_projects_action) class ProjectNewMenu(QtWidgets.QMenu): @@ -71,8 +74,8 @@ def __init__(self, parent=None) -> None: super().__init__(parent) self.setTitle("New project") - self.new_proj_action = actions.ProjectNew.get_QAction() - self.import_proj_action = actions.ProjectImport.get_QAction() + self.new_proj_action = app.actions.ProjectNew.get_QAction() + self.import_proj_action = app.actions.ProjectImport.get_QAction() self.new_proj_action.setText("Empty project") self.import_proj_action.setText("From .tar.gz file") @@ -94,14 +97,14 @@ def __init__(self, parent=None): self.actions = {} - for key in utils.get_templates(): - action = actions.ProjectNewFromTemplate.get_QAction(key) + for key in get_templates(): + action = app.actions.ProjectNewFromTemplate.get_QAction(key) action.setText(key) self.actions[key] = action self.addAction(action) for key in self.get_projects(): - action = actions.ProjectNewRemote.get_QAction(key) + action = app.actions.ProjectNewRemote.get_QAction(key) action.setText(key) self.actions[key] = action self.addAction(action) @@ -121,6 +124,28 @@ class ViewMenu(QtWidgets.QMenu): def __init__(self, parent=None) -> None: super().__init__(parent) self.setTitle("&View") + + + # Populate pages + self.page_actions = {} + for page_name in app.pages.base_pages.keys(): + action = QtWidgets.QAction(page_name, self) + action.setCheckable(True) + action.triggered.connect(lambda checked, name=page_name: app.main_window.toggle_page(name)) + # Update checked state when menu is about to show + self.page_actions[page_name] = action + self.addAction(action) + + # Update the checked state when menu is about to show + self.aboutToShow.connect(self.update_page_actions) + + self.addSeparator() + + def update_page_actions(self): + """Update the checked state of page actions based on which pages are visible.""" + for page_name, action in self.page_actions.items(): + is_visible = app.main_window.is_page_visible(page_name) + action.setChecked(is_visible) class CalculateMenu(QtWidgets.QMenu): @@ -133,37 +158,25 @@ def __init__(self, parent=None) -> None: self.setTitle("&Calculate") self.cs_actions = [] - self.new_cs_action = actions.CSNew.get_QAction() + self.new_cs_action = app.actions.CSNew.get_QAction() self.new_cs_action.setText("New setup...") self.addAction(self.new_cs_action) self.addSeparator() - signals.project.changed.connect(self.sync) - signals.meta.calculation_setups_changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) + 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 = actions.CSOpen.get_QAction(cs) + action = app.actions.CSOpen.get_QAction(cs) action.setText(cs) self.cs_actions.append(action) self.addAction(action) -# class ToolsMenu(QtWidgets.QMenu): -# """ -# Tools Menu: contains actions in regard to special tooling aspects of the AB -# """ -# -# def __init__(self, parent=None) -> None: -# super().__init__(parent) -# self.setTitle("&Tools") -# -# self.manage_plugins_action = actions.PluginWizardOpen.get_QAction() -# -# self.addAction(self.manage_plugins_action) - - class HelpMenu(QtWidgets.QMenu): """ Help Menu: contains actions that show info to the user or redirect them to online resources @@ -177,7 +190,7 @@ def __init__(self, parent=None) -> None: qicons.ab, "&About Activity Browser", self.about ) self.addAction( - "&About Qt", lambda: QtWidgets.QMessageBox.aboutQt(application.main_window) + "&About Qt", lambda: QtWidgets.QMessageBox.aboutQt(app.main_window) ) self.addAction( qicons.question, "&Get help on the wiki", self.open_wiki @@ -201,7 +214,7 @@ def about(self): """ # set up the window - about_window = QtWidgets.QMessageBox(parent=application.main_window) + about_window = QtWidgets.QMessageBox(parent=app.main_window) about_window.setWindowTitle("About the Activity Browser") about_window.setIconPixmap(qicons.ab.pixmap(QSize(150, 150))) about_window.setText(text) @@ -234,7 +247,7 @@ def __init__(self, parent=None): self.populate() self.aboutToShow.connect(self.populate) - self.triggered.connect(lambda act: actions.ProjectSwitch.run(act.data())) + self.triggered.connect(lambda act: app.actions.ProjectSwitch.run(act.data())) def populate(self): """ @@ -259,7 +272,7 @@ def populate(self): action = QtWidgets.QAction(proj.name, self) action.setData(proj.name) action.setIcon( - application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) if not bw_25 else qicons.empty) + app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) if not bw_25 else qicons.empty) self.addAction(action) @@ -270,9 +283,9 @@ def __init__(self, parent=None) -> None: self.setTitle("Import database") self.setIcon(qicons.import_db) - self.import_from_ecoinvent_action = actions.DatabaseImportFromEcoinvent.get_QAction() - self.import_from_excel_action = actions.DatabaseImporterExcel.get_QAction() - self.import_from_bw2package_action = actions.DatabaseImporterBW2Package.get_QAction() + self.import_from_ecoinvent_action = app.actions.DatabaseImportFromEcoinvent.get_QAction() + self.import_from_excel_action = app.actions.DatabaseImporterExcel.get_QAction() + self.import_from_bw2package_action = app.actions.DatabaseImporterBW2Package.get_QAction() self.import_from_ecoinvent_action.setText("ecoinvent...") self.import_from_excel_action.setText("from .xlsx") @@ -289,8 +302,8 @@ def __init__(self, parent=None) -> None: super().__init__(parent=parent) self.setTitle("Export database") - self.export_to_excel_action = actions.DatabaseExportExcel.get_QAction() - self.export_to_bw2package_action = actions.DatabaseExportBW2Package.get_QAction() + self.export_to_excel_action = app.actions.DatabaseExportExcel.get_QAction() + self.export_to_bw2package_action = app.actions.DatabaseExportBW2Package.get_QAction() self.export_to_excel_action.setText("to .xlsx") self.export_to_bw2package_action.setText("to .bw2package") @@ -308,8 +321,8 @@ def __init__(self, parent=None) -> None: self.beta_warning = QtWidgets.QWidgetAction(self) self.beta_warning.setDefaultWidget(QtWidgets.QLabel("Beta features, use at your own risk")) - self.import_from_ei_excel_action = actions.MethodImporterEcoinvent.get_QAction() - self.import_from_bw2io_action = actions.MethodImporterBW2IO.get_QAction() + self.import_from_ei_excel_action = app.actions.MethodImporterEcoinvent.get_QAction() + self.import_from_bw2io_action = app.actions.MethodImporterBW2IO.get_QAction() self.import_from_ei_excel_action.setText("from ecoinvent excel") self.import_from_bw2io_action.setText("from bw2io") diff --git a/activity_browser/app/pages/README.md b/activity_browser/app/pages/README.md new file mode 100644 index 000000000..fda37b35c --- /dev/null +++ b/activity_browser/app/pages/README.md @@ -0,0 +1,88 @@ +# 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 view page (DEBUG only) + +## Two types of pages + +1. **Base pages** - Pages that are initialized once and remain in memory (e.g., Welcome Screen, Parameters, Settings). + - They maintain their state and reload data on project switches. + - Hidden/shown based on user actions or preferences in the settings. + - Defined in `__init__.py`. +2. **Dynamic pages** - Pages that show specific data and are opened as such by the user (e.g. Activity Details, LCA results). + - Created on demand and closed when no longer needed. + - Multiple instances can exist (e.g., multiple activity detail pages) and will be grouped. + +## Development Guidelines + +When creating new pages: + +- Should follow the `PageNamePage` naming convention. +- Set a unique ObjectName for identification. +- Set appropriate tab titles using `setWindowTitle()`. + +## 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/` +[BASE PAGE] + +Manage parameters and scenarios: +- Project parameters +- Database parameters +- Activity parameters +- Parameter formulas +- Scenario management + +### `settings/` +[BASE PAGE] + +Application configuration: +- General preferences +- Project settings +- Plugin configuration +- Import/export settings diff --git a/activity_browser/layouts/pages/__init__.py b/activity_browser/app/pages/__init__.py similarity index 68% rename from activity_browser/layouts/pages/__init__.py rename to activity_browser/app/pages/__init__.py index bbded7e0a..1f360e945 100644 --- a/activity_browser/layouts/pages/__init__.py +++ b/activity_browser/app/pages/__init__.py @@ -5,3 +5,10 @@ from .lca_results import LCAResultsPage from .parameters import ParametersPage from .metadatastore import MetaDataStorePage +from .settings import SettingsPage + +base_pages = { + "Welcome": WelcomePage, + "Parameters": ParametersPage, + "Settings": SettingsPage, +} diff --git a/activity_browser/layouts/pages/activity_details/__init__.py b/activity_browser/app/pages/activity_details/__init__.py similarity index 100% rename from activity_browser/layouts/pages/activity_details/__init__.py rename to activity_browser/app/pages/activity_details/__init__.py diff --git a/activity_browser/layouts/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py similarity index 90% rename from activity_browser/layouts/pages/activity_details/activity_details.py rename to activity_browser/app/pages/activity_details/activity_details.py index 57412c1ab..5b2ae11e6 100644 --- a/activity_browser/layouts/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -1,10 +1,11 @@ -from logging import getLogger +from loguru import logger from qtpy import QtCore, QtWidgets import bw2data as bd -from activity_browser import signals, bwutils +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node_or_none from activity_browser.ui import widgets from .activity_header import ActivityHeader @@ -15,8 +16,6 @@ from .data_tab import DataTab from .consumers_tab import ConsumersTab -log = getLogger(__name__) - class ActivityDetailsPage(QtWidgets.QWidget): """ @@ -103,11 +102,11 @@ def connect_signals(self): """ Connects signals to their respective slots. """ - signals.node.deleted.connect(self.on_node_deleted) - signals.database.deleted.connect(self.on_database_deleted) - signals.meta.databases_changed.connect(self.syncLater) - signals.parameter.recalculated.connect(self.syncLater) - signals.node.changed.connect(self.syncLater) + app.signals.node.deleted.connect(self.on_node_deleted) + app.signals.database.deleted.connect(self.on_database_deleted) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.node.changed.connect(self.syncLater) def on_node_deleted(self, node): """ @@ -149,7 +148,9 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node_or_none(self.activity) + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node_or_none(self.activity) if self.activity is None: # Activity was already deleted diff --git a/activity_browser/layouts/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py similarity index 87% rename from activity_browser/layouts/pages/activity_details/activity_header.py rename to activity_browser/app/pages/activity_details/activity_header.py index 124c81055..c9828ef82 100644 --- a/activity_browser/layouts/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -1,9 +1,11 @@ -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 actions, bwutils, application +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets @@ -37,11 +39,13 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.activity) + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node(self.activity) self.clear_layout() - if bwutils.database_is_locked(self.activity["database"]): + if database_is_locked(self.activity["database"]): self.layout().addWidget(LockedWarningBar(self)) self.layout().addLayout(self.build_grid()) @@ -66,7 +70,7 @@ def build_grid(self) -> QtWidgets.QGridLayout: grid.setSpacing(10) grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) - db_locked = bwutils.database_is_locked(self.activity["database"]) + db_locked = database_is_locked(self.activity["database"]) setup = self.disabled_setup() if db_locked else self.enabled_setup() # Arrange widgets for display as a grid @@ -133,7 +137,7 @@ def change_name(self): """ if self.text() == self.parent().activity["name"]: return - actions.ActivityModify.run(self.parent().activity, "name", self.text()) + app.actions.ActivityModify.run(self.parent().activity, "name", self.text()) class ActivityLocation(QtWidgets.QLineEdit): @@ -151,7 +155,7 @@ def __init__(self, parent: ActivityHeader): super().__init__(parent.activity.get("location"), parent) self.editingFinished.connect(self.change_location) - locations = set(bwutils.AB_metadata.dataframe.get("location", ["GLO"])) + locations = set(app.metadata.dataframe.get("location", ["GLO"])) completer = QtWidgets.QCompleter(locations, self) self.setCompleter(completer) @@ -161,7 +165,7 @@ def change_location(self): """ if self.text() == self.parent().activity.get("location"): return - actions.ActivityModify.run(self.parent().activity, "location", self.text()) + app.actions.ActivityModify.run(self.parent().activity, "location", self.text()) class ActivityProperties(QtWidgets.QWidget): @@ -189,7 +193,7 @@ def __init__(self, parent: ActivityHeader): layout.addWidget(ActivityProperty(parent.activity, property_name)) add_label = QtWidgets.QLabel("Add property") - add_label.mouseReleaseEvent = lambda x: actions.ProcessPropertyModify.run(parent.activity) + add_label.mouseReleaseEvent = lambda x: app.actions.ProcessPropertyModify.run(parent.activity) layout.addWidget(add_label) @@ -211,8 +215,8 @@ def __init__(self, activity, property_name): """ super().__init__(property_name, None) - self.modify_action = actions.ProcessPropertyModify.get_QAction(activity, property_name) - self.remove_action = actions.ProcessPropertyRemove.get_QAction(activity, property_name) + self.modify_action = app.actions.ProcessPropertyModify.get_QAction(activity, property_name) + self.remove_action = app.actions.ProcessPropertyRemove.get_QAction(activity, property_name) self.menu = QtWidgets.QMenu(self) self.menu.addAction(self.modify_action) @@ -277,7 +281,7 @@ def change_allocation(self, allocation: str): act = self.parent().activity if act.get("allocation") == allocation: return - actions.ActivityModify.run(act, "allocation", allocation) + app.actions.ActivityModify.run(act, "allocation", allocation) class LockedWarningBar(QtWidgets.QToolBar): @@ -290,12 +294,12 @@ def __init__(self, parent: ActivityHeader): height = warning_label.minimumSizeHint().height() warning_icon = QtWidgets.QLabel(self) - qicon = application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) pixmap = qicon.pixmap(height, height) warning_icon.setPixmap(pixmap) migrate_label = QtWidgets.QLabel("Unlock database") - migrate_label.mouseReleaseEvent = lambda x: actions.DatabaseSetReadonly.run(parent.activity["database"], False) + migrate_label.mouseReleaseEvent = lambda x: app.actions.DatabaseSetReadonly.run(parent.activity["database"], False) self.addWidget(warning_icon) self.addWidget(warning_label) diff --git a/activity_browser/layouts/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py similarity index 68% rename from activity_browser/layouts/pages/activity_details/consumers_tab.py rename to activity_browser/app/pages/activity_details/consumers_tab.py index 5e2397dea..02546bded 100644 --- a/activity_browser/layouts/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -1,11 +1,13 @@ 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 actions, bwutils -from activity_browser.ui import widgets, icons +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.ui import widgets, icons, core class ConsumersTab(QtWidgets.QWidget): @@ -27,10 +29,10 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): """ super().__init__(parent) - self.activity = bwutils.refresh_node(activity) + self.activity = refresh_node(activity) self.view = ConsumersView(self) - self.model = ConsumersModel(self) + self.model = ConsumersModel(parent=self, enable_sorting=True) self.view.setModel(self.model) self.view.setSortingEnabled(True) @@ -50,7 +52,9 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.activity) + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node(self.activity) exchanges = [] if isinstance(self.activity, bf.Process): for product in self.activity.products(): @@ -58,7 +62,9 @@ def sync(self): else: exchanges = list(self.activity.upstream()) - self.model.setDataFrame(self.build_df(exchanges)) + df = self.build_df(exchanges) + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: """ @@ -71,8 +77,8 @@ def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: pd.DataFrame: The DataFrame containing the exchanges data. """ exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "output"]) - input_df = bwutils.AB_metadata.get_metadata(exc_df["input"].unique(), ["name", "type", "unit", "key"]) - output_df = bwutils.AB_metadata.get_metadata(exc_df["output"].unique(), ["name", "type", "key"]) + input_df = app.metadata.get_metadata(exc_df["input"].unique(), ["name", "type", "unit", "key"]) + output_df = app.metadata.get_metadata(exc_df["output"].unique(), ["name", "type", "key"]) df = exc_df.merge( input_df.rename({"name": "product", "type": "_product_type"}, axis="columns"), @@ -105,34 +111,43 @@ def mouseDoubleClickEvent(self, event) -> None: Args: event: The mouse event. """ - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ConsumersItem)] - keys = list({i["_consumer_key"] for i in items}) + indexes = self.selectedIndexes() + if not indexes: + return super().mouseDoubleClickEvent(event) + + keys = self.model().values_from_indices("_consumer_key", indexes) if keys: - actions.ActivityOpen.run(keys) + app.actions.ActivityOpen.run(keys) -class ConsumersItem(widgets.ABDataItem): +class ConsumersModel(core.ABTreeModel): """ - An item representing a consumer in the tree view. + A model representing the data for the consumers. """ - def decorationData(self, col, key): + + def decorationData(self, index): """ - Provides decoration data for the item. + Provides decoration data for the model. Args: - col: The column index. - key: The key for which to provide decoration data. + index: The index for which to provide decoration data. Returns: - The decoration data for the item. + The decoration data for the model. """ - if key not in ["product", "consumer"]: - return + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None - if key == "product": - activity_type = self["_product_type"] - else: # key is "consumer" - activity_type = self["_consumer_type"] + if column_name not in ["product", "consumer"]: + return None + + if column_name == "product": + activity_type = row.get("_product_type") + else: # column_name == "consumer" + activity_type = row.get("_consumer_type") if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: return icons.qicons.biosphere @@ -145,12 +160,4 @@ def decorationData(self, col, key): if activity_type == "waste": return icons.qicons.waste - -class ConsumersModel(widgets.ABItemModel): - """ - A model representing the data for the consumers. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ConsumersItem + return None diff --git a/activity_browser/layouts/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py similarity index 51% rename from activity_browser/layouts/pages/activity_details/data_tab.py rename to activity_browser/app/pages/activity_details/data_tab.py index 1e331f01c..b9d2da129 100644 --- a/activity_browser/layouts/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -1,12 +1,13 @@ from qtpy import QtWidgets, QtCore +from loguru import logger import pandas as pd import bw2data as bd import bw_functional as bf -from activity_browser import actions -from activity_browser.bwutils import refresh_node, database_is_locked -from activity_browser.ui import widgets, delegates +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked +from activity_browser.ui import widgets, delegates, core class DataTab(QtWidgets.QWidget): @@ -32,12 +33,13 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): # Data TreeView self.data_view = DataView(self) - self.data_model = DataModel(self) + self.data_model = DataModel(parent=self) self.data_view.setModel(self.data_model) - self.data_model.setDataFrame(self.build_df()) - self.data_model.group(2) - self.data_view.setColumnHidden(2, True) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.data_model.set_dataframe(df) + self.data_model.group(["_name"]) self.data_view.expandAll() self.build_layout() @@ -54,8 +56,14 @@ 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) - self.data_model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.data_model.set_dataframe(df) + self.data_model.group(["_name"]) + self.data_view.expandAll() def build_df(self) -> pd.DataFrame: """ @@ -65,23 +73,23 @@ def build_df(self) -> pd.DataFrame: pd.DataFrame: The DataFrame containing the activity data. """ df = pd.Series(self.activity.as_dict()).to_frame() - df["name"] = f"{self.activity['name']} {df.get('product', '')} ({self.activity['id']})" + df["_name"] = f"{self.activity['name']} {df.get('product', '')} ({self.activity['id']})" df["_activity_id"] = self.activity.id df["_activity_db"] = self.activity["database"] if isinstance(self.activity, bf.Process): for product in self.activity.products(): fn_df = pd.DataFrame.from_dict(product.as_dict(), orient="index") - fn_df["name"] = f"{product['name']}: {product.get('product', '')} ({product['id']})" + fn_df["_name"] = f"{product['name']}: {product.get('product', '')} ({product['id']})" fn_df["_activity_id"] = product.id fn_df["_activity_db"] = product["database"] df = pd.concat([df, fn_df]) df = df.reset_index() df = df.rename({"index": "field", 0: "value"}, axis=1) - df = df.sort_values(["name", "field"], ignore_index=True) + df = df.sort_values(["_name", "field"], ignore_index=True) - cols = ["field", "value", "name", "_activity_id", "_activity_db"] + cols = ["field", "value", "_name", "_activity_id", "_activity_db"] return df[cols] @@ -93,75 +101,89 @@ class DataView(widgets.ABTreeView): defaultColumnDelegates (dict): The default column delegates for the view. """ defaultColumnDelegates = { - "key": delegates.StringDelegate, + "field": delegates.StringDelegate, "value": delegates.NewFormulaDelegate, } -class DataItem(widgets.ABDataItem): +class DataModel(core.ABTreeModel): """ - An item representing a data entry in the tree view. + A model representing the data for the activity. """ - def flags(self, col: int, key: str): + + def setData(self, index: QtCore.QModelIndex, value, role: int = QtCore.Qt.ItemDataRole.EditRole) -> bool: """ - Returns the item flags for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to return the flags. + 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: - QtCore.Qt.ItemFlags: The item flags. + bool: True if the data was set successfully, False otherwise. """ - flags = super().flags(col, key) + if role != QtCore.Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False - if key == "value" and not database_is_locked(self["_activity_db"]): - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags + if column_name == "value": + value = eval(value) + app.actions.ActivityModify.run(row.get("_activity_id"), row.get("field"), value) + return True - def displayData(self, col: int, key: str): + return False + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: """ - Returns the display data for the given column and key. + Returns whether the index is editable. Args: - col (int): The column index. - key (str): The key for which to return the display data. + index (QtCore.QModelIndex): The index to check. Returns: - str: The display data. + bool: True if the index is editable, False otherwise. """ - if key == "value": - data = self[key] - if isinstance(data, str): - return f"'{data}'" - return str(data) + column_name = self.column_name(index) + row = self.row(index) - return super().displayData(col, key) + if row is None: + return False - def setData(self, col: int, key: str, value) -> bool: + if column_name == "value" and not database_is_locked(row.get("_activity_db")): + return True + + return False + + def displayData(self, index: QtCore.QModelIndex) -> any: """ - Sets the data for the given column and key. + Provides display data for the model. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index for which to provide display data. Returns: - bool: True if the data was set successfully, False otherwise. + The display data for the index. """ - if key in ["value"]: - value = eval(value) - actions.ActivityModify.run(self["_activity_id"], self["field"], value) - - return False - - -class DataModel(widgets.ABItemModel): - """ - A model representing the data for the activity. + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + # Branch node + node = index.internalPointer() + if isinstance(node, core.TreeNode): + return node.path[-1] if index.column() == 0 else None + return None + + if column_name == "value": + data = row.get(column_name) + if isinstance(data, str): + return f"'{data}'" + return str(data) - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = DataItem + return row.get(column_name) diff --git a/activity_browser/layouts/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py similarity index 74% rename from activity_browser/layouts/pages/activity_details/description_tab.py rename to activity_browser/app/pages/activity_details/description_tab.py index 266247726..c5605c7bb 100644 --- a/activity_browser/layouts/pages/activity_details/description_tab.py +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -1,8 +1,10 @@ from qtpy import QtWidgets, QtGui +from loguru import logger import bw2data as bd -from activity_browser import bwutils, actions +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked class DescriptionTab(QtWidgets.QTextEdit): @@ -20,7 +22,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): activity (tuple | int | bd.Node): The activity to display and edit the description for. parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. """ - self.activity = bwutils.refresh_node(activity) + self.activity = refresh_node(activity) super().__init__(parent, self.activity.get("comment", "")) self.setPlaceholderText("Click here to edit the description of this activity...") @@ -28,12 +30,14 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.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) # Set the read-only state based on the activity's database - self.setReadOnly(bwutils.database_is_locked(self.activity["database"])) + self.setReadOnly(database_is_locked(self.activity["database"])) def focusOutEvent(self, e): """ @@ -44,4 +48,4 @@ def focusOutEvent(self, e): """ if self.toPlainText() == self.activity.get("comment", ""): return - actions.ActivityModify.run(self.activity, "comment", self.toPlainText()) + app.actions.ActivityModify.run(self.activity, "comment", self.toPlainText()) diff --git a/activity_browser/layouts/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py similarity index 50% rename from activity_browser/layouts/pages/activity_details/exchanges_tab.py rename to activity_browser/app/pages/activity_details/exchanges_tab.py index 41bbe6339..91f67620e 100644 --- a/activity_browser/layouts/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -1,6 +1,8 @@ -from logging import getLogger +from PySide6.QtCore import QModelIndex +from loguru import logger +from typing import Literal -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt import pandas as pd @@ -8,11 +10,13 @@ import bw_functional as bf -from activity_browser import actions, bwutils -from activity_browser.bwutils import refresh_node, AB_metadata, database_is_locked, database_is_legacy -from activity_browser.ui import widgets, icons, delegates +from activity_browser import app +from activity_browser.bwutils.commontasks import (refresh_node, database_is_locked, database_is_legacy, + is_node_product_or_waste, is_node_biosphere, parameters_in_scope, + is_node_product, is_node_waste) +from activity_browser.ui import widgets, icons, delegates, core + -log = getLogger(__name__) EXCHANGE_MAP = { "natural resource": "biosphere", "emission": "biosphere", "inventory indicator": "biosphere", @@ -48,7 +52,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): # Output Table self.output_view = ExchangesView(self) - self.output_model = ExchangesModel(self) + self.output_model = ExchangesModel(tab=self) self.output_view.setModel(self.output_model) # Set indentation for output view @@ -56,7 +60,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): # Input Table self.input_view = ExchangesView(self) - self.input_model = ExchangesModel(self) + self.input_model = ExchangesModel(tab=self) self.input_view.setModel(self.input_model) # Set indentation for input view @@ -95,6 +99,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,23 +108,30 @@ 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) - self.output_model.setDataFrame(output_df) + output_df.reset_index(drop=True, inplace=True) + self.output_model.set_dataframe(output_df) self.output_view.drag_drop_hint.setVisible(output_df.empty) input_df = self.build_df(inputs) - self.input_model.setDataFrame(input_df) + input_df.reset_index(drop=True, inplace=True) + self.input_model.set_dataframe(input_df) self.input_view.drag_drop_hint.setVisible(input_df.empty) def build_df(self, exchanges) -> pd.DataFrame: @@ -133,12 +146,12 @@ def build_df(self, exchanges) -> pd.DataFrame: """ # Define the columns for the metadata cols = ["key", "unit", "name", "product", "location", "database", "allocation_factor", - "properties", "processor", "categories"] + "properties", "processor", "categories", "type"] # Create a DataFrame from the exchanges - exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "uncertainty type", "comment"]) - exc_df["type"] = [x["type"] for x in exchanges] - act_df = AB_metadata.get_metadata(exc_df["input"].unique(), cols) + 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"}) # Merge the exchanges DataFrame with the metadata DataFrame df = exc_df.merge( @@ -147,6 +160,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()] @@ -170,13 +186,12 @@ def build_df(self, exchanges) -> pd.DataFrame: df.rename({ "input": "_input_key", "processor": "_processor_key", - "uncertainty type": "uncertainty", + "type": "_exchange_type", "name": "producer", }, axis="columns", inplace=True) # 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"] @@ -194,10 +209,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): """ @@ -207,7 +277,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): """ @@ -216,78 +287,131 @@ def dropEvent(self, event): Args: event: The drop event. """ - log.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") - # Reset the palette on drop - self.overlay.deleteLater() + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + 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()} + + positive_exchanges = {"technosphere": set(), "biosphere": set(), "substitution": set()} + negative_exchanges = {"technosphere": set(), "substitution": set()} for key in keys: - if exc_type := get_exchange_type(key): - exchanges[exc_type].add(key) + exc_type = get_exchange_type(key, output=output) + if exc_type is None: + continue + if exc_type.startswith("-"): + negative_exchanges[exc_type[1:]].add(key) + else: + positive_exchanges[exc_type].add(key) # Run the action for new exchanges - for exc_type, keys in exchanges.items(): - actions.ExchangeNew.run(keys, self.activity.key, exc_type) + for exc_type, keys in positive_exchanges.items(): + app.actions.ExchangeNew.run(keys, self.activity.key, exc_type) + for exc_type, keys in negative_exchanges.items(): + app.actions.ExchangeNew.run(keys, self.activity.key, exc_type, amount=-1) + + def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", "resource", "emission", "generic"]: + """ + Determines the appropriate action based on the mime data. -def get_exchange_type(activity_key: tuple) -> str | None: - if bwutils.is_node_product(activity_key): - return "technosphere" - elif bwutils.is_node_biosphere(activity_key): + 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 is_node_product(activity_key): + return "substitution" if output else "technosphere" + if is_node_waste(activity_key): + return "-technosphere" if output else "-substitution" + elif is_node_biosphere(activity_key): return "biosphere" return None class RelinkDelegate(delegates.StringDelegate): matched: pd.DataFrame - column: str - item: "ExchangesItem" def createEditor(self, parent, option, index): - self.item = index.internalPointer() - self.column = index.model().columns()[index.column()] - self.column = "name" if self.column == "producer" else self.column + model: ExchangesModel = index.model() + + column = model.column_name(index) + column = "name" if column == "producer" else column - if self.column == "product" and self.item.functional: + if column == "product" and model.functional(index): return super().createEditor(parent, option, index) + + row = model.row(index) setup = { - "database": self.item["database"], - "name": self.item["producer"], - "product": self.item["product"], - "categories": self.item["categories"], - "location": self.item["location"], - "type": self.item.exchange.input["type"], + "database": row["database"], + "name": row["producer"], + "product": row["product"], + "categories": row["categories"], + "location": row["location"], + "type": row["_producer_type"], } - del setup[self.column] + del setup[column] # remove the column being edited because we are looking for alternatives - self.matched = AB_metadata.match(**setup) + self.matched = app.metadata.match(**setup) combo = QtWidgets.QComboBox(parent) - combo.addItems(list(self.matched.get(self.column, []).astype(str))) + combo.addItems(list(self.matched.get(column, []).astype(str))) return combo def setEditorData(self, editor: QtWidgets.QComboBox, index): - if self.column == "product" and self.item.functional: + model: ExchangesModel = index.model() + column = model.column_name(index) + column = "name" if column == "producer" else column + + if column == "product" and model.functional(index): return super().setEditorData(editor, index) - value = index.model().data(index, 0) + value = index.data() if value: i = editor.findText(str(value)) if i >= 0: editor.setCurrentIndex(i) def setModelData(self, editor: QtWidgets.QComboBox, model, index): - if self.column == "product" and self.item.functional: + model: ExchangesModel = index.model() + column = model.column_name(index) + column = "name" if column == "producer" else column + + if column == "product" and model.functional(index): return super().setModelData(editor, model, index) choice = editor.currentIndex() key = self.matched.iloc[choice].key + row = model.row(index) - actions.ExchangeModify.run( - index.internalPointer().exchange, + app.actions.ExchangeModify.run( + row.get("_exchange"), {"input": key} ) @@ -376,7 +500,7 @@ def setup_allocation(self): if database_is_locked(table_view.activity["database"]) or not self.column.startswith("property"): return - action = actions.ActivityModify.get_QAction(table_view.activity.key, + action = app.actions.ActivityModify.get_QAction(table_view.activity.key, "allocation", self.column[9:], parent=self) @@ -391,17 +515,17 @@ def column(self): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m: m.add(actions.ActivityNewProduct, [m.activity.key], + lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], enable=not m.locked and not database_is_legacy(m.activity["database"]) ), - lambda m: m.add(actions.ActivityNewProduct, [m.activity.key], "waste", + lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], "waste", enable=not m.locked and not database_is_legacy(m.activity["database"]), text="Create waste" ), lambda m: m.addSeparator(), - lambda m: m.add(actions.ExchangeDelete, m.exchanges, enable=bool(m.exchanges) and not m.locked), - lambda m: m.add(actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges)), - lambda m: m.add(actions.ActivityOpen, [x.input for x in m.exchanges], + lambda m: m.add(app.actions.ExchangeDelete, m.exchanges, enable=bool(m.exchanges) and not m.locked), + lambda m: m.add(app.actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges)), + lambda m: m.add(app.actions.ActivityOpen, [x.input for x in m.exchanges], enable=bool(m.exchanges), text="Open processs" if len(m.exchanges) == 1 else "Open processes", ), @@ -418,7 +542,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): """ @@ -430,6 +555,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) @@ -439,11 +570,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): @@ -468,236 +601,234 @@ 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. -class ExchangesItem(widgets.ABDataItem): - """ - An item representing an exchange in the tree view. + Args: + supportedActions: The supported drop actions. + """ + if database_is_locked(self.activity["database"]): + return - Attributes: - background_color (str): The background color of the item. - """ - background_color = None + super().startDrag(supportedActions) - @property - def exchange(self): - """ - Returns the exchange associated with the item. - Returns: - The exchange associated with the item. - """ - return self["_exchange"] +class ExchangesModel(core.ABTreeModel): + """ + A model representing the data for the exchanges. + """ + def __init__(self, tab: ExchangesTab): + super().__init__(parent=tab, enable_sorting=True) + self.tab = tab - @property - def functional(self): + def mimeTypes(self) -> list[str]: """ - Returns whether the exchange is functional. + Returns the list of MIME types that this model supports. Returns: - bool: True if the exchange is functional, False otherwise. + list[str]: List of supported MIME types. """ - return self["_exchange"].get("type") == "production" + return ["application/bw-exchangelist"] - @property - def scoped_parameters(self): + def mimeData(self, indices: list[QtCore.QModelIndex]) -> core.ABMimeData: """ - Returns the parameters in scope of the current exchange. + Returns the MIME data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the MIME data for. Returns: - dict: The parameters in scope. + core.ABMimeData: The MIME data containing the exchanges. """ - return bwutils.parameters_in_scope(self["_exchange"].output) + 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 flags(self, col: int, key: str): + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Returns the item flags for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to return the flags. + 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: - QtCore.Qt.ItemFlags: The item flags. + bool: True if the data was set successfully, False otherwise. """ - flags = super().flags(col, key) - # Check if the database is read-only. If it is, return the default flags. - if database_is_locked(self.exchange.output["database"]): - return flags - - # Allow editing for specific keys: "amount", "formula", and "uncertainty". - if key in ["amount", "formula", "uncertainty", "comment"]: - return flags | Qt.ItemFlag.ItemIsEditable + if role != Qt.ItemDataRole.EditRole: + return False - # Allow editing for "unit", "name", and "substitution_factor" if the exchange is functional. - if key in ["unit", "product", "substitution_factor"] and self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + column_name = self.column_name(index) + row = self.row(index) - if key in ["producer", "product", "location", "categories", "database"] and not self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + if row is None: + return False - # Allow editing for properties (keys starting with "property_") if the exchange is functional. - if key.startswith("property_") and self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + exchange = row.get("_exchange") + if exchange is None: + return False - # Allow editing for "allocation_factor" if the allocation is manual and the exchange is functional. - if key == "allocation_factor" and self.exchange.output.get("allocation") == "manual" and self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + if column_name in ["amount", "formula", "comment"]: + if column_name == "formula" and not str(value).strip(): + app.actions.ExchangeFormulaRemove.run([exchange]) + return True - # Return the default flags if none of the above conditions are met. - return flags + app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) + return True - def displayData(self, col: int, key: str): - """ - Returns the display data for the given column and key. + if column_name in ["unit", "product", "location", "substitution_factor", "allocation_factor"]: + act = exchange.input + app.actions.ActivityModify.run(act.key, column_name.lower(), value) + return True - Args: - col (int): The column index. - key (str): The key for which to return the display data. + if column_name.startswith("property_"): + # should move this process to a separate action + process = exchange.output + product = exchange.input - Returns: - str: The display data. - """ - if key in ["allocation_factor", "substitute", "substitution_factor"] and not self.functional: - return None + if not isinstance(process, bf.Process) or not isinstance(product, bf.Product): + logger.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") + return False - if key.startswith("property_") and not self.functional: - return None + prop_key = column_name[9:] - if key.startswith("property_") and isinstance(self[key], float): - return { - "amount": self[key], - "unit": "undefined", - "normalize": False, - } + prop = process.property_template(prop_key, value) - if key.startswith("property_") and self[key].get("normalize", True): - prop = self[key].copy() - prop["unit"] = prop['unit'] + f" / {self['unit']}" - return prop + props = product.get("properties", {}) + props[prop_key] = prop - return super().displayData(col, key) + app.actions.ActivityModify.run(product, "properties", props) + return True - def decorationData(self, col, key): + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Provides decoration data for the item. + Provides decoration data for the model. Args: - col: The column index. - key: The key for which to provide decoration data. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - The decoration data for the item. - """ - if key not in ["product", "substitute_name", "amount", "producer"] or not self.displayData(col, key): - return - - if key == "amount": - if pd.isna(self["formula"]) or self["formula"] is None: - # empty icon to align the values - return icons.qicons.empty + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name in ["product", "producer"]: + activity_type = self.get(index, "_producer_type") + if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere if column_name == "producer" else None + if activity_type == "processwithreferenceproduct": + return icons.qicons.processproduct if column_name == "producer" else icons.qicons.product + if activity_type in ["product", "process", "multifunctional", "nonfunctional"]: + return icons.qicons.process if column_name == "producer" else icons.qicons.product + if activity_type == "waste": + return icons.qicons.process if column_name == "producer" else icons.qicons.waste + + if column_name == "amount": + formula = self.get(index, "formula") + if pd.isna(formula) or formula is None or formula == "": + return None return icons.qicons.parameterized - activity_type = self.exchange.input.get("type") - - if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere - if activity_type == "product": - return icons.qicons.product - if activity_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if activity_type == "process": - return icons.qicons.process - if activity_type == "waste": - return icons.qicons.waste return None - - def fontData(self, col: int, key: str): + + def fontData(self, index: QtCore.QModelIndex) -> any: """ - Returns the font data for the given column and key. + Provides font data for the model. Args: - col (int): The column index. - key (str): The key for which to return the font data. + index (QtCore.QModelIndex): The index for which to provide font data. Returns: - QtGui.QFont: The font data. + QtGui.QFont: The font data for the index. """ - font = super().fontData(col, key) - - # set the font to bold if it's a production/functional exchange - if self.functional: + if self.substituted(index): + font = QtGui.QFont() + font.setItalic(True) font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - def backgroundData(self, col: int, key: str): - """ - Returns the background data for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the background data. - - Returns: - QtGui.QBrush: The background brush for the item. - """ - if self.background_color: - return QtGui.QBrush(QtGui.QColor(self.background_color)) + return font - if key == f"property_{self['_allocate_by']}": - from activity_browser import application - return application.palette().alternateBase() + if self.functional(index): + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font - def setData(self, col: int, key: str, value) -> bool: - """ - Sets the data for the given column and key. + return None - Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + def indexEditable(self, index): + column_name = self.column_name(index) + database = self.get(index, "_exchange")["output"][0] - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if key in ["amount", "formula", "comment"]: - if key == "formula" and not str(value).strip(): - actions.ExchangeFormulaRemove.run([self.exchange]) - return True + # Prevent editing if the database is locked + if database_is_locked(database): + return False + + functional = self.functional(index) - actions.ExchangeModify.run(self.exchange, {key.lower(): value}) + # Allow editing for specific keys: "amount", "formula", and "uncertainty". + if column_name in ["amount", "formula", "uncertainty", "comment"]: return True - if key in ["unit", "product", "location", "substitution_factor", "allocation_factor"]: - act = self.exchange.input - actions.ActivityModify.run(act.key, key.lower(), value) + # Allow editing for "unit", "name", and "substitution_factor" if the exchange is functional. + if column_name in ["unit", "product"] and functional: + return True - if key.startswith("property_"): - # should move this process to a separate action - process = self.exchange.output - product = self.exchange.input + # Allow editing for "producer", "location", "categories", and "database" if the exchange is not functional. + if column_name in ["producer", "product", "location", "categories", "database"] and not functional: + return True - if not isinstance(process, bf.Process) or not isinstance(product, bf.Product): - log.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") - return False + # Allow editing for properties (keys starting with "property_") if the exchange is functional. + if column_name.startswith("property_") and functional: + return True + + # Allow editing for allocation_factor if functional and allocation is manual + if column_name == "allocation_factor" and functional and self.tab.activity.get("allocation") == "manual": + return True - prop_key = key[9:] + return False - prop = process.property_template(prop_key, value) + def indexDragEnabled(self, index: QModelIndex) -> bool: + return True + + def functional(self, index): + """ + Returns whether the index is functional. - props = product.get("properties", {}) - props[prop_key] = prop + Args: + index (QtCore.QModelIndex): The index to check. - actions.ActivityModify.run(product, "properties", props) + Returns: + bool: True if the index is functional, False otherwise. + """ + return self.get(index, "_exchange_type") == "production" - return False + def substituted(self, index): + """ + Returns whether the index is functional. + Args: + index (QtCore.QModelIndex): The index to check. -class ExchangesModel(widgets.ABItemModel): - """ - A model representing the data for the exchanges. + Returns: + bool: True if the index is functional, False otherwise. + """ + return self.get(index, "_exchange_type") == "substitution" + + def scoped_parameters(self, index): + """ + Returns the scoped parameters for the index. - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ExchangesItem + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + Returns: + list: A list of scoped parameters for the index. + """ + exchange = self.get(index, "_exchange") + return parameters_in_scope(exchange.output) + \ No newline at end of file diff --git a/activity_browser/layouts/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py similarity index 79% rename from activity_browser/layouts/pages/activity_details/graph_tab.py rename to activity_browser/app/pages/activity_details/graph_tab.py index b25759b80..8b44a7f86 100644 --- a/activity_browser/layouts/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -1,6 +1,6 @@ import json import os -from logging import getLogger +from loguru import logger from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, SignalInstance, Slot @@ -8,11 +8,12 @@ import bw2data as bd import bw_functional as bf -from activity_browser import static, bwutils, actions +from activity_browser import static, app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets from .exchanges_tab import get_exchange_type -log = getLogger(__name__) + class GraphTab(QtWidgets.QWidget): @@ -24,6 +25,7 @@ class GraphTab(QtWidgets.QWidget): expanded_nodes (set): A set of node IDs that are expanded in the graph. button (QtWidgets.QPushButton): A button to trigger synchronization. bridge (Bridge): A bridge object for communication between Python and JavaScript. + backend (GraphBackend): A backend object for communication between Python and JavaScript. url (QUrl): The URL of the HTML file to display. channel (QtWebChannel.QWebChannel): A web channel for communication between Python and JavaScript. page (Page): A web engine page to display the HTML content. @@ -40,18 +42,19 @@ def __init__(self, activity, parent=None): super().__init__(parent) self.setAcceptDrops(True) - self.activity = bwutils.refresh_node(activity) + self.activity = refresh_node(activity) self.expanded_nodes = {self.activity.id} self.button = QtWidgets.QPushButton("CLICK ME") self.button.clicked.connect(self.sync) self.bridge = Bridge(self) + self.backend = GraphBackend(self) self.url = QUrl.fromLocalFile(os.path.join(static.__path__[0], "activity_graph.html")) self.channel = QtWebChannel.QWebChannel(self) self.channel.registerObject("bridge", self.bridge) - self.channel.registerObject("backend", self) + self.channel.registerObject("backend", self.backend) self.page = Page() self.page.setWebChannel(self.channel) @@ -70,7 +73,9 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.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) @@ -142,7 +147,6 @@ def build_json(self): return json.dumps(full) - @Slot(str) def expand_node(self, node_id: str): """ Expands a node in the graph. @@ -157,7 +161,6 @@ def expand_node(self, node_id: str): self.expanded_nodes.add(node.id) self.sync() - @Slot(str) def collapse_node(self, node_id: str): """ Collapses a node in the graph. @@ -185,7 +188,7 @@ def get_processor_from_exchange(exchange): source = exchange.input processors = list(source.upstream(kinds=["production"])) if len(processors) > 1: - log.warning("Multiple processors, only taking first one") + logger.warning("Multiple processors, only taking first one") processor = processors[0] return processor.output @@ -205,7 +208,7 @@ def dragEnterEvent(self, event): Args: event: The drag enter event. """ - if bwutils.database_is_locked(self.parent().activity["database"]): + if database_is_locked(self.parent().activity["database"]): return if event.mimeData().hasFormat("application/bw-nodekeylist"): @@ -230,7 +233,7 @@ def dropEvent(self, event): Args: event: The drop event. """ - log.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") # Reset the palette on drop self.overlay.deleteLater() @@ -243,7 +246,45 @@ def dropEvent(self, event): # Run the action for new exchanges for exc_type, keys in exchanges.items(): - actions.ExchangeNew.run(keys, self.parent().activity.key, exc_type) + app.actions.ExchangeNew.run(keys, self.parent().activity.key, exc_type) + + +class GraphBackend(QObject): + """ + A backend object for communication between Python and JavaScript. + This object is exposed to the JavaScript side and provides methods + that can be called from JavaScript to control the graph. + """ + def __init__(self, graph_tab: GraphTab, parent=None): + """ + Initializes the GraphBackend object. + + Args: + graph_tab (GraphTab): The GraphTab widget this backend is associated with. + parent (QObject, optional): The parent object. Defaults to None. + """ + super().__init__(parent) + self.graph_tab = graph_tab + + @Slot(str) + def expand_node(self, node_id: str): + """ + Expands a node in the graph. + + Args: + node_id (str): The ID of the node to expand. + """ + self.graph_tab.expand_node(node_id) + + @Slot(str) + def collapse_node(self, node_id: str): + """ + Collapses a node in the graph. + + Args: + node_id (str): The ID of the node to collapse. + """ + self.graph_tab.collapse_node(node_id) class Bridge(QObject): @@ -283,10 +324,10 @@ def javaScriptConsoleMessage(self, level: QtWebEngineWidgets.QWebEnginePage.Java _ (str): Unused parameter. """ if level == QtWebEngineWidgets.QWebEnginePage.InfoMessageLevel: - log.info(f"JS Info (Line {line}): {message}") + logger.info(f"JS Info (Line {line}): {message}") elif level == QtWebEngineWidgets.QWebEnginePage.WarningMessageLevel: - log.warning(f"JS Warning (Line {line}): {message}") + logger.warning(f"JS Warning (Line {line}): {message}") elif level == QtWebEngineWidgets.QWebEnginePage.ErrorMessageLevel: - log.error(f"JS Error (Line {line}): {message}") + logger.error(f"JS Error (Line {line}): {message}") else: - log.debug(f"JS Log (Line {line}): {message}") + logger.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py new file mode 100644 index 000000000..dd055fbb3 --- /dev/null +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -0,0 +1,387 @@ +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt +from loguru import logger + +import pandas as pd +import bw2data as bd + +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group, ParameterBase + +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): + """ + A widget that displays parameters related to a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display parameters for. + model (ParametersModel): The model containing the data for the parameters. + view (ParametersView): The view displaying the parameters. + """ + def __init__(self, activity, parent=None): + """ + Initializes the ParametersTab widget. + + Args: + activity (tuple | int | bd.Node): The activity to display parameters for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self.activity = refresh_node(activity) + + self.view = ParametersView(self) + self.view.setSortingEnabled(False) + self.view.setUniformRowHeights(True) + + self.model = ParametersModel(tab=self) + 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, 10, 0, 1) + layout.addWidget(self.view) + + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + 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 the activity. + """ + 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() + db_name = self.activity["database"] + + 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() + group_name = node_group(self.activity) or str(self.activity.id) + + 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 not database_is_locked(self.activity["database"]): + translated.append({ + "name": "New parameter...", + "_scope": f"Group: {group_name}", + "_database": self.activity["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) + + df["_activity"] = [self.activity for i in range(len(df))] + 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 ParametersView(widgets.ABTreeView): + """ + A view that displays the 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): + menuSetup = [ + lambda m: m.add(app.actions.ParameterDelete, m.parameters, enable=bool(m.parameters) and not m.locked), + ] + + @property + def locked(self): + table_view: ParametersView = self.parent() + return database_is_locked(table_view.activity["database"]) + + @property + def activity(self): + table_view: ParametersView = self.parent() + return table_view.activity + + @property + def parameters(self): + table_view: ParametersView = self.parent() + table_model: ParametersModel = table_view.model() + + selected_indices = table_view.selectedIndexes() + params = table_model.values_from_indices("_parameter", selected_indices) + # Convert to peewee models + return [p.to_peewee_model() for p in params if p is not None] + + @property + def activity(self): + """ + Returns the activity associated with the view. + + Returns: + The activity associated with the view. + """ + return self.parent().activity + + +class ParametersModel(core.ABTreeModel): + """ + A model representing the data for the parameters. + """ + def __init__(self, tab: ParametersTab): + super().__init__(parent=tab) + self.tab = tab + + 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": + formula = self.get(index, "formula") + formula = isinstance(formula, str) and formula.strip() + + return icons.qicons.parameterized if formula else icons.qicons.empty + + 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. + """ + parameter = self.get(index, "_parameter") + if parameter is None or isinstance(parameter, float): # NaN check + return {} + + return parameters_in_scope(parameter=parameter) diff --git a/activity_browser/layouts/pages/calculation_setup/__init__.py b/activity_browser/app/pages/calculation_setup/__init__.py similarity index 100% rename from activity_browser/layouts/pages/calculation_setup/__init__.py rename to activity_browser/app/pages/calculation_setup/__init__.py diff --git a/activity_browser/layouts/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py similarity index 87% rename from activity_browser/layouts/pages/calculation_setup/calculation_setup.py rename to activity_browser/app/pages/calculation_setup/calculation_setup.py index 091c6eaf4..17e2dc0f6 100644 --- a/activity_browser/layouts/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 signals, actions +from activity_browser import app from activity_browser.ui import widgets, icons from .scenario_section import ScenarioSection @@ -63,13 +64,15 @@ def build_layout(self): self.setLayout(layout) def connect_signals(self): - signals.project.changed.connect(self.sync) - signals.meta.calculation_setups_changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.meta.calculation_setups_changed.connect(self.sync) self.type_dropdown.currentTextChanged.connect(self.type_switch) 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() @@ -83,8 +86,8 @@ def type_switch(self, calculation_type: str): def run_calculation(self): if self.type_dropdown.currentText() == "Standard": - actions.CSCalculate.run(self.calculation_setup_name) + app.actions.CSCalculate.run(self.calculation_setup_name) elif self.type_dropdown.currentText() == "Scenario": scenario_data = self.scenario_section.scenario_dataframe() - actions.CSCalculate.run(self.calculation_setup_name, scenario_data) + app.actions.CSCalculate.run(self.calculation_setup_name, scenario_data) diff --git a/activity_browser/layouts/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py similarity index 55% rename from activity_browser/layouts/pages/calculation_setup/functional_unit_section.py rename to activity_browser/app/pages/calculation_setup/functional_unit_section.py index 6068ffb7c..baf8be3cb 100644 --- a/activity_browser/layouts/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -1,12 +1,13 @@ -from qtpy import QtWidgets +from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt +from loguru import logger import bw2data as bd import pandas as pd -from activity_browser import actions -from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import AB_metadata, is_node_product +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import is_node_product_or_waste class FunctionalUnitSection(QtWidgets.QWidget): @@ -16,8 +17,8 @@ def __init__(self, calculation_setup_name: str, parent=None): self.calculation_setup_name = calculation_setup_name self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) - self.view = FunctionalUnitView() - self.model = FunctionalUnitModel() + self.view = FunctionalUnitView(self) + self.model = FunctionalUnitModel(parent=self) self.view.setModel(self.model) self.build_layout() @@ -28,9 +29,13 @@ 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] - self.model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) except KeyError: self.parent().close() self.parent().deleteLater() @@ -44,7 +49,7 @@ def build_df(self): keys.append(key) amounts.append(amount) - act_df = AB_metadata.get_metadata(keys, cols) + act_df = app.metadata.get_metadata(keys, cols) act_df["amount"] = amounts act_df["_activity_key"] = keys act_df["_cs_name"] = self.calculation_setup_name @@ -53,7 +58,7 @@ def build_df(self): act_df["_processor_key"] = act_df["_processor_key"].fillna(act_df["_activity_key"]) # Retrieve metadata for unique processor keys, focusing on the "name" column. - processor_df = AB_metadata.get_metadata(act_df["_processor_key"].unique(), ["name"]) + processor_df = app.metadata.get_metadata(act_df["_processor_key"].unique(), ["name"]) # Flatten the index of the processor DataFrame to ensure compatibility with merging. processor_df.index = processor_df.index.to_flat_index() @@ -89,30 +94,18 @@ class FunctionalUnitView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.ActivityOpen, m.selected_processes, - text="Open process" if len(m.selected_processes) == 1 else "Open processes", - enable=len(m.selected_processes) > 0 + lambda m, p: m.add(app.actions.ActivityOpen, p.selected_processes(), + text="Open process" if len(p.selected_processes()) == 1 else "Open processes", + enable=len(p.selected_processes()) > 0 ), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.CSDeleteFunctionalUnit, m.cs_name, m.selected_fus, - text="Delete Functional Unit" if len(m.selected_fus) == 1 else "Delete Functional Units", - enable=len(m.selected_fus) > 0 + lambda m, p: m.add(app.actions.CSDeleteFunctionalUnit, p.cs_name(), p.selected_row_indices(), + text="Delete Functional Unit" if len(p.selected_processes()) == 1 else "Delete Functional Units", + enable=len(p.selected_processes()) > 0 ), ] - - @property - def selected_fus(self): - return list(set([index.internalPointer().key() for index in self.parent().selectedIndexes()])) - - @property - def selected_processes(self): - return list(set([index.internalPointer()["_processor_key"] for index in self.parent().selectedIndexes()])) - - @property - def cs_name(self): - return self.parent().parent().calculation_setup_name - + def __init__(self, parent=None): super().__init__(parent) self.setAcceptDrops(True) @@ -127,12 +120,12 @@ def mouseDoubleClickEvent(self, event) -> None: event: The mouse double click event. """ index = self.indexAt(event.pos()) - if index.column() == 0: + if index.column() == 1: # Prevent action on amount column return super().mouseDoubleClickEvent(event) if self.selectedIndexes(): - activities = [index.internalPointer()["_processor_key"] for index in self.selectedIndexes()] - actions.ActivityOpen.run(list(set(activities))) + activities = self.model().values_from_indices("_processor_key", self.selectedIndexes()) + app.actions.ActivityOpen.run(list(set(activities))) return None @@ -143,7 +136,7 @@ def dragEnterEvent(self, event): if event.mimeData().hasFormat("application/bw-nodekeylist"): keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") for key in keys: - if not is_node_product(key): + if not is_node_product_or_waste(key): keys.remove(key) if not keys: @@ -157,62 +150,92 @@ def dropEvent(self, event) -> None: keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") for key in keys.copy(): - if not is_node_product(key): + if not is_node_product_or_waste(key): keys.remove(key) - actions.CSAddFunctionalUnit.run(cs_name, keys) + app.actions.CSAddFunctionalUnit.run(cs_name, keys) + def selected_row_indices(self): + return [i.row() for i in super().selectedIndexes()] + + def cs_name(self): + return self.parent().calculation_setup_name + + def selected_processes(self): + return list(set(self.model().values_from_indices("_processor_key", self.selectedIndexes()))) -class FunctionalUnitItem(widgets.ABDataItem): - def decorationData(self, col: int, key: str): - if key == "product" and self["_type"] == "waste": - return icons.qicons.waste - elif key == "product" and self["type"] == "processwithreferenceproduct": - return icons.qicons.processproduct - elif key == "product": - return icons.qicons.product - if key == "process": - return icons.qicons.process - return super().decorationData(col, key) - def flags(self, col: int, key: str): +class FunctionalUnitModel(core.ABTreeModel): + """ + A model representing the data for the functional units. + """ + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Returns the item flags for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to return the flags. + 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: - QtCore.Qt.ItemFlags: The item flags. + bool: True if the data was set successfully, False otherwise. """ - flags = super().flags(col, key) - if key in ["amount"]: - return flags | Qt.ItemFlag.ItemIsEditable - return flags + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False - def setData(self, col: int, key: str, value) -> bool: + if column_name == "amount": + cs_name = row.get("_cs_name") + app.actions.CSChangeFunctionalUnit.run(cs_name, index.row(), value) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Sets the data for the given column and key. + Provides decoration data (icons) for the model. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - bool: True if the data was set successfully, False otherwise. + The decoration data (icon) for the index. """ - if key not in ["amount"]: - return False - - cs_name = self["_cs_name"] - index = self.key() - - actions.CSChangeFunctionalUnit.run(cs_name, index, value) + column_name = self.column_name(index) + + if column_name == "product": + product_type = self.get(index, "_type") + if product_type == "waste": + return icons.qicons.waste + elif product_type == "processwithreferenceproduct": + return icons.qicons.processproduct + else: + return icons.qicons.product + elif column_name == "process": + return icons.qicons.process + 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) -class FunctionalUnitModel(widgets.ABItemModel): - dataItemClass = FunctionalUnitItem + if column_name == "amount": + return True + + return False diff --git a/activity_browser/layouts/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py similarity index 58% rename from activity_browser/layouts/pages/calculation_setup/impact_category_section.py rename to activity_browser/app/pages/calculation_setup/impact_category_section.py index eafb72b56..2fcda22f8 100644 --- a/activity_browser/layouts/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -1,10 +1,11 @@ from qtpy import QtWidgets +from loguru import logger import bw2data as bd import pandas as pd -from activity_browser import actions -from activity_browser.ui import widgets, delegates +from activity_browser import app +from activity_browser.ui import widgets, delegates, core class ImpactCategorySection(QtWidgets.QWidget): @@ -14,8 +15,8 @@ def __init__(self, calculation_setup_name: str, parent=None): self.calculation_setup_name = calculation_setup_name self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) - self.view = ImpactCategoryView() - self.model = ImpactCategoryModel() + self.view = ImpactCategoryView(self) + self.model = ImpactCategoryModel(parent=self) self.view.setModel(self.model) self.build_layout() @@ -26,9 +27,13 @@ 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] - self.model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) except KeyError: self.parent().close() self.parent().deleteLater() @@ -38,8 +43,9 @@ def build_df(self): df = pd.DataFrame(data, columns=["name", "unit", "num_cfs"]) df["name"] = self.calculation_setup.get("ia", []) + df["_cs_name"] = self.calculation_setup_name - cols = ["name", "unit", "num_cfs"] + cols = ["name", "unit", "num_cfs", "_cs_name"] return df[cols] @@ -49,19 +55,21 @@ class ImpactCategoryView(widgets.ABTreeView): "name": delegates.StringDelegate } - class ContextMenu(QtWidgets.QMenu): - def __init__(self, pos, view: "ImpactCategoryView"): - super().__init__(view) - cs_name = view.parent().calculation_setup_name - - if not view.selectedIndexes(): - return + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.CSDeleteImpactCategory, m.cs_name, m.selected_ics, + text="Delete Impact Category" if len(m.selected_ics) == 1 else "Delete Impact Categories", + enable=len(m.selected_ics) > 0 + ), + ] - indices = [index.internalPointer().key() for index in view.selectedIndexes()] + @property + def selected_ics(self): + return list(set([index.row() for index in self.parent().selectedIndexes()])) - self.delete_ic_action = actions.CSDeleteImpactCategory.get_QAction(cs_name, indices) - print(self.delete_ic_action.text()) - self.addAction(self.delete_ic_action) + @property + def cs_name(self): + return self.parent().parent().calculation_setup_name def __init__(self, parent=None): super().__init__(parent) @@ -80,12 +88,11 @@ def dropEvent(self, event) -> None: event.accept() cs_name = self.parent().calculation_setup_name method_names = event.mimeData().retrievePickleData("application/bw-methodnamelist") - actions.CSAddImpactCategory.run(cs_name, method_names) + app.actions.CSAddImpactCategory.run(cs_name, method_names) -class ImpactCategoryItem(widgets.ABDataItem): +class ImpactCategoryModel(core.ABTreeModel): + """ + A model representing the data for the impact categories. + """ pass - - -class ImpactCategoryModel(widgets.ABItemModel): - dataItemClass = ImpactCategoryItem diff --git a/activity_browser/layouts/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py similarity index 96% rename from activity_browser/layouts/pages/calculation_setup/scenario_section.py rename to activity_browser/app/pages/calculation_setup/scenario_section.py index 9b47ce1f9..ff7040fef 100644 --- a/activity_browser/layouts/pages/calculation_setup/scenario_section.py +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from pathlib import Path from qtpy import QtWidgets @@ -8,11 +8,9 @@ import bw2data as bd from activity_browser.bwutils import superstructure as ss -from activity_browser import signals +from activity_browser import app from activity_browser.ui import icons, widgets -from activity_browser.bwutils import errors -log = getLogger(__name__) class ScenarioSection(QtWidgets.QWidget): @@ -27,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) @@ -65,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) @@ -88,9 +90,8 @@ def __init__(self, parent=None): self.connect_signals() def connect_signals(self) -> None: - signals.project.changed.connect(self.clear_tables) - signals.project.changed.connect(self.can_add_table) - signals.parameter_superstructure_built.connect(self.handle_superstructure_signal) + app.signals.project.changed.connect(self.clear_tables) + app.signals.project.changed.connect(self.can_add_table) self.table_btn.clicked.connect(self.add_table) self.table_btn.clicked.connect(self.can_add_table) @@ -213,10 +214,6 @@ def can_add_table(self) -> None: """ self.table_btn.setEnabled(len(self.tables) < self.max_tables) - def handle_superstructure_signal(self, table_idx: int, df: pd.DataFrame) -> None: - table = self.tables[table_idx] - table.sync_superstructure(df) - def save_action(self) -> None: """Creates and saves to file (.xlsx, or .csv) the scenario dataframe after the loaded scenarios have been merged. Will not contain duplicates. Will not contain self-referential technosphere flows. @@ -303,9 +300,9 @@ def load_action(self) -> None: idx = dialog.import_sheet.currentIndex() file_type_suffix = dialog.path.suffix separator = dialog.field_separator.currentData() - log.debug("separator == '{}'".format(separator)) + logger.debug("separator == '{}'".format(separator)) QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - log.info("Loading Scenario file. This may take a while for large files") + logger.info("Loading Scenario file. This may take a while for large files") # Try and read as a superstructure file # Choose a different routine for reading the file dependent on file type if file_type_suffix == ".feather": @@ -323,7 +320,7 @@ def load_action(self) -> None: # Read the file as a parameter scenario file if it is correspondingly arranged elif len(df.columns.intersection({"Name", "Group"})) == 2: # Try and read as parameter scenario file. - log.info("Superstructure: Attempting to read as parameter scenario file.") + logger.info("Superstructure: Attempting to read as parameter scenario file.") if not df["Group"].dtype == object: df["Group"] = df["Group"].astype(str) @@ -398,7 +395,7 @@ def scenario_db_check(self, df: pd.DataFrame) -> pd.DataFrame: @property def dataframe(self) -> pd.DataFrame: if self.scenario_df.empty: - log.debug("No data in scenario table {}, skipping".format(self.index + 1)) + logger.debug("No data in scenario table {}, skipping".format(self.index + 1)) return self.scenario_df diff --git a/activity_browser/layouts/pages/impact_category_details/__init__.py b/activity_browser/app/pages/impact_category_details/__init__.py similarity index 100% rename from activity_browser/layouts/pages/impact_category_details/__init__.py rename to activity_browser/app/pages/impact_category_details/__init__.py 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 new file mode 100644 index 000000000..e46fd9537 --- /dev/null +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -0,0 +1,307 @@ +from qtpy import QtWidgets, QtGui, QtCore +from qtpy.QtCore import Qt +from loguru import logger + +import bw2data as bd +import pandas as pd + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import is_node_biosphere + +from .impact_category_header import ImpactCategoryHeader + + +class ImpactCategoryDetailsPage(QtWidgets.QWidget): + def __init__(self, name: tuple, parent=None): + super().__init__(parent) + self.name = name + self.impact_category = bd.Method(name) + self.is_editable = False + + self.setObjectName(" | ".join(name)) + + self.header = ImpactCategoryHeader(self) + + self.view = CharacterizationFactorsView(self) + self.model = CharacterizationFactorsModel(page=self) + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + self.sync() + + def connect_signals(self): + app.signals.method.renamed.connect(self.on_method_renamed) + app.signals.method.deleted.connect(self.on_method_deleted) + app.signals.meta.methods_changed.connect(self.sync) + + def on_method_renamed(self, old_name, new_name): + if self.name == old_name: + self.name = new_name + self.setObjectName(" | ".join(new_name)) + self.setWindowTitle(" | ".join(new_name)) + + def on_method_deleted(self, method): + if method.name == self.name: + self.deleteLater() + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + if self.name not in bd.methods: + self.deleteLater() + return + + self.impact_category = bd.Method(self.name) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + self.header.sync() + + def build_layout(self): + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.header) + layout.addWidget(widgets.ABHLine(self)) + layout.addWidget(self.view) + self.setLayout(layout) + + 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(self.uncertainty_from_cf) + + other = app.metadata.dataframe[["id", "name", "categories", "database", "unit"]] + + df = df.merge(other, left_on="id", right_on="id").rename(columns={"id": "_id", "data": "_cf"}) + df["_impact_category_name"] = [self.name for i in range(len(df))] + df["_editable"] = self.is_editable + + cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf", "_editable"] + return df[cols] + + 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, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.add(app.actions.CFRemove, m.impact_category_name, m.char_factors, + enable=bool(m.char_factors) and m.is_editable, + text="Remove characterization factor(s)"), + ] + + @property + def is_editable(self): + table_view: CharacterizationFactorsView = self.parent() + return table_view.page.is_editable + + @property + def impact_category_name(self): + table_view: CharacterizationFactorsView = self.parent() + return table_view.page.name + + @property + def char_factors(self): + table_view: CharacterizationFactorsView = self.parent() + table_model: CharacterizationFactorsModel = table_view.model() + + selected_indices = table_view.selectedIndexes() + ids = table_model.values_from_indices("_id", selected_indices) + cfs = table_model.values_from_indices("_cf", selected_indices) + return list(zip(ids, cfs)) + + def __init__(self, parent): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSortingEnabled(True) + self.overlay = None + + @property + def page(self): + """Returns the ImpactCategoryDetailsPage associated with the view.""" + return self.parent() + + def dragEnterEvent(self, event): + """ + Handles the drag enter event. + + Args: + event: The drag enter event. + """ + if not self.parent().is_editable: + event.ignore() + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + self.overlay = widgets.ABDropOverlay(self) + self.overlay.show() + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + """Handles the drag move event - required for proper drop indicator.""" + if not self.parent().is_editable: + event.ignore() + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + event.accept() + else: + event.ignore() + + def dragLeaveEvent(self, event): + """ + Handles the drag leave event. + + Args: + event: The drag leave event. + """ + if not self.overlay is None: + # Reset the palette on drag leave + self.overlay.deleteLater() + self.overlay = None + + def dropEvent(self, event): + """ + Handles the drop event. + + Args: + event: The drop event. + """ + self.overlay.deleteLater() + self.overlay = None + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + + # Filter to only biosphere flows + biosphere_keys = [key for key in keys if is_node_biosphere(key)] + + if biosphere_keys: + app.actions.CFNew.run(self.parent().name, biosphere_keys) + + +class CharacterizationFactorsModel(core.ABTreeModel): + """ + A model representing the characterization factors data. + """ + def __init__(self, page: ImpactCategoryDetailsPage): + 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. + + 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 + + if column_name == "amount": + 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: + """ + 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 == "name": + return icons.qicons.biosphere + + 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. + """ + column_name = self.column_name(index) + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + def indexEditable(self, index): + """ + 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) + # Allow editing for amount and uncertainty if editable + if column_name in ["amount", "uncertainty"] and self.get(index, "_editable"): + return True + + return False + diff --git a/activity_browser/layouts/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py similarity index 93% rename from activity_browser/layouts/pages/impact_category_details/impact_category_header.py rename to activity_browser/app/pages/impact_category_details/impact_category_header.py index c853bdc0d..492728e7c 100644 --- a/activity_browser/layouts/pages/impact_category_details/impact_category_header.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_header.py @@ -1,5 +1,7 @@ from qtpy import QtWidgets, QtCore -from activity_browser import actions +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")) @@ -153,7 +161,7 @@ def _rename_method(self): """ Triggers the method rename action. """ - actions.MethodRename.run(self.parent().impact_category.name) + app.actions.MethodRename.run(self.parent().impact_category.name) class ImpactCategoryUnit(QtWidgets.QLineEdit): @@ -181,4 +189,4 @@ def change_unit(self): if self.text() == current_unit: return - actions.MethodMetaModify.run(impact_category.name, "unit", self.text()) \ No newline at end of file + app.actions.MethodMetaModify.run(impact_category.name, "unit", self.text()) \ No newline at end of file diff --git a/activity_browser/layouts/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py similarity index 98% rename from activity_browser/layouts/pages/lca_results/LCA_results.py rename to activity_browser/app/pages/lca_results/LCA_results.py index 0a0916212..b475f091c 100644 --- a/activity_browser/layouts/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -1,7 +1,7 @@ from collections import namedtuple from copy import deepcopy from typing import List, Optional -from logging import getLogger +from loguru import logger from datetime import datetime import numpy as np @@ -11,18 +11,20 @@ from stats_arrays.errors import InvalidParamsError -from activity_browser import signals, bwutils, 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() -log = getLogger(__name__) - def get_header_layout(header_text: str) -> QtWidgets.QVBoxLayout: vlayout = QtWidgets.QVBoxLayout() @@ -56,7 +58,7 @@ def get_unit(method: tuple, relative: bool = False) -> str: if relative: return "relative share" if method: # for all reference flows - return bwutils.commontasks.unit_of_method(method) + return unit_of_method(method) return "units of each impact category" @@ -102,7 +104,7 @@ def __init__(self, cs_name, mlca, contributions, mc, parent=None): self.cs_name, self.mlca, self.contributions, self.mc = cs_name, mlca, contributions, mc self.cs = bd.calculation_setups[self.cs_name] self.has_scenarios: bool = hasattr(mlca, "scenario_names") - self.method_dict = bwutils.commontasks.get_LCIA_method_name_dict(self.mlca.methods) + self.method_dict = get_LCIA_method_name_dict(self.mlca.methods) self.single_func_unit = len(self.mlca.func_units) == 1 self.single_method = len(self.mlca.methods) == 1 @@ -116,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), @@ -167,17 +169,17 @@ def update_scenario_data(self, index: int) -> None: def generate_content_on_click(self, index): if index == self.indexOf(self.tabs.sankey): if not self.tabs.sankey.has_sankey: - log.info("Generating Sankey Tab") + logger.info("Generating Sankey Tab") self.tabs.sankey.new_sankey() # elif index == self.indexOf(self.tabs.ft): # if not self.tabs.ft.has_been_opened: - # log.info("Generating First Tier results") + # logger.info("Generating First Tier results") # self.tabs.ft.has_been_opened = True # self.tabs.ft.update_tab() if index == self.indexOf(self.tabs.tree): if not self.tabs.tree.has_rendered_once: - log.info("Generating Tree Tab") + logger.info("Generating Tree Tab") self.tabs.tree.new_tree() @QtCore.Slot(name="lciaScenarioExport") @@ -328,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]: @@ -816,7 +814,7 @@ def update_plot(self, method_index: int = 0): method = self.parent.mlca.methods[method_index] df = self.parent.mlca.get_results_for_method(method_index) labels = [ - bwutils.commontasks.format_activity_label(next(iter(fu.keys())), style="pnld") + format_activity_label(next(iter(fu.keys())), style="pnld") for fu in self.parent.mlca.func_units ] idx = self.layout.indexOf(self.plot) @@ -965,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" @@ -1514,7 +1512,7 @@ def key_to_metadata(self, key: tuple) -> list: format: [reference product, activity name, location, unit, database] """ - return list(bwutils.AB_metadata.get_metadata([key], ["reference product", "name", "location", "unit"]).iloc[0]) + [key[0]] + return list(app.metadata.get_metadata([key], ["reference product", "name", "location", "unit"]).iloc[0]) + [key[0]] def metadata_to_index(self, data: list) -> str: """Convert list to formatted index. @@ -1860,11 +1858,11 @@ def calculate_mc_lca(self): iterations = int(self.iterations.text()) seed = None if self.seed.text(): - log.info(f"SEED: {self.seed.text()}") + logger.info(f"SEED: {self.seed.text()}") try: seed = int(self.seed.text()) except ValueError as e: - log.error( + logger.error( "Seed value must be an integer number or left empty.", exc_info=e ) QtWidgets.QMessageBox.warning( @@ -1884,13 +1882,13 @@ def calculate_mc_lca(self): QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) try: self.parent.mc.calculate(iterations=iterations, seed=seed, **includes) - signals.monte_carlo_finished.emit() + app.signals.monte_carlo_finished.emit() self.update_mc() except ( InvalidParamsError ) as e: # This can occur if uncertainty data is missing or otherwise broken # print(e) - log.error(e) + logger.error(e) QtWidgets.QMessageBox.warning( self, "Could not perform Monte Carlo simulation", str(e) ) @@ -2008,7 +2006,7 @@ def __init__(self, parent=None): super(GSATab, self).__init__(parent) self.parent = parent - self.GSA = bwutils.GlobalSensitivityAnalysis(self.parent.mc) + self.GSA = GlobalSensitivityAnalysis(self.parent.mc) header_ = QtWidgets.QToolBar() _header = header("Global Sensitivity Analysis") @@ -2053,7 +2051,7 @@ def __init__(self, parent=None): def connect_signals(self): self.button_run.clicked.connect(self.calculate_gsa) - signals.monte_carlo_finished.connect(self.monte_carlo_finished) + app.signals.monte_carlo_finished.connect(self.monte_carlo_finished) def add_GSA_ui_elements(self): # H-LAYOUT SETTINGS ROW 1 @@ -2167,7 +2165,7 @@ def calculate_gsa(self): except Exception as e: import traceback traceback.print_tb(e.__traceback__) - log.error(e) + logger.error(e) message = str(e) message_addition = "" if message == "singular matrix": @@ -2197,7 +2195,7 @@ def update_gsa(self, cs_name=None): self.table.table_name = "gsa_output_" + self.GSA.get_save_name() if self.checkbox_export_data_automatically.isChecked(): - log.info("EXPORTING DATA") + logger.info("EXPORTING DATA") self.GSA.export_GSA_input() self.GSA.export_GSA_output() @@ -2246,11 +2244,11 @@ def set_mc(self, mc, iterations=20): self.iterations = iterations def run(self): - log.info(f"Starting new Worker Thread. Iterations: {self.iterations}") + logger.info(f"Starting new Worker Thread. Iterations: {self.iterations}") self.mc.calculate(iterations=self.iterations) # res = bw.GraphTraversal().calculate(self.demand, self.method, self.cutoff, self.max_calc) - log.info("in thread {}".format(QtCore.QThread.currentThread())) - signals.monte_carlo_ready.emit(self.mc.cs_name) + logger.info("in thread {}".format(QtCore.QThread.currentThread())) + app.signals.monte_carlo_ready.emit(self.mc.cs_name) worker_thread = MonteCarloWorkerThread() diff --git a/activity_browser/layouts/pages/lca_results/__init__.py b/activity_browser/app/pages/lca_results/__init__.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/__init__.py rename to activity_browser/app/pages/lca_results/__init__.py diff --git a/activity_browser/layouts/pages/lca_results/dialogs.py b/activity_browser/app/pages/lca_results/dialogs.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/dialogs.py rename to activity_browser/app/pages/lca_results/dialogs.py diff --git a/activity_browser/layouts/pages/lca_results/plots.py b/activity_browser/app/pages/lca_results/plots.py similarity index 81% rename from activity_browser/layouts/pages/lca_results/plots.py rename to activity_browser/app/pages/lca_results/plots.py index 13a367683..a1bc673db 100644 --- a/activity_browser/layouts/pages/lca_results/plots.py +++ b/activity_browser/app/pages/lca_results/plots.py @@ -1,90 +1,20 @@ -# -*- coding: utf-8 -*- import math -from logging import getLogger +from loguru import logger import matplotlib.pyplot as plt import numpy as np import pandas as pd import seaborn as sns -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from qtpy import QtWidgets from bw2data import methods -from activity_browser.utils import savefilepath +from activity_browser.ui.widgets import ABPlot from activity_browser.bwutils.commontasks import wrap_text -log = getLogger(__name__) -# todo: sizing of the figures needs to be improved and systematized... -# todo: Bokeh is a potential alternative as it allows interactive visualizations, -# but this issue needs to be resolved first: https://github.com/bokeh/bokeh/issues/8169 -class Plot(QtWidgets.QWidget): - ALL_FILTER = "All Files (*.*)" - PNG_FILTER = "PNG (*.png)" - SVG_FILTER = "SVG (*.svg)" - - def __init__(self, parent=None): - super().__init__(parent) - # create figure, canvas, and axis - # self.figure = Figure(tight_layout=True) - self.figure = Figure(constrained_layout=True) - self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumHeight(0) - - self.canvas.destroyed.connect(self.check) - - self.ax = self.figure.add_subplot(111) # create an axis - self.plot_name = "Figure" - - # set the layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.canvas) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.updateGeometry() - - def check(self): - print("WHY DELETE") - - def plot(self, *args, **kwargs): - raise NotImplementedError - - def reset_plot(self) -> None: - self.figure.clf() - self.ax = self.figure.add_subplot(111) - - def get_canvas_size_in_inches(self): - # print("Canvas size:", self.canvas.get_width_height()) - return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) - - def to_png(self): - """Export to .png format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.PNG_FILTER - ) - if filepath: - if not filepath.endswith(".png"): - filepath += ".png" - self.figure.savefig(filepath) - - def to_svg(self): - """Export to .svg format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.SVG_FILTER - ) - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - self.figure.savefig(filepath) - - -class LCAResultsBarChart(Plot): +class LCAResultsBarChart(ABPlot): """ " Generate a bar chart comparing the absolute LCA scores of the products""" def __init__(self, parent=None): @@ -116,7 +46,7 @@ def plot(self, df: pd.DataFrame, method: tuple, labels: list): self.canvas.draw() -class LCAResultsPlot(Plot): +class LCAResultsPlot(ABPlot): def __init__(self, parent=None): super().__init__(parent) self.plot_name = "LCA heatmap" @@ -180,7 +110,7 @@ def plot(self, df: pd.DataFrame, invert_plot: bool = False): self.canvas.draw() -class ContributionPlot(Plot): +class ContributionPlot(ABPlot): MAX_LEGEND = 30 def __init__(self, parent=None): @@ -280,7 +210,7 @@ def plot(self, df: pd.DataFrame, unit: str = None): self.canvas.draw() -class CorrelationPlot(Plot): +class CorrelationPlot(ABPlot): def __init__(self, parent=None): super().__init__(parent) sns.set(style="darkgrid") @@ -344,7 +274,7 @@ def plot(self, df: pd.DataFrame): self.canvas.draw() -class MonteCarloPlot(Plot): +class MonteCarloPlot(ABPlot): """Monte Carlo plot.""" def __init__(self, parent=None): diff --git a/activity_browser/ui/web/sankey_navigator.py b/activity_browser/app/pages/lca_results/sankey_navigator.py similarity index 93% rename from activity_browser/ui/web/sankey_navigator.py rename to activity_browser/app/pages/lca_results/sankey_navigator.py index 4cf6c2067..a71948e4b 100644 --- a/activity_browser/ui/web/sankey_navigator.py +++ b/activity_browser/app/pages/lca_results/sankey_navigator.py @@ -3,10 +3,9 @@ import os import time from typing import List -from logging import getLogger +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 @@ -15,30 +14,17 @@ from qtpy.QtCore import Slot from qtpy.QtWidgets import QComboBox -from activity_browser import signals +from activity_browser import app 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 -log = getLogger(__name__) -# 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") @@ -87,7 +71,7 @@ def load_finished_handler(self) -> None: def connect_signals(self): super().connect_signals() self.button_calculate.clicked.connect(self.new_sankey) - signals.database_selected.connect(self.set_database) + app.signals.database_selected.connect(self.set_database) # checkboxes self.func_unit_cb.currentIndexChanged.connect(self.new_sankey) self.method_cb.currentIndexChanged.connect(self.new_sankey) @@ -239,14 +223,14 @@ def update_sankey( cache_key = (demand_index, method_index, scenario_index, cut_off, max_calc) if data := self.cache.get(cache_key, False): # this Sankey is already cached, generate the Sankey with the cached data - log.debug(f"CACHED sankey for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CACHED sankey for: {demand}, {method}, key: {cache_key}") self.graph.new_graph(data) self.has_sankey = bool(self.graph.json_data) self.send_json() return start = time.time() - log.debug(f"CALCULATE sankey for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CALCULATE sankey for: {demand}, {method}, key: {cache_key}") try: if scenario_lca: self.parent.mlca.update_lca_calculation_for_sankey( @@ -274,7 +258,7 @@ def update_sankey( QtWidgets.QMessageBox.information( None, "Nonsensical numeric result.", str(e) ) - log.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") + logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") # cache the generated Sankey data self.cache[cache_key] = data @@ -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/layouts/pages/lca_results/style.py b/activity_browser/app/pages/lca_results/style.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/style.py rename to activity_browser/app/pages/lca_results/style.py diff --git a/activity_browser/layouts/pages/lca_results/tables.py b/activity_browser/app/pages/lca_results/tables.py similarity index 98% rename from activity_browser/layouts/pages/lca_results/tables.py rename to activity_browser/app/pages/lca_results/tables.py index ad25d75bc..a44677581 100644 --- a/activity_browser/layouts/pages/lca_results/tables.py +++ b/activity_browser/app/pages/lca_results/tables.py @@ -1,7 +1,7 @@ import os import datetime from typing import Optional, Any -from logging import getLogger +from loguru import logger import arrow import numpy as np @@ -12,14 +12,14 @@ 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 -log = getLogger(__name__) + class CustomHeader(QtWidgets.QHeaderView): @@ -176,7 +176,7 @@ def data(self, index, role=Qt.DisplayRole): if role == Qt.ItemDataRole.CheckStateRole: value = self._dataframe.iat[index.row(), index.column()] if isinstance(value, str): - log.error(f"Expected bool, received str: {value}!!") + 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 @@ -280,7 +280,7 @@ def test_query_on_column(test_type: str, col_data: pd.Series, query) -> bool: col_data.astype(float) <= float(query[1]) ) else: - log.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) + logger.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) return col_data == query def get_filter_mask(self, filters: dict) -> pd.Series: @@ -320,7 +320,7 @@ def get_filter_mask(self, filters: dict) -> pd.Series: 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: - log.info( + logger.info( "There were no matches for filter: {}: '{}'".format( col_filt[0], col_filt[1] ) @@ -435,7 +435,7 @@ def set_filters(self, mask) -> None: self.activate_filter = True self.invalidateFilter() self.activate_filter = False - log.info("{} filter matches found".format(self.matches)) + logger.info("{} filter matches found".format(self.matches)) def clear_filters(self) -> None: self.mask = None @@ -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 95% rename from activity_browser/ui/web/tree_navigator.py rename to activity_browser/app/pages/lca_results/tree_navigator.py index 2a3b566c0..d3d2ba158 100644 --- a/activity_browser/ui/web/tree_navigator.py +++ b/activity_browser/app/pages/lca_results/tree_navigator.py @@ -1,7 +1,7 @@ import json import time from typing import List, Optional -from logging import getLogger +from loguru import logger import bw2calc as bc import bw2data as bd @@ -19,17 +19,13 @@ Edge as GraphEdge, GroupedNodes as GraphGroupedNodes, ) - -from activity_browser import signals -from activity_browser.mod import bw2data as bd from bw2data.backends import ActivityDataset -from activity_browser.utils import get_base_path -from .base import BaseGraph, BaseNavigatorWidget -from ..widgets.combobox import CheckableComboBox -from ...bwutils import AB_metadata -from ...bwutils.commontasks import identify_activity_type -log = getLogger(__name__) +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 import widgets + class SmallComboBox(QtWidgets.QComboBox): """A small combo box that does not expand to fill the available space.""" @@ -41,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: @@ -49,7 +46,7 @@ class TreeNavigatorWidget(BaseNavigatorWidget): Green flows: Avoided impacts """ - HTML_FILE = str(get_base_path().joinpath("static", "tree_navigator.html").resolve()) + HTML_FILE = str(get_package_path() / "static" / "tree_navigator.html") def __init__(self, cs_name, parent=None): super().__init__(parent, css_file="tree_navigator.css") @@ -70,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") @@ -88,7 +85,7 @@ def load_finished_handler(self) -> None: def connect_signals(self): super().connect_signals() self.button_calculate.clicked.connect(self.new_tree) - signals.database_selected.connect(self.set_database) + app.signals.database_selected.connect(self.set_database) # checkboxes self.func_unit_cb.currentIndexChanged.connect(self.new_tree) self.method_cb.currentIndexChanged.connect(self.new_tree) @@ -247,14 +244,14 @@ def update_tree( ) if data := self.cache.get(cache_key, False): # this Graph is already cached, generate the tree with Graph cached data - log.debug(f"CACHED tree for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CACHED tree for: {demand}, {method}, key: {cache_key}") self.graph.new_graph(data) self.has_rendered_once = bool(self.graph.json_data) self.send_json() return start = time.time() - log.debug(f"CALCULATE tree for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CALCULATE tree for: {demand}, {method}, key: {cache_key}") try: if scenario_lca: @@ -291,7 +288,7 @@ def update_tree( QtWidgets.QMessageBox.information( None, "Nonsensical numeric result.", str(e) ) - log.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") + logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") # cache the generated Graph data self.cache[cache_key] = data @@ -328,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/layouts/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py similarity index 63% rename from activity_browser/layouts/pages/metadatastore.py rename to activity_browser/app/pages/metadatastore.py index 92e830253..b1336b260 100644 --- a/activity_browser/layouts/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -1,7 +1,8 @@ from qtpy import QtWidgets +from loguru import logger -from activity_browser.ui import widgets, delegates -from activity_browser.bwutils import AB_metadata +from activity_browser.ui import widgets, delegates, core +from activity_browser.app import metadata, signals class MetaDataStorePage(QtWidgets.QWidget): @@ -9,7 +10,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("MetaDataStorePage") - self.model = MDSModel(self, AB_metadata.dataframe) + self.model = core.ABTreeModel(metadata.dataframe, self, chunk_size=50) self.view = MDSView(self) self.view.setModel(self.model) @@ -17,10 +18,11 @@ def __init__(self, parent=None): self.connect_signals() def connect_signals(self): - AB_metadata.synced.connect(self.sync) + signals.metadata.synced.connect(self.sync) def sync(self): - self.model.setDataFrame(AB_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/layouts/pages/parameters/__init__.py b/activity_browser/app/pages/parameters/__init__.py similarity index 100% rename from activity_browser/layouts/pages/parameters/__init__.py rename to activity_browser/app/pages/parameters/__init__.py 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..be4a2663e --- /dev/null +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -0,0 +1,296 @@ +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) + self._populate_later_flag = False + + # 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.syncLater) + app.signals.parameter.changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.parameter.deleted.connect(self.syncLater) + app.signals.project.changed.connect(self.syncLater) + app.signals.meta.databases_changed.connect(self.syncLater) + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + + 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(QtWidgets.QMenu): + """ + 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 new file mode 100644 index 000000000..72c953230 --- /dev/null +++ b/activity_browser/app/pages/parameters/parameters.py @@ -0,0 +1,65 @@ +from qtpy import QtWidgets, QtCore + +from activity_browser.ui import widgets + +from .parameters_section import ParametersSection +from .parameterized_exchanges_section import ParameterizedExchangesSection + + +class ParametersPage(QtWidgets.QWidget): + """ + A widget that displays all parameters and parameterized exchanges in the current project. + + 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): + """ + Initializes the ParametersPage widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + self.parameters_section = ParametersSection(self) + self.parameterized_exchanges_section = ParameterizedExchangesSection(self) + + self.build_layout() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + 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) + + 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..aa6f3df5e --- /dev/null +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -0,0 +1,443 @@ +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, parameters_in_scope +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) + self._populate_later_flag = False + + # Parameters tree view + self.model = ProjectParametersModel(parent=self) + self.view = ProjectParametersView() + self.view.setSortingEnabled(False) + self.view.setUniformRowHeights(True) + + 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.syncLater) + app.signals.project.changed.connect(self.syncLater) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.database.deleted.connect(self.syncLater) + + app.signals.parameter.changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.parameter.deleted.connect(self.syncLater) + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + + 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.data['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([g not in ["project"] + list(bd.databases) + for g in p.selected_groups()]) + and all([not database_is_locked(p.data['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": + formula = self.get(index, "formula") + formula = isinstance(formula, str) and formula.strip() + + return icons.qicons.parameterized if formula else icons.qicons.empty + + 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. + """ + parameter = self.get(index, "_parameter") + if parameter is None or isinstance(parameter, float): # NaN check + return {} + + return parameters_in_scope(parameter=parameter) + diff --git a/activity_browser/app/pages/settings/README.md b/activity_browser/app/pages/settings/README.md new file mode 100644 index 000000000..830bf407f --- /dev/null +++ b/activity_browser/app/pages/settings/README.md @@ -0,0 +1,194 @@ +# Settings Module + +This module contains the settings page and its chapters. + +## Structure + +``` +settings/ +├── __init__.py # Module exports +├── settings_page.py # Main SettingsPage class +├── base.py # BaseSettingsChapter (base class for all chapters) +├── startup.py # StartupSettingsChapter +├── appearance.py # AppearanceSettingsChapter +├── project_manager.py # ProjectManagerSettingsChapter +├── metadatastore.py # MetadataStoreSettingsChapter +├── plugins.py # PluginsSettingsChapter +└── README.md # This file +``` + +## Adding a New Chapter + +### Step 1: Create a new chapter file + +Create a new file in this directory, e.g., `my_chapter.py`: + +```python +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.settings import ab_settings +from .base import BaseSettingsChapter + + +class MySettingsChapter(BaseSettingsChapter): + """Chapter for my settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Create your widgets + self.my_widget = QtWidgets.QLineEdit() + + self.build_layout() + self.connect_signals() + + def connect_signals(self): + """Connect signals for change tracking.""" + self.my_widget.textChanged.connect(self.changed.emit) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Create your UI + group = QtWidgets.QGroupBox("My Settings") + group_layout = QtWidgets.QGridLayout() + group_layout.addWidget(QtWidgets.QLabel("Setting:"), 0, 0) + group_layout.addWidget(self.my_widget, 0, 1) + group.setLayout(group_layout) + + layout.addWidget(group) + layout.addStretch() + + self.setLayout(layout) + + def get_current_state(self): + """Return current state for change tracking.""" + return { + 'my_setting': self.my_widget.text(), + } + + def save_settings(self): + """Save chapter-specific settings.""" + ab_settings.my_setting = self.my_widget.text() + logger.info("Saved my settings") + + def reset(self): + """Reset chapter to initial values.""" + self.my_widget.setText(ab_settings.my_setting) + + def restore_defaults(self): + """Restore default values.""" + self.my_widget.setText("default value") +``` + +### Step 2: Import in settings_page.py + +In `settings_page.py`, add your import: + +```python +from .my_chapter import MySettingsChapter +``` + +### Step 3: Add to chapters list + +In the `SettingsPage.__init__()` method, add your chapter: + +```python +# Create chapters +self.startup_chapter = StartupSettingsChapter(self) +self.appearance_chapter = AppearanceSettingsChapter(self) +self.my_chapter = MySettingsChapter(self) # <-- Add this + +# Add chapters to the stack +self.chapters = [ + ("Startup", self.startup_chapter), + ("Appearance", self.appearance_chapter), + ("My Chapter", self.my_chapter), # <-- And this +] +``` + +That's it! Your new chapter is now integrated. + +## BaseSettingsChapter Interface + +All chapters must inherit from `BaseSettingsChapter` and implement these methods: + +- **`get_current_state()`** - Return the current state as a dictionary for change tracking +- **`save_settings()`** - Save the chapter's settings to `ab_settings` +- **`reset()`** - Reset widgets to current `ab_settings` values +- **`restore_defaults()`** - Set widgets to default values + +### Change Tracking + +The base class automatically tracks changes using the `changed` signal: + +1. Override `get_current_state()` to return a dictionary of current values +2. Connect widget signals to `self.changed.emit()` to notify of changes +3. The save button will be enabled/disabled automatically based on changes + +Example: +```python +def __init__(self, parent=None): + super().__init__(parent) + self.my_widget = QtWidgets.QLineEdit() + self.build_layout() + self.connect_signals() + +def connect_signals(self): + # Emit changed signal when the widget changes + self.my_widget.textChanged.connect(self.changed.emit) + +def get_current_state(self): + """Return current state for change tracking.""" + return { + 'my_value': self.my_widget.text(), + } +``` + +## Existing Chapters + +### StartupSettingsChapter (`startup.py`) +Manages: +- Brightway directory selection and management +- Startup project selection +- Directory validation and project discovery + +### AppearanceSettingsChapter (`appearance.py`) +Manages: +- Theme selection (Light/Dark) +- Future: Font sizes, colors, etc. + +### ProjectManagerSettingsChapter (`project_manager.py`) +Manages: +- Project management settings +- Project creation and deletion + +### MetadataStoreSettingsChapter (`metadatastore.py`) +Manages: +- Metadata store caching settings +- Searcher configuration + +### PluginsSettingsChapter (`plugins.py`) +Manages: +- List of enabled Python plugins +- Add/remove plugin packages that should be imported at startup + +## Testing + +Test the settings page with: + +```bash +python test_settings_page.py +``` + +## Best Practices + +1. **Keep chapters focused** - Each chapter should handle a specific area of settings +2. **Use QGroupBox** - Organize widgets within chapters using group boxes +3. **Add tooltips** - Help users understand what each setting does +4. **Validate input** - Check settings before saving +5. **Log changes** - Use logger to record setting changes +6. **Handle errors gracefully** - Show appropriate error messages to users diff --git a/activity_browser/app/pages/settings/__init__.py b/activity_browser/app/pages/settings/__init__.py new file mode 100644 index 000000000..521c047d9 --- /dev/null +++ b/activity_browser/app/pages/settings/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from .settings_page import SettingsPage +from .base import BaseSettingsChapter +from .startup import StartupSettingsChapter +from .appearance import AppearanceSettingsChapter +from .project_manager import ProjectManagerSettingsChapter +from .metadatastore import MetadataStoreSettingsChapter +from .plugins import PluginsSettingsChapter + +__all__ = [ + "SettingsPage", + "BaseSettingsChapter", + "StartupSettingsChapter", + "AppearanceSettingsChapter", + "ProjectManagerSettingsChapter", + "MetadataStoreSettingsChapter", + "PluginsSettingsChapter", +] diff --git a/activity_browser/app/pages/settings/appearance.py b/activity_browser/app/pages/settings/appearance.py new file mode 100644 index 000000000..1ced98e4e --- /dev/null +++ b/activity_browser/app/pages/settings/appearance.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.pages.settings.base import BaseSettingsChapter + + +class AppearanceSettingsChapter(BaseSettingsChapter): + """Chapter for appearance-related settings.""" + + theme_map = { + "default": "System default", + "light": "Light theme", + "dark": "Dark theme", + } + + pane_tab_position_map = { + "top": "Top", + "bottom": "Bottom", + "left": "Left", + "right": "Right", + } + + def __init__(self, parent=None): + super().__init__(parent) + + # Theme selector + self.theme_combo = QtWidgets.QComboBox() + + # Pane tab position selector + self.pane_tab_position_combo = QtWidgets.QComboBox() + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + # Emit changed signal when settings change + self.theme_combo.currentTextChanged.connect(lambda: self.changed.emit()) + self.pane_tab_position_combo.currentTextChanged.connect(lambda: self.changed.emit()) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Theme section + theme_group = QtWidgets.QGroupBox("Theme") + theme_layout = QtWidgets.QGridLayout() + theme_layout.addWidget(QtWidgets.QLabel("Theme:"), 0, 0) + theme_layout.addWidget(self.theme_combo, 0, 1) + theme_group.setLayout(theme_layout) + + # Pane tab position section + pane_tab_group = QtWidgets.QGroupBox("Pane Tab Position") + pane_tab_layout = QtWidgets.QGridLayout() + pane_tab_layout.addWidget(QtWidgets.QLabel("Position:"), 0, 0) + pane_tab_layout.addWidget(self.pane_tab_position_combo, 0, 1) + pane_tab_group.setLayout(pane_tab_layout) + + layout.addWidget(theme_group) + layout.addWidget(pane_tab_group) + layout.addStretch() + + self.setLayout(layout) + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.theme_combo.clear() + self.theme_combo.addItems(self.theme_map.values()) + self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) + + self.pane_tab_position_combo.clear() + self.pane_tab_position_combo.addItems(self.pane_tab_position_map.values()) + self.pane_tab_position_combo.setCurrentText(self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom")) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_state = { + 'theme': self.theme_combo.currentText(), + 'pane_tab_position': self.pane_tab_position_combo.currentText(), + } + initial_state = { + 'theme': self.theme_map.get(settings["appearance"]["theme"], "System default"), + 'pane_tab_position': self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom"), + } + return current_state != initial_state + + def set_settings(self): + """Save appearance settings.""" + new_theme = self.theme_combo.currentText() + settings["appearance"]["theme"] = [key for key, value in self.theme_map.items() if value == new_theme][0] + + new_pane_position = self.pane_tab_position_combo.currentText() + settings["appearance"]["pane_tab_position"] = [key for key, value in self.pane_tab_position_map.items() if value == new_pane_position][0] + diff --git a/activity_browser/app/pages/settings/base.py b/activity_browser/app/pages/settings/base.py new file mode 100644 index 000000000..9cd455f02 --- /dev/null +++ b/activity_browser/app/pages/settings/base.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from qtpy import QtCore, QtWidgets + + +class BaseSettingsChapter(QtWidgets.QWidget): + """Base class for settings chapters.""" + + # Signal emitted when settings change + changed = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.settings_page = parent + self._initial_state = None + + def get_current_state(self): + """ + Override this to return the current state of the chapter. + Should return a dictionary or tuple representing current values. + """ + return {} + + def has_changes(self): + """Check if the chapter has unsaved changes.""" + if self._initial_state is None: + return False + return self.get_current_state() != self._initial_state + + def save_settings(self): + """Override this to save chapter-specific settings.""" + pass + + def reset(self): + """Override this to reset chapter to initial values.""" + pass + + def restore_defaults(self): + """Override this to restore default values.""" + pass diff --git a/activity_browser/app/pages/settings/metadatastore.py b/activity_browser/app/pages/settings/metadatastore.py new file mode 100644 index 000000000..7659d4239 --- /dev/null +++ b/activity_browser/app/pages/settings/metadatastore.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.actions.metadatastore_cache_clear import MetaDataStoreCacheClear +from activity_browser.app.pages.settings.base import BaseSettingsChapter + + +class MetadataStoreSettingsChapter(BaseSettingsChapter): + """Chapter for metadatastore-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Caching enabled checkbox + self.caching_checkbox = QtWidgets.QCheckBox("Enable caching") + self.caching_checkbox.setToolTip( + "Enable caching for faster data access. " + "Disable if you experience memory issues or want to force fresh data loading." + ) + + # Searcher enabled checkbox + self.searcher_checkbox = QtWidgets.QCheckBox("Enable searcher") + self.searcher_checkbox.setToolTip( + "Enable the full-text search functionality for activities and metadata. " + "Disable if you experience performance issues with large databases." + ) + + # Clear cache button + self.clear_cache_button = QtWidgets.QPushButton("Clear Cache") + self.clear_cache_button.setToolTip( + "Clear the metadata store cache and reload the current project. " + "Use this if you experience issues with outdated or corrupted cache data." + ) + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + # Emit changed signal when settings change + self.caching_checkbox.stateChanged.connect(lambda: self.changed.emit()) + self.searcher_checkbox.stateChanged.connect(lambda: self.changed.emit()) + + # Connect clear cache button + self.clear_cache_button.clicked.connect(MetaDataStoreCacheClear.run) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Metadata store group + metadatastore_group = QtWidgets.QGroupBox("Metadata Store Options") + metadatastore_layout = QtWidgets.QVBoxLayout() + + metadatastore_layout.addWidget(self.caching_checkbox) + metadatastore_layout.addWidget(self.searcher_checkbox) + + # Add clear cache button + metadatastore_layout.addWidget(self.clear_cache_button) + + # Add description label + description = QtWidgets.QLabel( + "These settings control the behavior of the metadata store, " + "which manages activity data for improved performance." + ) + description.setWordWrap(True) + description.setStyleSheet("color: gray; font-size: 10pt;") + metadatastore_layout.addWidget(description) + + metadatastore_group.setLayout(metadatastore_layout) + + layout.addWidget(metadatastore_group) + layout.addStretch() + + self.setLayout(layout) + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + try: + self.caching_checkbox.setChecked( + settings["metadatastore"]["caching_enabled"] + ) + self.searcher_checkbox.setChecked( + settings["metadatastore"]["searcher_enabled"] + ) + except (KeyError, TypeError): + # Use defaults if settings don't exist yet + self.caching_checkbox.setChecked(True) + self.searcher_checkbox.setChecked(True) + + def has_changes(self): + """Check if there are unsaved changes.""" + try: + current_state = { + 'caching_enabled': self.caching_checkbox.isChecked(), + 'searcher_enabled': self.searcher_checkbox.isChecked(), + } + initial_state = { + 'caching_enabled': settings["metadatastore"]["caching_enabled"], + 'searcher_enabled': settings["metadatastore"]["searcher_enabled"], + } + return current_state != initial_state + except (KeyError, TypeError): + # If settings don't exist, check against defaults + return (self.caching_checkbox.isChecked() != True or + self.searcher_checkbox.isChecked() != True) + + def set_settings(self): + """Save metadatastore settings.""" + if "metadatastore" not in settings.global_config: + settings.global_config["metadatastore"] = {} + + settings.global_config["metadatastore"]["caching_enabled"] = self.caching_checkbox.isChecked() + settings.global_config["metadatastore"]["searcher_enabled"] = self.searcher_checkbox.isChecked() + + logger.info( + f"Metadatastore settings saved: " + f"caching={self.caching_checkbox.isChecked()}, " + f"searcher={self.searcher_checkbox.isChecked()}" + ) + diff --git a/activity_browser/app/pages/settings/plugins.py b/activity_browser/app/pages/settings/plugins.py new file mode 100644 index 000000000..bd743a624 --- /dev/null +++ b/activity_browser/app/pages/settings/plugins.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +import importlib.util +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.pages.settings.base import BaseSettingsChapter +from activity_browser.ui.icons import qicons + + +class PluginsSettingsChapter(BaseSettingsChapter): + """Chapter for plugin-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # List widget to display enabled plugins + self.plugin_list = QtWidgets.QListWidget() + self.plugin_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + + # Input field for adding new plugins + self.plugin_input = QtWidgets.QLineEdit() + self.plugin_input.setPlaceholderText("Enter Python package name (e.g., my_plugin)") + + # Buttons + self.add_button = QtWidgets.QPushButton("Add") + self.remove_button = QtWidgets.QPushButton("Remove") + self.remove_button.setEnabled(False) + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + self.add_button.clicked.connect(self.add_plugin) + self.remove_button.clicked.connect(self.remove_plugin) + self.plugin_input.returnPressed.connect(self.add_plugin) + self.plugin_list.itemSelectionChanged.connect(self.on_selection_changed) + self.plugin_list.model().rowsInserted.connect(lambda: self.changed.emit()) + self.plugin_list.model().rowsRemoved.connect(lambda: self.changed.emit()) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Plugin list section + plugin_group = QtWidgets.QGroupBox("Enabled Plugins") + plugin_layout = QtWidgets.QVBoxLayout() + + # Description label + description = QtWidgets.QLabel( + "Add Python packages that should be imported as plugins.\n" + "These packages will be loaded when Activity Browser starts." + ) + description.setWordWrap(True) + plugin_layout.addWidget(description) + + # List widget + plugin_layout.addWidget(self.plugin_list) + + # Input section + input_layout = QtWidgets.QHBoxLayout() + input_layout.addWidget(self.plugin_input) + input_layout.addWidget(self.add_button) + plugin_layout.addLayout(input_layout) + + # Remove button + plugin_layout.addWidget(self.remove_button) + + plugin_group.setLayout(plugin_layout) + + layout.addWidget(plugin_group) + layout.addStretch() + + self.setLayout(layout) + + def on_selection_changed(self): + """Enable/disable remove button based on selection.""" + self.remove_button.setEnabled(len(self.plugin_list.selectedItems()) > 0) + + def module_exists(self, module_name): + """Check if a module can be found/imported.""" + try: + spec = importlib.util.find_spec(module_name) + return spec is not None + except (ImportError, ModuleNotFoundError, ValueError, AttributeError): + return False + + def add_plugin_to_list(self, plugin_name): + """Add a plugin to the list widget with appropriate icon.""" + item = QtWidgets.QListWidgetItem(plugin_name) + + # Check if module exists and add warning icon if not + if not self.module_exists(plugin_name): + # Use standard warning icon + icon = qicons.critical + item.setIcon(icon) + item.setToolTip(f"Warning: Module '{plugin_name}' not found. " + "Make sure it is installed before starting Activity Browser.") + logger.warning(f"Plugin module '{plugin_name}' not found") + else: + icon = qicons.empty + item.setIcon(icon) + item.setToolTip(f"Module '{plugin_name}' is available") + + self.plugin_list.addItem(item) + + def add_plugin(self): + """Add a plugin to the list.""" + plugin_name = self.plugin_input.text().strip() + if not plugin_name: + return + + # Check if plugin already exists + existing_items = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + if plugin_name in existing_items: + QtWidgets.QMessageBox.warning( + self, + "Duplicate Plugin", + f"The plugin '{plugin_name}' is already in the list." + ) + return + + # Add to list with icon + self.add_plugin_to_list(plugin_name) + self.plugin_input.clear() + logger.debug(f"Added plugin: {plugin_name}") + self.changed.emit() + + def remove_plugin(self): + """Remove selected plugin from the list.""" + selected_items = self.plugin_list.selectedItems() + if not selected_items: + return + + for item in selected_items: + plugin_name = item.text() + row = self.plugin_list.row(item) + self.plugin_list.takeItem(row) + logger.debug(f"Removed plugin: {plugin_name}") + + self.changed.emit() + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.plugin_list.clear() + enabled_plugins = settings["plugins"].get("enabled_plugins", []) + for plugin in enabled_plugins: + self.add_plugin_to_list(plugin) + self.plugin_input.clear() + self.remove_button.setEnabled(False) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + saved_plugins = settings["plugins"].get("enabled_plugins", []) + return current_plugins != saved_plugins + + def set_settings(self): + """Save plugin settings.""" + current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + settings["plugins"]["enabled_plugins"] = current_plugins + logger.info(f"Saved enabled plugins: {current_plugins}") + diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py new file mode 100644 index 000000000..925550825 --- /dev/null +++ b/activity_browser/app/pages/settings/project_manager.py @@ -0,0 +1,227 @@ +from loguru import logger + +import pandas as pd +from qtpy import QtWidgets, QtGui + +import bw2data as bd +from bw2io import remote + +from activity_browser import app, ui +from activity_browser.bwutils.commontasks import get_templates +from activity_browser.ui import widgets, core + +from .base import BaseSettingsChapter + + +class ProjectManagerSettingsChapter(BaseSettingsChapter): + """Chapter for project and template management.""" + + def __init__(self, parent=None): + super().__init__(parent) + + self.tabs = QtWidgets.QTabWidget(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) + + self.template_view = TemplateView(self) + self.template_view.setModel(self.template_model) + + self.tabs.addTab(self.project_view, "Projects") + self.tabs.addTab(self.template_view, "Templates") + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.tabs) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + def connect_signals(self): + """Connect signals and slots.""" + 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() + self.project_model.set_dataframe(df) + self.project_view.resizeColumnToContents(1) + + df = self.build_template_df() + self.template_model.set_dataframe(df) + self.template_view.resizeColumnToContents(1) + + def reset(self): + """Reset to initial values.""" + self.sync() + + def has_changes(self): + """Project manager doesn't have editable settings.""" + return False + + def set_settings(self): + """No settings to save for project manager.""" + pass + + def build_project_df(self) -> pd.DataFrame: + """Build DataFrame for projects.""" + data = [] + for proj_ds in sorted(bd.projects): + # if for any reason the project data is not a dictionary, log a warning and set it to an empty dict + if not isinstance(proj_ds.data, dict): + logger.warning(f"Project {proj_ds.name} has no data dictionary") + proj_ds.data = {} + + data.append({ + "name": proj_ds.name, + "path": proj_ds.dir, + "version": "Brightway25" if proj_ds.data.get("25", False) else "Legacy" + }) + + cols = ["name", "version", "path"] + return pd.DataFrame(data, columns=cols) + + def build_template_df(self) -> pd.DataFrame: + """Build DataFrame for templates.""" + data = [] + + templates = get_templates() + remote_templates = remote.get_projects() + + for name in sorted(templates): + data.append({ + "name": name, + "path": templates[name], + "remote": "No" + }) + + for name in sorted(remote_templates): + data.append({ + "name": name, + "path": remote_templates[name], + "remote": "Yes" + }) + + cols = ["name", "path", "remote"] + return pd.DataFrame(data, columns=cols) + + +class ProjectView(widgets.ABTreeView): + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.addMenu(p.get_project_new_menu(m)), + lambda m, p: m.addSeparator() if p.has_selection else None, + lambda m, p: m.add(app.actions.ProjectDuplicate, p.selected_project, + enable=p.single_selection) if p.single_selection else None, + lambda m, p: m.add(app.actions.ProjectCreateTemplate, p.selected_project, m.parent(), + enable=p.single_selection) if p.single_selection else None, + lambda m, p: m.add(app.actions.ProjectMigrate25, p.selected_project, + enable=(p.single_selection and p.is_legacy)) if p.single_selection and p.is_legacy else None, + lambda m, p: m.addSeparator() if p.has_selection else None, + lambda m, p: m.add(app.actions.ProjectDelete, p.selected_projects, + enable=p.has_selection) if p.has_selection else None, + ] + + def __init__(self, parent): + super().__init__(parent) + self.setSortingEnabled(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + + def get_project_new_menu(self, parent): + """Get the ProjectNewMenu.""" + from activity_browser.app.menu_bar import ProjectNewMenu + return ProjectNewMenu(parent) + + @property + def selected_projects(self) -> list: + if not self.selectedIndexes(): + return [] + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) + + @property + def selected_project(self): + return self.selected_projects[0] if self.single_selection else None + + @property + def single_selection(self): + return len(self.selected_projects) == 1 + + @property + def has_selection(self): + return len(self.selected_projects) > 0 + + @property + def is_legacy(self): + if not self.single_selection: + return False + index = self.selectedIndexes()[0] + return self.model().get(index, "version") == "Legacy" + + +class ProjectModel(core.ABTreeModel): + """Model for project data.""" + + def fontData(self, index): + """Provide font data for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + def decorationData(self, index): + """Provide icon decoration for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + name = self.get(index, "name") + if name == app.settings["startup"]["startup_project"]: + return ui.icons.qicons.star + if name == bd.projects.current: + return ui.icons.qicons.forward + + return ui.icons.qicons.empty + + return None + + +class TemplateView(widgets.ABTreeView): + + class ContextMenu(widgets.ABMenu): + menuSetup = [] + + def __init__(self, parent): + super().__init__(parent) + self.setSortingEnabled(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + + +class TemplateModel(core.ABTreeModel): + """Model for template data.""" + + def fontData(self, index): + """Provide font data for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py new file mode 100644 index 000000000..ffbe139c3 --- /dev/null +++ b/activity_browser/app/pages/settings/settings_page.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from pathlib import Path + +from qtpy import QtWidgets + +from bw2data import projects + +from activity_browser.app import settings, signals + +from .startup import StartupSettingsChapter +from .appearance import AppearanceSettingsChapter +from .project_manager import ProjectManagerSettingsChapter +from .metadatastore import MetadataStoreSettingsChapter +from .plugins import PluginsSettingsChapter + + +class SettingsPage(QtWidgets.QWidget): + """Settings page with a sidebar navigation for different settings chapters.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("SettingsPage") + + # Store initial state for cancel functionality + self.last_project = projects.current + self.last_bwdir = projects._base_data_dir + + # Chapter list (sidebar) + self.chapter_list = QtWidgets.QListWidget() + self.chapter_list.setMaximumWidth(200) + self.chapter_list.setMinimumWidth(100) + self.chapter_list.setSpacing(2) + + # Stacked widget for chapter content + self.content_stack = QtWidgets.QStackedWidget() + + # Create chapters + self.startup_chapter = StartupSettingsChapter(self) + self.appearance_chapter = AppearanceSettingsChapter(self) + self.project_manager_chapter = ProjectManagerSettingsChapter(self) + self.metadatastore_chapter = MetadataStoreSettingsChapter(self) + self.plugins_chapter = PluginsSettingsChapter(self) + + # Add chapters to the stack + self.chapters = [ + ("Startup", self.startup_chapter), + ("Appearance", self.appearance_chapter), + ("Projects", self.project_manager_chapter), + ("Metadata Store", self.metadatastore_chapter), + ("Plugins", self.plugins_chapter), + ] + + for name, widget in self.chapters: + self.chapter_list.addItem(name) + self.content_stack.addWidget(widget) + + # Select first chapter by default + self.chapter_list.setCurrentRow(0) + + # Buttons + self.button_layout = QtWidgets.QHBoxLayout() + self.save_button = QtWidgets.QPushButton("Save") + self.cancel_button = QtWidgets.QPushButton("Cancel") + self.restore_defaults_button = QtWidgets.QPushButton("Restore Defaults") + + self.button_layout.addWidget(self.restore_defaults_button) + self.button_layout.addStretch() + self.button_layout.addWidget(self.cancel_button) + self.button_layout.addWidget(self.save_button) + + # Build layout + self.build_layout() + self.connect_signals() + + # Store initial state and disable save button initially + self.save_button.setEnabled(False) + + def build_layout(self): + """Build the main layout with sidebar and content area.""" + # Main content area with sidebar and content + content_layout = QtWidgets.QHBoxLayout() + content_layout.addWidget(self.chapter_list) + + # Add vertical separator + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.VLine) + separator.setFrameShadow(QtWidgets.QFrame.Sunken) + content_layout.addWidget(separator) + + content_layout.addWidget(self.content_stack, 1) + + # Main layout + main_layout = QtWidgets.QVBoxLayout() + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addLayout(content_layout, 1) + main_layout.addLayout(self.button_layout) + + self.setLayout(main_layout) + + # Set minimum size for resizability + self.setMinimumSize(400, 300) + + def connect_signals(self): + """Connect signals and slots.""" + signals.project.changed.connect(self.reset_all) + + self.chapter_list.currentRowChanged.connect(self.content_stack.setCurrentIndex) + self.save_button.clicked.connect(self.save_settings) + self.cancel_button.clicked.connect(self.cancel_settings) + self.restore_defaults_button.clicked.connect(self.restore_defaults) + + # Connect change signals from each chapter + for name, chapter in self.chapters: + if hasattr(chapter, 'changed'): + chapter.changed.connect(self.on_chapter_changed) + + def on_chapter_changed(self): + """Called when any chapter's settings change.""" + has_changes = self.has_changes() + self.save_button.setEnabled(has_changes) + + def has_changes(self): + """Check if any chapter has unsaved changes.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'has_changes') and chapter.has_changes(): + return True + return False + + def save_settings(self): + """Save all settings from all chapters.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'set_settings'): + chapter.set_settings() + + settings.save() + logger.info("Settings saved successfully") + + # Reset all chapters to the new saved state + self.reset_all() + + def cancel_settings(self): + """Cancel changes and revert to previous state.""" + logger.info("Cancelling settings changes") + self.reset_all() + + def restore_defaults(self): + """Restore default settings for the current chapter.""" + logger.info("Restoring default settings") + settings.restore_defaults() + self.reset_all() + + def reset_all(self): + """Reset all chapters to their initial states.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'reset'): + chapter.reset() + self.save_button.setEnabled(False) + diff --git a/activity_browser/app/pages/settings/startup.py b/activity_browser/app/pages/settings/startup.py new file mode 100644 index 000000000..aa5b3c7f6 --- /dev/null +++ b/activity_browser/app/pages/settings/startup.py @@ -0,0 +1,218 @@ +# -*- 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.app import settings, panes, pages +from .base import BaseSettingsChapter + + +class StartupSettingsChapter(BaseSettingsChapter): + """Chapter for startup-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Brightway directory + self.bwdir_combo = QtWidgets.QComboBox() + self.bwdir_browse_button = QtWidgets.QPushButton("Browse") + self.bwdir_remove_button = QtWidgets.QPushButton("Remove") + + # Startup project + self.startup_project_combo = QtWidgets.QComboBox() + + # Shown panes checkboxes + self.pane_checkboxes = {} + self.available_panes = list(panes.base_panes.keys()) + for pane_name in self.available_panes: + self.pane_checkboxes[pane_name] = QtWidgets.QCheckBox(pane_name) + + # Shown pages checkboxes + self.page_checkboxes = {} + self.available_pages = list(pages.base_pages.keys()) + for page_name in self.available_pages: + self.page_checkboxes[page_name] = QtWidgets.QCheckBox(page_name) + + self.build_layout() + self.connect_signals() + self.reset() + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Brightway directory section + bwdir_group = QtWidgets.QGroupBox("Brightway Directory") + bwdir_layout = QtWidgets.QGridLayout() + bwdir_layout.addWidget(QtWidgets.QLabel("Directory:"), 0, 0) + bwdir_layout.addWidget(self.bwdir_combo, 0, 1) + bwdir_layout.addWidget(self.bwdir_browse_button, 0, 2) + bwdir_layout.addWidget(self.bwdir_remove_button, 0, 3) + bwdir_group.setLayout(bwdir_layout) + + # Startup project section + project_group = QtWidgets.QGroupBox("Startup Project") + project_layout = QtWidgets.QGridLayout() + project_layout.addWidget(QtWidgets.QLabel("Project:"), 0, 0) + project_layout.addWidget(self.startup_project_combo, 0, 1) + project_group.setLayout(project_layout) + + # Shown panes section + panes_group = QtWidgets.QGroupBox("Panes shown at startup") + panes_layout = QtWidgets.QVBoxLayout() + for pane_name in self.available_panes: + panes_layout.addWidget(self.pane_checkboxes[pane_name]) + panes_group.setLayout(panes_layout) + + # Shown pages section + pages_group = QtWidgets.QGroupBox("Pages shown at startup") + pages_layout = QtWidgets.QVBoxLayout() + for page_name in self.available_pages: + pages_layout.addWidget(self.page_checkboxes[page_name]) + pages_group.setLayout(pages_layout) + + layout.addWidget(bwdir_group) + layout.addWidget(project_group) + layout.addWidget(panes_group) + layout.addWidget(pages_group) + layout.addStretch() + + self.setLayout(layout) + + def connect_signals(self): + """Connect signals and slots.""" + self.bwdir_browse_button.clicked.connect(self.browse_bwdir) + self.bwdir_remove_button.clicked.connect(self.remove_bwdir) + + # Emit changed signal when settings change + self.bwdir_combo.currentTextChanged.connect(lambda: self.changed.emit()) + self.bwdir_combo.currentTextChanged.connect(self.show_virtual_projects) + self.startup_project_combo.currentTextChanged.connect(lambda: self.changed.emit()) + + # Connect checkboxes + for checkbox in self.pane_checkboxes.values(): + checkbox.stateChanged.connect(lambda: self.changed.emit()) + for checkbox in self.page_checkboxes.values(): + checkbox.stateChanged.connect(lambda: self.changed.emit()) + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.bwdir_combo.clear() + self.bwdir_combo.addItems(settings["startup"].get("saved_brightway_directories", [])) + self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) + + self.startup_project_combo.clear() + self.startup_project_combo.addItems(self.get_projects_from_path(settings["startup"]["brightway_directory"])) + self.startup_project_combo.setCurrentText(settings["startup"]["startup_project"]) + + # Set pane checkboxes + shown_panes = settings["startup"].get("shown_panes", []) + for pane_name, checkbox in self.pane_checkboxes.items(): + checkbox.setChecked(pane_name in shown_panes) + + # Set page checkboxes + shown_pages = settings["startup"].get("shown_pages", []) + for page_name, checkbox in self.page_checkboxes.items(): + checkbox.setChecked(page_name in shown_pages) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_state = { + 'brightway_directory': self.bwdir_combo.currentText(), + 'saved_brightway_directories': [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())], + 'startup_project': self.startup_project_combo.currentText(), + 'shown_panes': [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()], + 'shown_pages': [name for name, cb in self.page_checkboxes.items() if cb.isChecked()], + } + initial_state = { + 'brightway_directory': settings["startup"]["brightway_directory"], + 'saved_brightway_directories': settings["startup"].get("saved_brightway_directories", []), + 'startup_project': settings["startup"]["startup_project"], + 'shown_panes': settings["startup"].get("shown_panes", []), + 'shown_pages': settings["startup"].get("shown_pages", []), + } + return current_state != initial_state + + def set_settings(self): + """Save startup settings.""" + + settings["startup"]["brightway_directory"] = self.bwdir_combo.currentText() + settings["startup"]["saved_brightway_directories"] = [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())] + settings["startup"]["startup_project"] = self.startup_project_combo.currentText() + + # Save shown panes and pages + settings["startup"]["shown_panes"] = [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()] + settings["startup"]["shown_pages"] = [name for name, cb in self.page_checkboxes.items() if cb.isChecked()] + + # --- Helper methods --- # + def browse_bwdir(self): + """Browse for a brightway directory.""" + path = Path(QtWidgets.QFileDialog.getExistingDirectory( + self, "Select a brightway2 database folder" + )) + if not path: + return + + if (path / "projects.db").is_file(): + self.bwdir_combo.addItem(str(path)) + self.bwdir_combo.setCurrentText(str(path)) + return + + reply = QtWidgets.QMessageBox.question( + self, + "New brightway data directory?", + 'This directory does not contain any projects. Switching to this directory will create a new brightway2 data folder here.', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, + ) + + if reply == QtWidgets.QMessageBox.Cancel: + return + + self.bwdir_combo.addItem(str(path)) + self.bwdir_combo.setCurrentText(str(path)) + + def remove_bwdir(self): + """Remove the selected brightway directory from the list.""" + reply = QtWidgets.QMessageBox.question( + self, + "Delete Brightway2 directory?", + "This action will remove the local information only, click 'Yes' to remove\n" + "the projects. Data on the 'disk' will remain untouched and needs to be removed manually", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, + ) + if reply == QtWidgets.QMessageBox.Cancel: + return + + removed_index = self.bwdir_combo.currentIndex() + self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) + self.bwdir_combo.removeItem(removed_index) + + def show_virtual_projects(self): + """Show projects from the virtual Brightway directory.""" + virtual_projects = self.get_projects_from_path(self.bwdir_combo.currentText()) + startup = settings["startup"]["startup_project"] + + self.startup_project_combo.clear() + self.startup_project_combo.addItems(virtual_projects if virtual_projects else ["default"]) + self.startup_project_combo.setCurrentText(startup if startup in virtual_projects else "default") + + def get_projects_from_path(self, path: str): + """Get project names from a brightway directory.""" + database_file = os.path.join(path, "projects.db") + if not os.path.exists(database_file): + return [] + db = SqliteDatabase(database_file) + + 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()] diff --git a/activity_browser/layouts/pages/welcome.py b/activity_browser/app/pages/welcome.py similarity index 90% rename from activity_browser/layouts/pages/welcome.py rename to activity_browser/app/pages/welcome.py index 1d099aaa4..25ef4fb65 100644 --- a/activity_browser/layouts/pages/welcome.py +++ b/activity_browser/app/pages/welcome.py @@ -2,9 +2,9 @@ from qtpy import QtWebEngineWidgets, QtWidgets, QtCore, QtGui, QtWebChannel -from activity_browser import actions, signals +from activity_browser import app, app from activity_browser.static import startscreen -from activity_browser.bwutils import projects_by_last_opened +from activity_browser.bwutils.commontasks import projects_by_last_opened class WelcomePage(QtWidgets.QWidget): @@ -31,7 +31,7 @@ def __init__(self, parent=None): self.setLayout(self.vl) self.bridge.ready.connect(self.update_welcome) - signals.project.changed.connect(lambda: self.page.load(self.url)) + app.signals.project.changed.connect(lambda: self.page.load(self.url)) def update_welcome(self): projects = projects_by_last_opened() @@ -63,7 +63,7 @@ def open_project(self, project_name): """ Emits the ready signal. """ - actions.ProjectSwitch.run(project_name) + app.actions.ProjectSwitch.run(project_name) class WelcomeWebPage(QtWebEngineWidgets.QWebEnginePage): def acceptNavigationRequest(self, qurl, navtype, mainframe): diff --git a/activity_browser/app/panes/README.md b/activity_browser/app/panes/README.md new file mode 100644 index 000000000..6b6eb305c --- /dev/null +++ b/activity_browser/app/panes/README.md @@ -0,0 +1,75 @@ +# 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) + +## Existing Panes +- **Databases Pane** - View of available databases +- **Database Products Pane** - Search and browse product-type nodes within a database +- **Impact Categories Pane** - Browse impact assessment methods +- **Calculation Setups Pane** - List of Calculation Setups + +## 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) +``` + +## Development Guidelines + +When creating new panes: + +- **Inherit from AbstractPane** - Use the base class for consistency +- **Set pane title** - Use the standard `PaneNamePane` naming convention to set the title automatically +- **Base panes** - Add base panes to `__init__.py` in this directory so they are loaded by the main window on project change. + + +## 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 new file mode 100644 index 000000000..e6b2aab5a --- /dev/null +++ b/activity_browser/app/panes/__init__.py @@ -0,0 +1,10 @@ +from .database_products import DatabaseProductsPane +from .databases import DatabasesPane +from .impact_categories import ImpactCategoriesPane +from .calculation_setups import CalculationSetupsPane + +base_panes = { + "Databases": DatabasesPane, + "Impact Categories": ImpactCategoriesPane, + "Calculation Setups": CalculationSetupsPane, +} diff --git a/activity_browser/layouts/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py similarity index 66% rename from activity_browser/layouts/panes/calculation_setups.py rename to activity_browser/app/panes/calculation_setups.py index f88437867..c5efdf014 100644 --- a/activity_browser/layouts/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -1,10 +1,11 @@ from qtpy import QtWidgets, QtGui +from loguru import logger import bw2data as bd import pandas as pd -from activity_browser import signals, actions -from activity_browser.ui import widgets, delegates +from activity_browser import app +from activity_browser.ui import widgets, delegates, core class CalculationSetupsPane(widgets.ABAbstractPane): @@ -23,8 +24,8 @@ def __init__(self, parent): parent (QtWidgets.QWidget): The parent widget for this pane. """ super().__init__(parent) + self.model = CalculationSetupsModel(parent=self) self.view = CalculationSetupsView() - self.model = CalculationSetupsModel() self.view.setModel(self.model) self.view.setAlternatingRowColors(True) @@ -36,8 +37,7 @@ def connect_signals(self): """ Connects the signals to the appropriate slots. """ - signals.meta.calculation_setups_changed.connect(self.sync) - signals.project.changed.connect(self.sync) + app.signals.meta.calculation_setups_changed.connect(self.sync) def build_layout(self): """ @@ -52,7 +52,9 @@ def sync(self): """ Synchronizes the model with the current state of the calculation setups. """ - self.model.setDataFrame(self.build_df()) + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + df = self.build_df() + self.model.set_dataframe(df) self.view.resizeColumnToContents(0) def build_df(self) -> pd.DataFrame: @@ -90,25 +92,28 @@ class CalculationSetupsView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda menu: menu.add(actions.CSNew), - lambda menu: menu.add(actions.CSOpen, menu.calculation_setups, - enable=bool(menu.calculation_setups)), - lambda menu: menu.add(actions.CSDelete, menu.calculation_setups, - enable=bool(menu.calculation_setups)), - lambda menu: menu.add(actions.CSRename, menu.calculation_setups[0] if menu.single_selection else None, - enable=menu.single_selection), - lambda menu: menu.addSeparator(), - lambda menu: menu.add(actions.CSCalculate, menu.calculation_setups[0] if menu.single_selection else None, - enable=menu.single_selection), + lambda m, p: m.add(app.actions.CSNew), + lambda m, p: m.add(app.actions.CSOpen, p.calculation_setups, + enable=bool(p.calculation_setups)), + lambda m, p: m.add(app.actions.CSDelete, p.calculation_setups, + enable=bool(p.calculation_setups)), + lambda m, p: m.add(app.actions.CSRename, p.calculation_setups[0] if p.single_selection else None, + enable=p.single_selection), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.CSCalculate, p.calculation_setups[0] if p.single_selection else None, + enable=p.single_selection), ] - @property - def calculation_setups(self): - return [item["name"] for item in {index.internalPointer() for index in self.parent().selectedIndexes()}] + @property + def calculation_setups(self): + if not self.selectedIndexes(): + return [] + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) - @property - def single_selection(self): - return len(self.calculation_setups) == 1 + @property + def single_selection(self): + return len(self.calculation_setups) == 1 class HeaderMenu(QtWidgets.QMenu): """ @@ -129,12 +134,16 @@ def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): Args: event (QtGui.QMouseEvent): The mouse double click event. """ - if not self.selectedIndexes(): + index = self.indexAt(event.pos()) + + if not index.isValid(): return - index = self.indexAt(event.pos()) + row = self.model().row(index) + if row is None: + return - actions.CSOpen.run(index.internalPointer()["name"]) + app.actions.CSOpen.run(row["name"]) def dragMoveEvent(self, event) -> None: @@ -164,36 +173,30 @@ def dropEvent(self, event) -> None: functional_units = [{key: 1.0} for key in keys] - actions.CSNew.run(functional_units=functional_units) + app.actions.CSNew.run(functional_units=functional_units) -class CalculationSetupsItem(widgets.ABDataItem): +class CalculationSetupsModel(core.ABTreeModel): """ - An item representing a calculation setup in the tree view. + A model representing the data for the calculation setups. """ - def fontData(self, col: int, key: str): + + def fontData(self, index): """ - Provides font data for the item. + Provides font data for the model. Args: - col (int): The column index. - key (str): The key for which to provide font data. + index: The index for which to provide font data. Returns: - QtGui.QFont: The font data for the item. + QtGui.QFont: The font data for the index. """ - font = super().fontData(col, key) - if key == "name": - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - + column_name = self.column_name(index) -class CalculationSetupsModel(widgets.ABItemModel): - """ - A model representing the data for the databases. + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = CalculationSetupsItem + return None diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py new file mode 100644 index 000000000..e8a2fe6c1 --- /dev/null +++ b/activity_browser/app/panes/database_products.py @@ -0,0 +1,608 @@ +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, QModelIndex + +import bw2data as bd + +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, is_node_biosphere, nodes_to_excel + + +NODETYPES = { + "all_nodes": [], + "processes": ["process", "multifunctional", "processwithreferenceproduct", "nonfunctional"], + "products": ["product", "processwithreferenceproduct", "waste"], + "biosphere": ["natural resource", "emission", "inventory indicator", "economic", "social"], +} + + +class DatabaseProductsPane(widgets.ABAbstractPane): + """ + A widget that displays products related to a specific database. + + Attributes: + database (bd.Database): The database to display products for. + model (ProductModel): The model containing the data for the products. + table_view (ProductView): The view displaying the products. + search (widgets.ABLineEdit): The search bar for quick search. + """ + def __init__(self, parent, db_name: str): + """ + Initializes the DatabaseProductsPane widget. + + Args: + parent (QtWidgets.QWidget): The parent widget. + db_name (str): The name of the database to display products for. + """ + self.name = "database_products_pane_" + db_name + + super().__init__(parent) + + self.database = bd.Database(db_name) + self.title = db_name + 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_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() + self.loading_spinner.setRange(0, 0) # Indeterminate/busy indicator + self.loading_spinner.setTextVisible(False) + self.loading_spinner.setMaximumWidth(200) + self.loading_spinner.setMaximumHeight(20) + + self.loading_label = widgets.ABLabel("Loading database...") + self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + font = self.loading_label.font() + font.setPointSize(14) + 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() + self.sync() + + def build_layout(self): + # Create a stacked layout to switch between loading and table view + self.stacked_layout = QtWidgets.QStackedLayout() + + # Page 0: Loading indicator with spinner + loading_widget = QtWidgets.QWidget(self) + loading_layout = QtWidgets.QVBoxLayout(loading_widget) + loading_layout.addStretch() + loading_layout.addWidget(self.loading_spinner, alignment=Qt.AlignmentFlag.AlignCenter) + loading_layout.addWidget(self.loading_label) + loading_layout.addStretch() + self.stacked_layout.addWidget(loading_widget) + + # Page 1: Table view + table_widget = QtWidgets.QWidget(self) + table_layout = QtWidgets.QVBoxLayout(table_widget) + table_layout.setSpacing(0) + table_layout.setContentsMargins(0, 0, 0, 0) + 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.addLayout(top_bar) + layout.addLayout(self.stacked_layout) + + # Set the table view as the central widget of the window + self.setLayout(layout) + + def connect_signals(self): + app.signals.metadata.synced.connect(self.on_metadata_changed) + app.signals.database.deleted.connect(self.on_database_deleted) + + 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 + self.update_loading_state() + + if any(db == self.database.name for db, code in added | updated | deleted): + self.sync() + + def update_loading_state(self): + """ + Updates the loading state based on whether primary metadata has loaded. + Shows the loading indicator if primary data is still loading, otherwise shows the table. + """ + if app.metadata.loader.secondary_status == "done": + # Show table view + self.stacked_layout.setCurrentIndex(1) + else: + # Show loading indicator + self.stacked_layout.setCurrentIndex(0) + + 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() + + 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) + + 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) + + self.table_view.reset() + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from the database products. + + Returns: + pd.DataFrame: The DataFrame containing the products data. + """ + t = time() + cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] + + 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()] + props_df = pd.DataFrame(list(props_df.get("properties")), index=props_df.key) + props_df.rename(lambda col: f"property_{col}", axis="columns", inplace=True) + + df = df.merge( + props_df, + left_on="key", + right_index=True, + how="left", + ) + + 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].reset_index(drop=True) + + def on_database_deleted(self, db_name: str): + """ + Handles the database deleted signal by closing the widget if the database is deleted. + + Args: + db_name (str): The name of the deleted database. + """ + if db_name == self.database.name: + self.deleteLater() + + def on_mode_switch(self, check: Qt.CheckState): + """ + Handles the mode switch between simple and detailed view. + + Args: + check (Qt.CheckState): The check state of the toggle. + """ + self.simple = check == Qt.CheckState.Unchecked + self.update_table_style() + self.update_column_visibility() + + +class ProductView(ui.widgets.ABTreeView): + """ + A view that displays the products in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "categories": delegates.ListDelegate, + "key": delegates.StringDelegate, + "processor": delegates.StringDelegate, + "node": delegates.CardDelegate, + } + + class ContextMenu(ui.widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.ActivityOpen, p.selected_activities, + text="Open process" if len(p.selected_activities) == 1 else "Open processes", + enable=len(p.selected_activities) > 0 + ), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.ActivityNewProcess, p.db_name, + enable=not database_is_locked(p.db_name), + ), + lambda m, p: m.add(app.actions.ActivityDuplicate, p.selected_activities, + text="Duplicate process" if len(p.selected_activities) == 1 else "Duplicate processes", + enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), + ), + lambda m, p: m.add(app.actions.ActivityDuplicateToDB, p.selected_activities, + text="Duplicate process to database" if len(p.selected_activities) == 1 else "Duplicate processes to database", + enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), + ), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.ActivityDelete, p.selected_activities, + text="Delete process" if len(p.selected_activities) == 1 else "Delete processes", + enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), + ), + lambda m, p: m.add(app.actions.ActivityDelete, p.selected_products, + text="Delete product" if len(p.selected_products) == 1 else "Delete products", + enable=len(p.selected_products) > 0 and not + database_is_locked(p.db_name) and not + database_is_legacy(p.db_name), + ), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.CSNew, + functional_units=[{prod: m.get_functional_unit_amount(prod)} for prod in p.selected_products], + enable=len(p.selected_products) > 0, + text="Create setup" + ), + lambda m, p: m.add(app.actions.ActivitySDFToClipboard, p.selected_products, + enable=len(p.selected_products) > 0, + ), + ] + + @staticmethod + def get_functional_unit_amount(key): + from activity_browser.bwutils.commontasks import refresh_node + excs = list(refresh_node(key).upstream(["production"])) + exc = excs[0] if len(excs) == 1 else {} + return exc.get("amount", 1.0) + + def __init__(self, parent: DatabaseProductsPane, db_name: str): + """ + Initializes the ProductView. + + Args: + parent (DatabaseProductsPane): The parent widget. + db_name (str): The name of the database. + """ + super().__init__(parent) + self.setSortingEnabled(True) + self.setDragEnabled(True) + 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): + """ + Sets the default column delegates for the view. + """ + super().setDefaultColumnDelegates() + + columns = self.model().columns() + for i, col_name in enumerate(columns): + if not col_name.startswith("property_"): + continue + # Set the delegate for property columns + self.setItemDelegateForColumn(i, self.propertyDelegate) + + def mouseDoubleClickEvent(self, event) -> None: + """ + Handles the mouse double click event to open the selected activities. + + Args: + event: The mouse double click event. + """ + 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]: + """ + Returns the selected products. + + Returns: + list[tuple]: The list of selected products. + """ + keys = self.model().values_from_indices("key", self.selectedIndexes()) + types = self.model().values_from_indices("type", self.selectedIndexes()) + + return list({key for key, type in zip(keys, types) if not type == "nonfunctional"}) + + @property + def selected_activities(self) -> list[tuple]: + """ + Returns the selected activities. + + Returns: + list[tuple]: The list of selected activities. + """ + processors = self.model().values_from_indices("processor", self.selectedIndexes()) + keys = self.model().values_from_indices("key", self.selectedIndexes()) + + return list({processor if not pd.isna(processor) else key for processor, key in zip(processors, keys)}) + + +class ProductModel(ui.core.ABTreeModel): + #-- flag overrides --- + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + return True + + # -- 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", "node"]: + return None + + 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 column_name in ["name", "node"] and node_type in NODETYPES["biosphere"]: + return icons.qicons.biosphere + 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) + if column_name not in ["name", "product"]: + return None + + row = self.row(index) + + html_tooltip = f""" + {row.get('product')}
+ {row.get('name')}
+
+ {row.get('unit')} | {row.get('location')} | {row.get('type')} + """ + + return html_tooltip + + 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 = set(self.values_from_indices("key", indices)) + 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/layouts/panes/databases.py b/activity_browser/app/panes/databases.py similarity index 60% rename from activity_browser/layouts/panes/databases.py rename to activity_browser/app/panes/databases.py index 10af03971..92b4d1ece 100644 --- a/activity_browser/layouts/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -1,14 +1,16 @@ import datetime +from loguru import logger -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt import bw2data as bd import pandas as pd -from activity_browser import signals, actions, bwutils -from activity_browser.ui import widgets, icons, delegates -from activity_browser.ui.menu_bar import ImportDatabaseMenu +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 class DatabasesPane(widgets.ABAbstractPane): @@ -30,8 +32,10 @@ def __init__(self, parent): parent (QtWidgets.QWidget): The parent widget. """ super().__init__(parent) + self._populate_later_flag = False + + self.model = DatabasesModel(parent=self) self.view = DatabasesView() - self.model = DatabasesModel() self.view.setModel(self.model) self.view.setAlternatingRowColors(True) @@ -44,10 +48,10 @@ def connect_signals(self): """ Connects the signals to the appropriate slots. """ - signals.meta.databases_changed.connect(self.sync) - signals.project.changed.connect(self.sync) - signals.database.deleted.connect(self.sync) - signals.database_read_only_changed.connect(self.sync) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.metadata.synced.connect(self.syncLater) + app.signals.database.deleted.connect(self.syncLater) + app.signals.database_read_only_changed.connect(self.syncLater) def build_layout(self): """ @@ -58,13 +62,32 @@ def build_layout(self): layout.setContentsMargins(5, 0, 5, 5) self.setLayout(layout) + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + def sync(self): """ Synchronizes the model with the current state of the databases. """ - self.model.setDataFrame(self.build_df()) - self.view.resizeColumnToContents(0) - self.view.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_df() + self.model.set_dataframe(df) + self.view.resizeColumnToContents(1) + self.view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) def build_df(self) -> pd.DataFrame: """ @@ -85,7 +108,7 @@ def build_df(self) -> pd.DataFrame: "name": name, "depends": ", ".join(bd.databases[name].get("depends", [])), "modified": dt, - "records": bwutils.commontasks.count_database_records(name), + "records": count_database_records(name), "read_only": bd.databases[name].get("read_only", True), "default_allocation": bd.databases[name].get("default_allocation", "unspecified"), "backend": bd.databases[name].get("backend") @@ -111,11 +134,11 @@ class DatabasesView(widgets.ABTreeView): class ExportDatabaseContextMenu(widgets.ABMenu): menuSetup = [ lambda m: m.setTitle("Export database" if len(m.parent().selected_databases) == 1 else "Export databases"), - lambda m, p: m.add(actions.DatabaseExportExcel, p.selected_databases if p.selected_databases else [], + lambda m, p: m.add(app.actions.DatabaseExportExcel, p.selected_databases if p.selected_databases else [], enable=len(p.selected_databases) >= 1, text="to .xlsx", ), - lambda m, p: m.add(actions.DatabaseExportBW2Package, p.selected_databases if p.selected_databases else [], + lambda m, p: m.add(app.actions.DatabaseExportBW2Package, p.selected_databases if p.selected_databases else [], enable=len(p.selected_databases) >= 1, text="to .bw2package", ), @@ -123,20 +146,21 @@ class ExportDatabaseContextMenu(widgets.ABMenu): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.DatabaseNew), + lambda m, p: m.add(app.actions.DatabaseNew), lambda m: m.addMenu(ImportDatabaseMenu(m)), lambda m, p: m.addMenu(DatabasesView.ExportDatabaseContextMenu(parent=p)), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.DatabaseDelete, p.selected_databases if p.selected_databases else [], + lambda m, p: m.add(app.actions.DatabaseDelete, p.selected_databases if p.selected_databases else [], enable=len(p.selected_databases) >= 1, text="Delete databases" if len(p.selected_databases) > 1 else "Delete database", ), - lambda m, p: m.add(actions.DatabaseDuplicate, p.selected_databases[0] if p.selected_databases else None, + 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(actions.DatabaseProcess, p.selected_databases[0] if p.selected_databases else None, + 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(), - lambda m, p: m.add(actions.DatabaseSetReadonly, p.selected_databases[0] if p.selected_databases else None, + lambda m, p: m.add(app.actions.DatabaseSetReadonly, p.selected_databases[0] if p.selected_databases else None, not m.selected_readonly, enable=len(p.selected_databases) == 1, text="Unlock database" if m.selected_readonly else "Lock database", @@ -153,7 +177,9 @@ def selected_readonly(self): """ if not self.parent().selected_databases: return None - return self.parent().selectedIndexes()[0].internalPointer()["read_only"] + index = self.parent().selectedIndexes()[0] + row = self.parent().model().row(index) + return row.get("read_only") if row is not None else None class HeaderMenu(QtWidgets.QMenu): """ @@ -175,14 +201,18 @@ def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): if not index.isValid(): return super().mouseDoubleClickEvent(event) - db_name = index.internalPointer()["name"] + row = self.model().row(index) + if row is None: + return super().mouseDoubleClickEvent(event) + + db_name = row.get("name") - if index.column() == 0: - read_only = index.internalPointer()["read_only"] - actions.DatabaseSetReadonly.run(db_name, not read_only) + if index.column() == 1: + read_only = row.get("read_only") + app.actions.DatabaseSetReadonly.run(db_name, not read_only) return - actions.DatabaseOpen.run([db_name]) + app.actions.DatabaseOpen.run([db_name]) def keyPressEvent(self, event: QtGui.QKeyEvent): """ @@ -193,7 +223,7 @@ def keyPressEvent(self, event: QtGui.QKeyEvent): """ if event.key() == Qt.Key_Delete: if self.selected_databases: - actions.DatabaseDelete.run(self.selected_databases) + app.actions.DatabaseDelete.run(self.selected_databases) return super().keyPressEvent(event) @@ -208,69 +238,75 @@ def selected_databases(self) -> list: """ if not self.selectedIndexes(): return [] - return list(set([i.internalPointer()["name"] for i in self.selectedIndexes()])) + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) -class DatabasesItem(widgets.ABDataItem): +class DatabasesModel(core.ABTreeModel): """ - An item representing a database in the tree view. + A model representing the data for the databases. """ - def decorationData(self, col: int, key: str): + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Provides decoration data for the item. + Provides decoration data for the model. Args: - col (int): The column index. - key (str): The key for which to provide decoration data. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - The decoration data for the item. + The decoration data for the index. """ - if key == "read_only": - return icons.qicons.locked if self["read_only"] else icons.qicons.empty - return super().decorationData(col, key) + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None - def displayData(self, col: int, key: str): + if column_name == "read_only": + return icons.qicons.locked if row.get("read_only") else icons.qicons.empty + + return None + + def displayData(self, index: QtCore.QModelIndex) -> any: """ - Provides display data for the item. + Provides display data for the model. Args: - col (int): The column index. - key (str): The key for which to provide display data. + index (QtCore.QModelIndex): The index for which to provide display data. Returns: - The display data for the item. + The display data for the index. """ - if key == "read_only": + column_name = self.column_name(index) + row = self.row(index) + + if row is None: return None - return super().displayData(col, key) - def fontData(self, col: int, key: str): + if column_name == "read_only": + return None + + return row.get(column_name) + + def fontData(self, index: QtCore.QModelIndex) -> any: """ - Provides font data for the item. + Provides font data for the model. Args: - col (int): The column index. - key (str): The key for which to provide font data. + index (QtCore.QModelIndex): The index for which to provide font data. Returns: - QtGui.QFont: The font data for the item. + QtGui.QFont: The font data for the index. """ - font = super().fontData(col, key) - if key == "name": - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font + column_name = self.column_name(index) + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font -class DatabasesModel(widgets.ABItemModel): - """ - A model representing the data for the databases. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = DatabasesItem + return None def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.ItemDataRole.DisplayRole): """ @@ -284,8 +320,8 @@ def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.Ite Returns: The header data for the model. """ - if section == 0 and role == Qt.ItemDataRole.DisplayRole: + if section == 1 and role == Qt.ItemDataRole.DisplayRole: return "" - if section == 0 and role == Qt.ItemDataRole.DecorationRole: + if section == 1 and role == Qt.ItemDataRole.DecorationRole: return icons.qicons.unlocked return super().headerData(section, orientation, role) diff --git a/activity_browser/layouts/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py similarity index 57% rename from activity_browser/layouts/panes/impact_categories.py rename to activity_browser/app/panes/impact_categories.py index a4216874f..f909a8581 100644 --- a/activity_browser/layouts/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -1,10 +1,10 @@ from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Qt +from loguru import logger import bw2data as bd import pandas as pd -from activity_browser import signals, actions +from activity_browser import app, app from activity_browser.ui import widgets, core, delegates @@ -14,8 +14,8 @@ class ImpactCategoriesPane(widgets.ABAbstractPane): def __init__(self, parent=None): super().__init__(parent) + self.model = ImpactCategoriesModel(parent=self) self.view = ImpactCategoriesView() - self.model = ImpactCategoriesModel() self.view.setModel(self.model) self.view.setSelectionMode(QtWidgets.QTableView.SingleSelection) @@ -31,7 +31,6 @@ def __init__(self, parent=None): self.build_layout() self.connect_signals() - self.load() def build_layout(self): layout = QtWidgets.QVBoxLayout() @@ -42,29 +41,22 @@ def build_layout(self): self.setLayout(layout) def connect_signals(self): - signals.meta.methods_changed.connect(self.sync) - signals.project.changed.connect(self.sync) - signals.database_read_only_changed.connect(self.sync) - - def load(self): - self.model.setDataFrame(self.build_df()) - self.model.group(1) - self.view.setColumnHidden(1, True) - self.view.setColumnHidden(2, True) - self.view.setColumnHidden(3, True) - self.view.sortByColumn(1, Qt.SortOrder.AscendingOrder) + app.signals.meta.methods_changed.connect(self.sync) + app.signals.database_read_only_changed.connect(self.sync) def sync(self): - self.model.setDataFrame(self.build_df()) + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_df() + self.model.set_dataframe(df, group=["_method_name"]) def build_df(self): df = pd.DataFrame(bd.methods.values()) df["_method_name"] = bd.methods.keys() df["name"] = df["_method_name"].apply(lambda x: x[-1]) - df["groups"] = df["_method_name"].apply(lambda x: x[:-1]) - cols = ["name", "groups", "unit", "num_cfs", "_method_name"] + cols = ["name", "unit", "num_cfs", "_method_name"] if df.empty: return pd.DataFrame(columns=cols) @@ -79,40 +71,32 @@ class ImpactCategoriesView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.MethodNew), + lambda m, p: m.add(app.actions.MethodNew), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.MethodOpen, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodOpen, p.selected_impact_categories, text="Open impact category" if len(p.selected_impact_categories) == 1 else "Open impact categories", enable=len(p.selected_impact_categories) > 0 ), - lambda m, p: m.add(actions.MethodDelete, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodDelete, p.selected_impact_categories, text="Delete impact category" if len( p.selected_impact_categories) == 1 else "Delete impact categories", enable=len(p.selected_impact_categories) > 0 ), - lambda m, p: m.add(actions.MethodDuplicate, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodDuplicate, p.selected_impact_categories, text="Duplicate impact category", enable=len(p.selected_impact_categories) == 1 ), - lambda m, p: m.add(actions.MethodRename, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodRename, p.selected_impact_categories, text="Rename impact category", enable=len(p.selected_impact_categories) == 1 ), ] - @staticmethod - def get_functional_unit_amount(key): - from activity_browser.bwutils import refresh_node - excs = list(refresh_node(key).upstream(["production"])) - exc = excs[0] if len(excs) == 1 else {} - return exc.get("amount", 1.0) - - @property - def database_name(self): - return self.parent().parent().database.name - @property def selected_impact_categories(self): + if not self.selectedIndexes(): + return [] + indices = [i for i in self.selectedIndexes() if i.column() == 0] impact_categories = [] @@ -123,42 +107,17 @@ def selected_impact_categories(self): def mouseDoubleClickEvent(self, event) -> None: if self.selected_impact_categories: - actions.MethodOpen.run(self.selected_impact_categories) - - -class ImpactCategoriesItem(widgets.ABDataItem): - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. + app.actions.MethodOpen.run(self.selected_impact_categories) - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - return super().flags(col, key) | Qt.ItemFlag.ItemIsDragEnabled +class ImpactCategoriesModel(core.ABTreeModel): + """ + A model representing the data for the impact categories. + """ -class ImpactCategoriesBranchItem(widgets.ABBranchItem): - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - return super().flags(col, key) | Qt.ItemFlag.ItemIsDragEnabled - - -class ImpactCategoriesModel(widgets.ABItemModel): - dataItemClass = ImpactCategoriesItem - branchItemClass = ImpactCategoriesBranchItem + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + """Enable drag for all items.""" + return True def mimeData(self, indices: list[QtCore.QModelIndex]): """ @@ -180,12 +139,40 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): return data def get_impact_categories(self, index: QtCore.QModelIndex): - if isinstance(index.internalPointer(), self.dataItemClass): - return [index.internalPointer()["_method_name"]] - + """ + Get all impact category method names for the given index. + + For leaf nodes (full depth paths), returns the single method name. + For branch nodes (partial depth paths), returns all child method names. + + Args: + index: The index to get impact categories for. + + Returns: + list: List of method name tuples. + """ + if not index.isValid(): + return [] + + node = index.internalPointer() + + if not isinstance(node, core.TreeNode): + return [] + + # If this is a leaf node, return its method name + if node.is_leaf: + row = self.row(index) + if row is not None: + return [row["_method_name"]] + return [] + + # If this is a branch node, collect all child method names recursively ics = [] - for i, child in enumerate(index.internalPointer().children().values()): - child_index = self.createIndex(i, 0, child) + for i, child_node in enumerate(node.children): + if i >= node.loaded_count: + break # Only process loaded children + child_index = self.createIndex(i, 0, child_node) ics += self.get_impact_categories(child_index) + return ics diff --git a/activity_browser/signals.py b/activity_browser/app/signalling.py similarity index 60% rename from activity_browser/signals.py rename to activity_browser/app/signalling.py index 0915e0952..16fd0d744 100644 --- a/activity_browser/signals.py +++ b/activity_browser/app/signalling.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger from time import time -from qtpy.QtCore import QObject, Signal, SignalInstance +from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer, QEvent from blinker import signal as blinker_signal -log = getLogger(__name__) + class NodeSignals(QObject): @@ -50,6 +50,46 @@ class MetaSignals(QObject): calculation_setups_changed: SignalInstance = Signal(object, object) +class MetaDataSignals(QObject): + """Signals for MetaDataStore updates.""" + synced: SignalInstance = Signal(set, set, set) # added, updated, deleted + + def __init__(self, parent=None): + from activity_browser.bwutils.metadata import MetaDataStore + super().__init__(parent) + + self._metadata = MetaDataStore() + self._flusher = QTimer(self, interval=100) + self._flusher.timeout.connect(self._flush_metadata) + self._flusher.start() + + def _flush_metadata(self): + added, updated, deleted = self._metadata.flush_mutations() + + if not (added or updated or deleted): + return + + t = time() + self.synced.emit(added, updated, deleted) + + logger.log("SIGNAL", f"Metadata: synced: {time() - t:.2f} seconds") + +class SettingSignals(QObject): + changed = Signal() # Settings have changed + + def __init__(self, parent=None): + from activity_browser.bwutils.settings import Settings + + super().__init__(parent) + Settings().changed.connect(self.emit_changed) + + def emit_changed(self, *args, **kwargs): + """Emit the changed signal.""" + t = time() + self.changed.emit() + logger.log("SIGNAL", f"Settings: changed: {time() - t:.2f} seconds") + + class ABSignals(QObject): """Signals used for the Activity Browser should be defined here. While arguments can be passed to signals, it is good practice not to do this if possible. @@ -62,37 +102,39 @@ class ABSignals(QObject): database = DatabaseSignals() project = ProjectSignals() meta = MetaSignals() + metadata = MetaDataSignals() parameter = ParameterSignals() + settings = SettingSignals() - import_project = Signal() # Import a project - export_project = Signal() # Export the current project + # import_project = Signal() # Import a project + # export_project = Signal() # Export the current project database_selected = Signal(str) # This database was selected (opened) | name of database database_read_only_changed = Signal(str, bool) # The read_only state of database changed | name of database, read-only state - database_tab_open = Signal(str) # This database tab is being viewed by user | name of database - add_activity_to_history = Signal(tuple) - safe_open_activity_tab = Signal(tuple) # Open activity details tab in read-only mode | key of activity - unsafe_open_activity_tab = Signal(tuple) # Open activity details tab in editable mode | key of activity - close_activity_tab = Signal(tuple) # Close this activity details tab | key of activity - open_activity_graph_tab = Signal(tuple) # Open the graph-view tab | key of activity - edit_activity = Signal(str) # An activity in this database may now be edited | name of database - added_parameter = Signal(str, str, str) # This parameter has been added | name of the parameter, amount, type (project, database or activity) - parameters_changed = Signal() # The parameters have changed - parameter_scenario_sync = Signal(int, object, bool) # Synchronize this data for table | index of the table, dataframe with scenario data, include default scenario - parameter_superstructure_built = Signal(int, object) # Superstructure built from scenarios | index of the table, dataframe with scenario data - set_default_calculation_setup = Signal() # Show the default (first) calculation setup - calculation_setup_changed = Signal() # Calculation setup was changed - calculation_setup_selected = Signal(str) # This calculation setup was selected (opened) | name of calculation setup - lca_calculation = Signal(dict) # Generate a calculation setup | dictionary with name, type (simple/scenario) and potentially scenario data - delete_method = Signal(tuple, str) # Delete this method | tuple of impact category, level of tree OR the proxy - method_selected = Signal(tuple) # This method was selected (opened) | tuple of method + # database_tab_open = Signal(str) # This database tab is being viewed by user | name of database + # add_activity_to_history = Signal(tuple) + # safe_open_activity_tab = Signal(tuple) # Open activity details tab in read-only mode | key of activity + # unsafe_open_activity_tab = Signal(tuple) # Open activity details tab in editable mode | key of activity + # close_activity_tab = Signal(tuple) # Close this activity details tab | key of activity + # open_activity_graph_tab = Signal(tuple) # Open the graph-view tab | key of activity + # edit_activity = Signal(str) # An activity in this database may now be edited | name of database + # added_parameter = Signal(str, str, str) # This parameter has been added | name of the parameter, amount, type (project, database or activity) + # parameters_changed = Signal() # The parameters have changed + # parameter_scenario_sync = Signal(int, object, bool) # Synchronize this data for table | index of the table, dataframe with scenario data, include default scenario + # parameter_superstructure_built = Signal(int, object) # Superstructure built from scenarios | index of the table, dataframe with scenario data + # set_default_calculation_setup = Signal() # Show the default (first) calculation setup + # calculation_setup_changed = Signal() # Calculation setup was changed + # calculation_setup_selected = Signal(str) # This calculation setup was selected (opened) | name of calculation setup + # lca_calculation = Signal(dict) # Generate a calculation setup | dictionary with name, type (simple/scenario) and potentially scenario data + # delete_method = Signal(tuple, str) # Delete this method | tuple of impact category, level of tree OR the proxy + # method_selected = Signal(tuple) # This method was selected (opened) | tuple of method monte_carlo_finished = Signal() # The monte carlo calculations are finished - new_statusbar_message = Signal(str) # Update the statusbar this message | message - restore_cursor = Signal() # Restore the cursor to normal - project_updates_available = Signal(str, int) # Project name and number of updates available - toggle_show_or_hide_tab = Signal(str) # Show/Hide the tab with this name | name of tab - show_tab = Signal(str) # Show this tab | name of tab - hide_tab = Signal(str) # Hide this tab | name of tab - hide_when_empty = Signal() # Show/Hide tab when it has/does not have sub-tabs + # new_statusbar_message = Signal(str) # Update the statusbar this message | message + # restore_cursor = Signal() # Restore the cursor to normal + # project_updates_available = Signal(str, int) # Project name and number of updates available + # toggle_show_or_hide_tab = Signal(str) # Show/Hide the tab with this name | name of tab + # show_tab = Signal(str) # Show this tab | name of tab + # hide_tab = Signal(str) # Hide this tab | name of tab + # hide_when_empty = Signal() # Show/Hide tab when it has/does not have sub-tabs plugin_selected = Signal(str, bool) # This plugin was/was not selected | name of plugin, selected state def __getattribute__(self, item): @@ -145,17 +187,17 @@ def _on_signaleddataset_on_save(self, sender, old, new): if isinstance(new, ActivityDataset): t = time() self.node.changed.emit(new, old) - log.debug(f"Activity changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: changed: {time() - t:.2f} seconds") elif isinstance(new, ExchangeDataset): t = time() self.edge.changed.emit(new, old) - log.debug(f"Exchange changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Edge: changed: {time() - t:.2f} seconds") elif isinstance(new, (ProjectParameter, DatabaseParameter, ActivityParameter)): t = time() self.parameter.changed.emit(new, old) - log.debug(f"Parameter changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Parameter: changed: {time() - t:.2f} seconds") else: - log.debug(f"Unknown dataset type changed: {type(new)}") + logger.debug(f"Unknown dataset type changed: {type(new)}") def _on_signaleddataset_on_delete(self, sender, old): from bw2data.backends import ActivityDataset, ExchangeDataset @@ -164,90 +206,90 @@ def _on_signaleddataset_on_delete(self, sender, old): if isinstance(old, ActivityDataset): t = time() self.node.deleted.emit(old) - log.debug(f"Activity deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: deleted: {time() - t:.2f} seconds") elif isinstance(old, ExchangeDataset): t = time() self.edge.deleted.emit(old) - log.debug(f"Exchange deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Edge: deleted: {time() - t:.2f} seconds") elif isinstance(old, (ProjectParameter, DatabaseParameter, ActivityParameter)): t = time() self.parameter.deleted.emit(old) - log.debug(f"Parameter deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Parameter: deleted: {time() - t:.2f} seconds") else: - log.debug(f"Unknown dataset type deleted: {type(old)}") + logger.debug(f"Unknown dataset type deleted: {type(old)}") def _on_activity_database_change(self, sender, old, new): t = time() self.node.database_change.emit(old, new) - log.debug(f"Activity db changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: database_change: {time() - t:.2f} seconds") def _on_activity_code_change(self, sender, old, new): t = time() self.node.code_change.emit(old, new) - log.debug(f"Activity code changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: code_change: {time() - t:.2f} seconds") def _on_database_delete(self, sender, name): t = time() self.database.deleted.emit(name) - log.debug(f"Database deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Database: deleted: {time() - t:.2f} seconds") def _on_database_reset(self, sender, name): from bw2data import Database t = time() self.database.reset.emit(Database(name)) - log.debug(f"Database reset signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Database: reset: {time() - t:.2f} seconds") def _on_database_write(self, sender, name): from bw2data import Database t = time() self.database.written.emit(Database(name)) - log.debug(f"Database write signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Database: written: {time() - t:.2f} seconds") def _on_project_changed(self, ds): t = time() self.project.changed.emit(ds, self._project_dataset) self._project_dataset = ds - log.debug(f"Project changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Project: changed: {time() - t:.2f} seconds") def _on_project_created(self, ds): t = time() self.project.created.emit() - log.debug(f"Project created signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Project: created: {time() - t:.2f} seconds") def _on_database_metadata_change(self, sender, old, new): t = time() self.meta.databases_changed.emit(old, new) - log.debug(f"DB metadata changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Meta: databased_changed: {time() - t:.2f} seconds") def _on_methods_metadata_change(self, sender, old, new): t = time() self.meta.methods_changed.emit(old, new) - log.debug(f"Methods metadata changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Meta: methods_changed: {time() - t:.2f} seconds") def _on_cs_metadata_change(self, sender, old, new): t = time() self.meta.calculation_setups_changed.emit(old, new) - log.debug(f"CS metadata changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Meta: calculation_setups_changed: {time() - t:.2f} seconds") def _on_method_write(self, sender): t = time() self.method.changed.emit(sender) - log.debug(f"Method changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Method: changed: {time() - t:.2f} seconds") def _on_method_deregister(self, sender): t = time() self.method.deleted.emit(sender) - log.debug(f"Method deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Method: deleted: {time() - t:.2f} seconds") def _on_parameter_recalculate(self, sender, *args, **kwargs): t = time() self.parameter.recalculated.emit() - log.debug(f"Param recalculated signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Parameter: recalculated: {time() - t:.2f} seconds") def _on_parameterized_exchange_recalculate(self, sender, *args, **kwargs): t = time() self.edge.recalculated.emit() - log.debug(f"Param exchange recalculated signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Edge: recalculated: {time() - t:.2f} seconds") def patch_methods_datastore(): @@ -275,12 +317,12 @@ def patch_projects(): from bw2data.project import ProjectManager def delete_project(self, name=None, delete_dir=False): + from activity_browser.app import signals original_delete(self, name, delete_dir) + t = time() signals.project.deleted.emit(name) + logger.log("SIGNAL", f"Project: deleted: {time() - t:.2f} seconds") original_delete = ProjectManager.delete_project setattr(ProjectManager, "delete_project", delete_project) - - -signals = ABSignals() diff --git a/activity_browser/bwutils/README.md b/activity_browser/bwutils/README.md new file mode 100644 index 000000000..acbd57bf8 --- /dev/null +++ b/activity_browser/bwutils/README.md @@ -0,0 +1,56 @@ +# 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 loading and caching for quick access +- **`searchengine/`** - Fuzzy search functionality for dataframes +- **`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 +``` + +## 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 diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index f15c26be6..2cdbf9dd5 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -3,19 +3,4 @@ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. """ -import bw_functional -from .commontasks import cleanup_deleted_bw_projects as cleanup -from .commontasks import (refresh_node, refresh_node_or_none, refresh_parameter, refresh_edge, refresh_edge_or_none, - parameters_in_scope, exchanges_to_sdf, database_is_locked, database_is_legacy, projects_by_last_opened, - node_group, is_node_product, is_node_biosphere, is_node_process) -from .metadata import AB_metadata -from .montecarlo import MonteCarloLCA -from .multilca import MLCA, Contributions -from .pedigree import PedigreeMatrix -from .sensitivity_analysis import GlobalSensitivityAnalysis -from .superstructure import SuperstructureContributions, SuperstructureMLCA -from .uncertainty import (CFUncertaintyInterface, ExchangeUncertaintyInterface, - ParameterUncertaintyInterface, - get_uncertainty_interface) -from .utils import Parameter diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 8b616f899..9853c1998 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -1,12 +1,13 @@ +import os import hashlib import textwrap from datetime import datetime -from logging import getLogger +from loguru import logger from collections import OrderedDict import arrow import pandas as pd -import peewee as pw +import numpy as np import bw2data as bd from bw2data.parameters import ParameterBase, ProjectParameter, DatabaseParameter, ActivityParameter, Group @@ -14,11 +15,8 @@ from functools import lru_cache -from .metadata import AB_metadata from .utils import Parameter -log = getLogger(__name__) - """ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. @@ -105,7 +103,7 @@ def cleanup_deleted_bw_projects() -> None: NOTE: This cannot be done from within the AB. """ n_dir = bd.projects.purge_deleted_directories() - log.info(f"Deleted {n_dir} unused project directories!") + logger.info(f"Deleted {n_dir} unused project directories!") def projects_by_last_opened(): @@ -165,8 +163,9 @@ def count_database_records(name: str) -> int: """To account for possible brightway database types that do not implement the __len__ method. """ + from activity_browser.app import metadata try: - return len(AB_metadata.dataframe.loc[name]) + return len(metadata.dataframe.loc[name]) except KeyError: return 0 @@ -195,11 +194,14 @@ def get_activity_name(key, str_length=22): return ",".join(key.get("name", "").split(",")[:3])[:str_length] +def is_node_product_or_waste(node: tuple | int | bd.Node) -> bool: + return is_node_product(node) or is_node_waste(node) + def is_node_product(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) raw_type = node._document.type - if raw_type in ["product", "waste", "processwithreferenceproduct"]: + if raw_type in ["product", "processwithreferenceproduct"]: return True if raw_type == "process" and len(node.upstream(kinds=["production"])): @@ -207,6 +209,15 @@ def is_node_product(node: tuple | int | bd.Node) -> bool: return False +def is_node_waste(node: tuple | int | bd.Node) -> bool: + node = refresh_node(node) + raw_type = node._document.type + + if raw_type == "waste": + return True + + return False + def is_node_biosphere(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) @@ -225,12 +236,12 @@ def is_node_process(node: tuple | int | bd.Node) -> bool: return False -def refresh_node(node: tuple | int | bd.Node) -> bd.Node: +def refresh_node(node: tuple | int | np.int64 | bd.Node) -> bd.Node: if isinstance(node, bd.Node): node = bd.get_node(id=node.id) elif isinstance(node, tuple): node = bd.get_node(key=node) - elif isinstance(node, int): + elif isinstance(node, (int, np.int64)): node = bd.get_node(id=node) else: raise ValueError("Activity must be either a tuple, int or Node instance") @@ -381,16 +392,18 @@ def identify_activity_type(activity): def generate_copy_code(key: tuple) -> str: """Generate a new code to use when copying an activity""" + from activity_browser.app import metadata + db, code = key - metadata = AB_metadata.get_database_metadata(db) + meta = metadata.get_database_metadata(db) if "_copy" in code: code = code.split("_copy")[0] copies = ( - metadata["key"] + meta["key"] .apply(lambda x: x[1] if code in x[1] and "_copy" in x[1] else None) .dropna() .to_list() - if not metadata.empty + if not meta.empty else [] ) if not copies: @@ -452,7 +465,7 @@ def get_exchanges_in_scenario_difference_file_notation(exchanges): except: # The input activity does not exist. remove the exchange. - log.error( + logger.error( "Something did not work with the following exchange: {}. It was removed from the list.".format( exc ) @@ -494,3 +507,59 @@ def get_LCIA_method_name_dict(keys: list) -> dict: values: brightway2 method tuples """ return {", ".join(key): key for key in keys} + + +# Common tasks +def savefilepath( + default_file_name: str = "AB_file", file_filter: str = "All Files (*.*)" +): + """A central function to get a safe file path.""" + from qtpy import QtWidgets + + safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=None, + caption="Choose location for saving", + dir=os.path.join(os.path.expanduser("~"), safe_name), + filter=file_filter, + ) + return filepath + + +def get_templates() -> dict: + import platformdirs, os + + base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") + template_dir = os.path.join(base_dir, "templates") + os.makedirs(template_dir, exist_ok=True) + + collection = {} + + for file in os.listdir(template_dir): + if file.endswith(".tar.gz"): + collection[file[:-7]] = os.path.join(template_dir, 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/ecospold2biosphereimporter.py b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py index 181a61eab..949d43fa1 100644 --- a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py +++ b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py @@ -1,6 +1,6 @@ import os from zipfile import ZipFile -from logging import getLogger +from loguru import logger from bw2io.importers import Ecospold2BiosphereImporter from bw2io.importers.ecospold2_biosphere import EMISSIONS_CATEGORIES @@ -9,9 +9,20 @@ from activity_browser.mod import bw2data as bd from ...info import __ei_versions__ -from ...utils import sort_semantic_versions -log = getLogger(__name__) + +def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: + """Return a sorted (default highest to lowest) list of semantic versions. + + Sorts based on the semantic versioning system. + """ + return list( + sorted( + versions, + key=lambda x: tuple(map(int, x.split("."))), + reverse=highest_to_lowest, + ) + ) def create_default_biosphere3(version) -> None: @@ -19,12 +30,12 @@ def create_default_biosphere3(version) -> None: # format version number to only Major/Minor version = version[:3] - if version == sort_semantic_versions(__ei_versions__)[0][:3]: - log.debug(f"Installing biosphere version >{version}<") + if version == __ei_versions__[0][:3]: + logger.debug(f"Installing biosphere version >{version}<") # most recent version eb = Ecospold2BiosphereImporter() else: - log.debug(f"Installing legacy biosphere version >{version}<") + logger.debug(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB eb = ABEcospold2BiosphereImporter(version=version) eb.apply_strategies() @@ -56,7 +67,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(__file__), "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in sort_semantic_versions(__ei_versions__): + for ei_version in __ei_versions__: use_version = ei_version fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" @@ -74,7 +85,7 @@ def extract_flow_data(o): ) as file: root = objectify.parse(file).getroot() - log.debug(f"Installing biosphere {use_version} for chosen version {version}") + logger.debug(f"Installing biosphere {use_version} for chosen version {version}") flow_data = bd.utils.recursive_str_to_unicode( [extract_flow_data(ds) for ds in root.iterchildren()] ) diff --git a/activity_browser/bwutils/exporters.py b/activity_browser/bwutils/exporters.py index b8014c4ab..9ec71cf36 100644 --- a/activity_browser/bwutils/exporters.py +++ b/activity_browser/bwutils/exporters.py @@ -5,11 +5,10 @@ from typing import Union import xlsxwriter +import bw2data as bd from bw2io.export.csv import reformat from bw2io.export.excel import CSVFormatter, create_valid_worksheet_name -from activity_browser.mod import bw2data as bd - from .importers import ABPackage from .pedigree import PedigreeMatrix diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py new file mode 100644 index 000000000..6fec07a8e --- /dev/null +++ b/activity_browser/bwutils/filesystem.py @@ -0,0 +1,25 @@ +import platformdirs +from pathlib import Path + +import bw2data as bd + + +def get_package_path() -> Path: + path = Path(__file__).resolve().parents[1] + path.mkdir(parents=True, exist_ok=True) + return path + +def get_appdata_path() -> Path: + path = Path(platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="pylca")) + path.mkdir(parents=True, exist_ok=True) + return path + +def get_project_path() -> Path: + path = bd.projects.dir + path.mkdir(parents=True, exist_ok=True) + return path + +def get_project_ab_path() -> Path: + 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 96bafa27b..69dbb49ea 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -19,14 +19,13 @@ normalize_biosphere_names, normalize_units, set_code_by_activity_hash, strip_biosphere_exc_locations) - -from activity_browser.mod import bw2data as bd +import bw2data as bd from .errors import LinkingFailed 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): @@ -127,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) @@ -134,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/ecoinvent_importer.py b/activity_browser/bwutils/io/ecoinvent_importer.py index b584f3c23..909843617 100644 --- a/activity_browser/bwutils/io/ecoinvent_importer.py +++ b/activity_browser/bwutils/io/ecoinvent_importer.py @@ -5,7 +5,7 @@ from io import BytesIO from lxml import objectify from functools import partial -from logging import getLogger +from loguru import logger import tqdm import bw2data as bd @@ -35,7 +35,7 @@ update_social_flows_in_older_consequential, ) -log = getLogger(__name__) + class Ecoinvent7zImporter: @@ -72,7 +72,7 @@ def install_ecoinvent(self, db_name, biosphere_name: str = "biosphere3"): """ # if the db already exists, warn the user of the impending overwriting and delete the existing database if db_name in bd.databases: - log.warning(f"Database already exists, overwriting {db_name}") + logger.warning(f"Database already exists, overwriting {db_name}") bd.Database(db_name).delete(warn=False) if self.is_compressed: @@ -123,7 +123,7 @@ def apply_strategies(self, db_data, biosphere_name): return db_data def read_archive_to_bytes(self) -> {str: BytesIO}: - log.info("Extracting .7z archive to memory") + logger.info("Extracting .7z archive to memory") with py7zr.SevenZipFile(self.archive_path, mode='r') as archive: # Find all .spold dataset files file_list = [ @@ -138,7 +138,7 @@ def read_archive_to_bytes(self) -> {str: BytesIO}: def process_bytes(self, spold_bytes: {str: BytesIO}, db_name: str) -> list: with multiprocessing.Pool(processes=multiprocessing.cpu_count()) as pool: - log.info(f"Extracting XML data from {len(spold_bytes)} datasets") + logger.info(f"Extracting XML data from {len(spold_bytes)} datasets") results = [ pool.apply_async( self.extract_activity, diff --git a/activity_browser/bwutils/metadata/README.md b/activity_browser/bwutils/metadata/README.md new file mode 100644 index 000000000..e3ac4b93f --- /dev/null +++ b/activity_browser/bwutils/metadata/README.md @@ -0,0 +1,54 @@ +# metadata + +Metadata management for activities, databases, and methods. + +## Overview + +This directory handles storage, retrieval, and management of metadata associated with LCI data in Activity Browser. The MetaDataStore provides quick access to reading node data. + +## Purpose + +Metadata management provides: +- **In memory** - Quicker access to ranges of nodes +- **Unpacked data blob** - Unpack the data blob from the sqlite for quick access +- **Search enhancement** - Fuzzy search capabilities on metadata fields + +## Metadata Types + +See `fields.py` for defined metadata fields and schemas. Common types include: +- **code** - Activity codes +- **name** - Activity names +- **synonyms** - Alternative names + +## Storage +Metadata is cached separately from Brightway2's native storage to allow faster access and searching. It is stored as a pickle on each flush. + +## 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(activity_key, fields=["name", "comment"]) +meta = metadata.get_database_metadata(database_name, fields=["description"]) +``` + +### Searching Metadata +```python +results = metadata.search(query="renewable energy") +``` diff --git a/activity_browser/bwutils/metadata/__init__.py b/activity_browser/bwutils/metadata/__init__.py index f4aa82ad7..a49f85c5e 100644 --- a/activity_browser/bwutils/metadata/__init__.py +++ b/activity_browser/bwutils/metadata/__init__.py @@ -1 +1,3 @@ -from .metadata import AB_metadata \ 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 c77ef3118..7533feac4 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,56 +1,94 @@ -import subprocess import sqlite3 -import sys import pickle -from logging import getLogger +import os +from multiprocessing import Pool +from loguru import logger from typing import Literal - import pandas as pd -import bw2data as bd -from bw2data.backends import sqlite3_lci_db -from qtpy import QtCore +from qtpy.QtCore import QObject, QThread, Signal, SignalInstance -from activity_browser import signals, application -from activity_browser.ui.core import threading +from activity_browser.bwutils.settings import Settings from .metadata import MetaDataStore -from .fields import secondary_types, primary, secondary - -log = getLogger(__name__) +from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields -class MDSLoader(QtCore.QObject): +class MDSLoader(QObject): primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" def __init__(self, mds: MetaDataStore): - super().__init__(mds) + super().__init__(parent=mds) self.mds = mds + self.thread: QThread | None = None self.connect_signals() def connect_signals(self): - signals.project.changed.connect(self.on_project_changed) + from bw2data import signals + + # Connect to Brightway's project_changed signal + signals.project_changed.connect(self.on_project_changed) - def on_project_changed(self): + def on_project_changed(self, sender): + """Called when the Brightway project changes.""" self.load_project() 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" - # start loading threads - thread = SecondaryLoadThread(self) - thread.setObjectName("SecondaryLoadThread-MDSLoader") - thread.done.connect(self.secondary_load_project) - thread.start(databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath)) + # check for valid cache and load from it if available + if self._has_cache() and Settings()["metadatastore"]["caching_enabled"]: + self.cache_load_project() + return + + # start loading thread for secondary metadata + self.thread = SecondaryLoadThread( + databases=list(bd.databases), + sqlite_db=str(sqlite3_lci_db._filepath), + parent=self, + ) + 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 + with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset", con) @@ -58,7 +96,7 @@ def primary_load_project(self): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - log.debug(f"Primary metadata loaded with {len(primary_df)} rows") + logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = primary_df for idx in primary_df.index: @@ -67,28 +105,50 @@ 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)) - log.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") - self.mds.dataframe = pd.concat([self.mds.dataframe[primary], secondary_df], axis=1) + assert all(secondary_df.index.isin(self.mds.keys)) + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + 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): - # start loading threads - thread = SecondaryLoadThread(self) - thread.done.connect(self.secondary_load_database) - thread.start(databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath)) + 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 + self.thread = SecondaryLoadThread( + databases=[database_name], + sqlite_db=str(sqlite3_lci_db._filepath), + parent=self, + ) + self.thread.result.connect(self.secondary_load_database) + self.thread.start() # load primary metadata in the main thread self.primary_load_database(database_name) def primary_load_database(self, database_name: str): + from bw2data.backends import sqlite3_lci_db + with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset WHERE database = '{database_name}'", con) @@ -96,69 +156,188 @@ def primary_load_database(self, database_name: str): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - log.debug(f"Primary metadata loaded with {len(primary_df)} rows") + logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = pd.concat([self.mds.dataframe, primary_df]) 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)): - log.debug("Secondary database metadata dropping rows") + logger.debug("Secondary database metadata dropping rows") secondary_df = secondary_df[secondary_df.index.isin(indices)] - log.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 -class SecondaryLoadThread(threading.ABThread): - done: QtCore.SignalInstance = QtCore.Signal(pd.DataFrame, str) + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + lci_path = filesystem.get_project_path() / "lci" / "databases.db" - def run_safely(self, databases: list[str], sqlite_db: str): - processes = [self.open_load_process(db, sqlite_db) for db in databases] + if not cache_path.exists() or not lci_path.exists(): + return False - full_df = pd.DataFrame() - for proc in processes: - stdout_data, stderr_data = proc.communicate() - if proc.returncode != 0: - log.error(f"Error loading metadata: {stderr_data.decode()}") - continue - df = pickle.loads(stdout_data) - if df.empty: - continue + cache_mtime = cache_path.stat().st_mtime + lci_mtime = lci_path.stat().st_mtime - full_df = pd.concat([full_df, df]) + return cache_mtime >= lci_mtime - self.done.emit(full_df, sqlite_db) + def _cache_check(self, cached_df: pd.DataFrame) -> bool: + import bw2data as bd + from bw2data.backends import sqlite3_lci_db - def open_load_process(self, database_name: str, sqlite_db: str) -> subprocess.Popen: - import activity_browser.bwutils.metadata._sub_loader as sl + 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 - return subprocess.Popen( - [sys.executable, sl.__file__, str(sqlite_db), database_name] + secondary, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + 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 Settings()["metadatastore"]["searcher_enabled"] is False: + logger.debug("Skipping searcher initialization due to settings") + 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, parent): + super().__init__(parent=parent) + self.databases = databases + self.sqlite_db = sqlite_db + + def run(self): + """Execute the loading in a background thread.""" + try: + 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]) + + except Exception as e: + 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]): + 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 \ No newline at end of file diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index ffab3ad23..4d63b315c 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,26 +1,33 @@ -from time import time -from logging import getLogger -from typing import Literal +from typing import Literal, Optional +from loguru import logger -import pandas as pd - -from qtpy.QtCore import Qt, QObject, Signal, SignalInstance, QTimer - -from .fields import all, all_types +from qtpy.QtCore import QObject +import pandas as pd -log = getLogger(__name__) +from activity_browser.bwutils.settings import Settings +from .fields import all_fields, all_types class MetaDataStore(QObject): - synced: SignalInstance = Signal(set, set, set) # added, updated, deleted - + """Singleton class to manage metadata storage, loading, updating, and searching.""" + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls, *args, **kwargs) + cls._instance._initialized = False + return cls._instance + def __init__(self, parent=None): - from activity_browser import application from .loader import MDSLoader from .updater import MDSUpdater + from .searcher import MDSSearcher - super().__init__(parent) + if self._initialized: + return + self._initialized = True + super().__init__(parent=parent) self._dataframe = pd.DataFrame() @@ -30,9 +37,7 @@ def __init__(self, parent=None): self.loader = MDSLoader(self) self.updater = MDSUpdater(self) - self.flusher: QTimer | None = None - - self.moveToThread(application.thread()) + self.searcher: MDSSearcher | None = None # initialized by the loader @property def dataframe(self) -> pd.DataFrame: @@ -40,15 +45,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,48 +88,186 @@ def register_mutation(self, key: tuple[str, str], action: Literal["add", "update else: raise ValueError(f"Unknown action: {action}") - if not self.flusher: - self.flusher = QTimer(self, interval=100) - self.flusher.timeout.connect(self.flush_mutations) - self.flusher.start() + def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], set[tuple[str, str]]]: + from activity_browser.bwutils import filesystem - def flush_mutations(self): if not (self._added or self._updated or self._deleted): - return + return set(), set(), set() + + added = self._added.copy() + updated = self._updated.copy() + deleted = self._deleted.copy() - t = time() - self.synced.emit(self._added, self._updated, self._deleted) + self._added.clear() + self._updated.clear() + self._deleted.clear() - self._added.clear(), self._updated.clear(), self._deleted.clear() + if Settings()["metadatastore"]["caching_enabled"]: + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + self._dataframe.to_pickle(cache_path) - log.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") + 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( + df = self._dataframe.query( " and ".join( [ - f"`{key}` == '{value}'" if not pd.isna(value) else f"`{key}`.isnull()" + 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 _pandas_search(self, query: str, database: str = None, columns: list = None) -> pd.DataFrame: + """Fallback pandas-based search when searcher is not initialized. + + Args: + query: Search query string, may contain key:value parameters + database: Optional database name to restrict search + columns: Optional list of columns to return + + Returns: + DataFrame with matching results + """ + params, clean_query = get_query_parameters(query) + columns = columns if columns is not None else all_fields + + # Start with the full dataframe or database subset + if database and database in self.databases: + df = self._dataframe.loc[[database]] + else: + df = self._dataframe + + if not clean_query.strip(): + # If no search query, just filter by parameters + if params: + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', case=False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + return df[columns] -AB_metadata = MetaDataStore() + # Search across text fields: name, product, synonyms, categories, unit, location + search_fields = ['name', 'product', 'synonyms', 'categories', 'unit', 'location', 'CAS number'] + mask = pd.Series([False] * len(df), index=df.index) + + for field in search_fields: + if field in df.columns: + # Case-insensitive search + mask |= df[field].astype(str).str.contains(clean_query, case=False, na=False) + + df = df[mask] + + # Apply additional parameter filters if any + if params: + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', case=False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + + return df[columns] if columns else df + + def search(self, query: str, columns: list = None) -> pd.DataFrame: + if self.searcher: + # Advanced searcher is initialized, so use that + params, query = get_query_parameters(query) + result = self.searcher.search(query) + return self._meta_from_result(params, result, columns) + + # Fallback to simple pandas search + logger.debug("Using simple pandas search as searcher is not initialized.") + return self._pandas_search(query, columns=columns) + + def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: + if self.searcher: + params, query = get_query_parameters(query) + result = self.searcher.fuzzy_search(query, database=database) + return self._meta_from_result(params, result, columns) + + # Fallback to simple pandas search + logger.debug(f"Using simple pandas search for database '{database}' as searcher is not initialized.") + return self._pandas_search(query, database=database, columns=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 clear_cache(self): + from activity_browser.bwutils import filesystem + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + if cache_path.exists(): + cache_path.unlink() + logger.info("Metadata store cache cleared.") + else: + logger.info("No metadata store cache found to clear.") + + +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 2c969c52f..a0fff878f 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -1,35 +1,40 @@ -from logging import getLogger +from loguru import logger import pandas as pd import numpy as np -import timeit -from qtpy import QtCore - -from activity_browser import signals, application +from qtpy.QtCore import QObject from .metadata import MetaDataStore -from .fields import primary, secondary, all_types +from .fields import primary, secondary, all_types, search_engine_whitelist -log = getLogger(__name__) +class MDSUpdater(QObject): -class MDSUpdater(QtCore.QObject): def __init__(self, mds: MetaDataStore): - super().__init__(mds) - + super().__init__(parent=mds) self.mds = mds self.connect_signals() def connect_signals(self): - signals.node.changed.connect(self.on_node_changed) - signals.node.deleted.connect(self.on_node_deleted) - - signals.meta.databases_changed.connect(self.on_database_changed) - signals.database.deleted.connect(self.on_database_changed) + from bw2data import signals + from bw2data.meta import databases + + # Connect to Brightway signals + signals.signaleddataset_on_save.connect(self.on_signaleddataset_save) + signals.signaleddataset_on_delete.connect(self.on_signaleddataset_delete) + signals.on_database_delete.connect(self.on_database_deleted_bw) + databases._save_signal.connect(self.on_databases_metadata_change) # callbacks - def on_node_changed(self, new, old): + def on_signaleddataset_save(self, sender, old, new): + """Called when a dataset is created or modified in Brightway.""" + from bw2data.backends import ActivityDataset + + # Only process ActivityDataset (nodes), not exchanges or parameters + if not isinstance(new, ActivityDataset): + return + node_data = {f: getattr(new, f) for f in primary} node_data = node_data | {f: new.data.get(f, np.NaN) for f in secondary} node_data["key"] = new.key @@ -37,15 +42,32 @@ def on_node_changed(self, new, old): 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_node_deleted(self, ds): + def on_signaleddataset_delete(self, sender, old): + """Called when a dataset is deleted in Brightway.""" + from bw2data.backends import ActivityDataset + + # Only process ActivityDataset (nodes), not exchanges or parameters + if not isinstance(old, ActivityDataset): + return + try: + # Create a Series with the key to match the delete_node signature + ds = pd.Series({"key": old.key, "id": old.id}, name=old.key) self.delete_node(ds) except KeyError: pass + def on_database_deleted_bw(self, sender, name): + """Called when a database is deleted in Brightway.""" + self.delete_database(name) + + def on_databases_metadata_change(self, sender, old, new): + """Called when the databases metadata changes (e.g., new database added).""" + self.on_database_changed() + def on_database_changed(self) -> None: databases = databases_in_sqlite() @@ -57,31 +79,73 @@ 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) def delete_database(self, db_name: str): + if db_name not in self.mds.databases: + return + 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] @@ -89,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/montecarlo.py b/activity_browser/bwutils/montecarlo.py index 09b4e91df..c6b96a952 100644 --- a/activity_browser/bwutils/montecarlo.py +++ b/activity_browser/bwutils/montecarlo.py @@ -1,19 +1,13 @@ from collections import defaultdict from time import time from typing import Optional, Union -from logging import getLogger +from loguru import logger +import bw2data as bd import bw2calc as bc import bw2data as bd import numpy as np import pandas as pd -from stats_arrays import MCRandomNumberGenerator - -from activity_browser.mod import bw2data as bd - -from .manager import MonteCarloParameterManager - -log = getLogger(__name__) class MonteCarloLCA(object): @@ -87,8 +81,8 @@ def construct_lca( characterization: bool = True, seed_override: Optional[int] = None, ) -> bc.MultiLCA: - log.info(f"Monte Carlo demands: {demands}") - log.info(f"Monte Carlo impact categories: {method_config}") + logger.info(f"Monte Carlo demands: {demands}") + logger.info(f"Monte Carlo impact categories: {method_config}") demands = { index: {bd.get_activity(k).id: v for k, v in fu.items()} for index, fu in demands.items() @@ -307,7 +301,7 @@ def calculate(self, iterations: int = 10, seed: Optional[int] = None, **kwargs): # self.lca.lcia_calculation() self.results[iteration, int(row), col] = self.lca.scores[(m, row)] - log.info( + logger.info( f"Monte Carlo LCA: finished {iterations} iterations for {len(self.func_units)} reference flows and " f"{len(self.methods)} methods in {np.round(time() - start, 2)} seconds." ) @@ -331,10 +325,10 @@ def get_results_by(self, act_key=None, method=None): if act_key: act_index = self.activity_index.get(act_key) - log.info(f"Activity key provided: {act_key} {act_index}") + logger.info(f"Activity key provided: {act_key} {act_index}") if method: method_index = self.method_index.get(method) - log.info(f"Method provided: {method} {method_index}") + logger.info(f"Method provided: {method} {method_index}") if not act_key and not method: return self.results @@ -393,7 +387,7 @@ def get_labels( def perform_MonteCarlo_LCA(project="default", cs_name=None, iterations=10): """Performs Monte Carlo LCA based on a calculation setup and returns the Monte Carlo LCA object.""" - log.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") + logger.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") bd.projects.set_current(project, update=False) # perform Monte Carlo simulation diff --git a/activity_browser/bwutils/multilca.py b/activity_browser/bwutils/multilca.py index 9d709443b..7096de158 100644 --- a/activity_browser/bwutils/multilca.py +++ b/activity_browser/bwutils/multilca.py @@ -1,21 +1,22 @@ from collections import OrderedDict from copy import deepcopy from typing import Iterable, Optional, Union -from logging import getLogger +from loguru import logger +import bw2data as bd import bw2calc as bc import numpy as np import pandas as pd -from qtpy.QtWidgets import QApplication, QMessageBox -from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2analyzer import ABContributionAnalysis from .commontasks import wrap_text from .errors import ReferenceFlowValueError -from .metadata import AB_metadata +from .metadata import MetaDataStore + +metadata = MetaDataStore() + -log = getLogger(__name__) ca = ABContributionAnalysis() @@ -110,6 +111,8 @@ class MLCA(object): """ def __init__(self, cs_name: str, lca_class: bc.LCA = bc.LCA): + from qtpy.QtWidgets import QApplication, QMessageBox + try: cs = bd.calculation_setups[cs_name] except KeyError: @@ -346,14 +349,16 @@ class Contributions(object): DEFAULT_EF_AGGREGATES = ["none"] + DEFAULT_EF_FIELDS def __init__(self, mlca): + from activity_browser.app import metadata + if not isinstance(mlca, MLCA): raise ValueError("Must pass an MLCA object. Passed:", type(mlca)) self.mlca = mlca # Set default metadata keys (those not in the dataframe will be eliminated) - self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in AB_metadata.dataframe.columns] - self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in AB_metadata.dataframe.columns] + self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in metadata.dataframe.columns] + self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in metadata.dataframe.columns] # Specific datastructures for retrieving relevant MLCA data # inventory: inventory, reverse index, metadata keys, metadata fields @@ -503,10 +508,10 @@ def get_labels( translated_keys.append(k) elif isinstance(k, str): translated_keys.append(k) - elif k in AB_metadata.dataframe.index: + elif k in metadata.dataframe.index: translated_keys.append( separator.join( - [str(l) for l in list(AB_metadata.get_metadata(k, fields))] + [str(l) for l in list(metadata.get_metadata(k, fields))] ) ) else: @@ -553,11 +558,11 @@ def join_df_with_metadata( df.index.names = ["database", "code"] # get metadata for rows - keys = [k for k in df.index if k in AB_metadata.dataframe.index] - metadata = AB_metadata.get_metadata(keys, x_fields).astype(object) + keys = [k for k in df.index if k in metadata.dataframe.index] + meta = metadata.get_metadata(keys, x_fields).astype(object) # join data with metadata - joined = metadata.join(df, how="outer") + joined = meta.join(df, how="outer") if special_keys: # replace index keys with labels @@ -565,7 +570,7 @@ def join_df_with_metadata( complete_index = special_keys + keys joined = joined.reindex(complete_index, axis="index", fill_value=0.0) except: - log.error( + logger.error( "Could not put 'Total', 'Rest (+)' and 'Rest (-)' on positions 0, 1 and 2 in the dataframe." ) joined.index = cls.get_labels(joined.index, fields=x_fields) @@ -651,7 +656,7 @@ def _build_inventory( data.columns = Contributions.get_labels(columns, max_length=30) data = pd.merge( - AB_metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" + metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" ) data.reset_index(inplace=True, drop=True) @@ -766,9 +771,9 @@ def aggregate_by_parameters( df = pd.DataFrame(contributions).T columns = list(range(contributions.shape[0])) df.index = rev_index.values() - metadata = AB_metadata.dataframe.loc[AB_metadata.dataframe["id"].isin(keys), fields + ["id"]] + meta = metadata.dataframe.loc[metadata.dataframe["id"].isin(keys), fields + ["id"]] - joined = metadata.merge(df, left_on="id", right_index=True, how="left") + joined = meta.merge(df, left_on="id", right_index=True, how="left") joined.reset_index(inplace=True, drop=True) grouped = joined.groupby(parameters, observed=False) aggregated = grouped[columns].sum() @@ -801,7 +806,7 @@ def _correct_method_index(self, mthd_indx: list) -> dict: conv_dict[mthd] = v return conv_dict - def _contribution_index_cols(self, **kwargs) -> (dict, Optional[Iterable]): + def _contribution_index_cols(self, **kwargs) -> tuple[dict, Optional[Iterable]]: if kwargs.get("method") is not None: return self.mlca.fu_index, self.act_fields return self._correct_method_index(self.mlca.methods), None 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/sensitivity_analysis.py b/activity_browser/bwutils/sensitivity_analysis.py index 1a12f8cc0..8dfbed818 100644 --- a/activity_browser/bwutils/sensitivity_analysis.py +++ b/activity_browser/bwutils/sensitivity_analysis.py @@ -8,16 +8,15 @@ import os import traceback from time import time -from logging import getLogger +from loguru import logger import bw2calc as bc import numpy as np import pandas as pd +import bw2data as bd from SALib.analyze import delta -from activity_browser.mod import bw2data as bd - -from ..settings import ab_settings +# from ..settings import ab_settings from .montecarlo import MonteCarloLCA, perform_MonteCarlo_LCA try: @@ -28,7 +27,7 @@ from bw2calc import GraphTraversal -log = getLogger(__name__) + def get_lca(fu, method): @@ -36,7 +35,7 @@ def get_lca(fu, method): lca = bc.LCA(fu, method=method) lca.lci() lca.lcia() - log.info(f"Non-stochastic LCA score: {lca.score}") + logger.info(f"Non-stochastic LCA score: {lca.score}") # add reverse dictionaries lca.activity_dict_rev, lca.product_dict_rev, lca.biosphere_dict_rev = ( @@ -57,7 +56,7 @@ def filter_technosphere_exchanges(lca, cutoff=0.05, max_calc=1000): for e in res["edges"]: if e.consumer_index != -1: # filter out head introduced in graph traversal technosphere_exchange_indices.append((e.producer_index, e.consumer_index)) - log.info( + logger.info( "TECHNOSPHERE {} filtering resulted in {} of {} exchanges and took {} iterations in {} seconds.".format( lca.technosphere_matrix.shape, len(technosphere_exchange_indices), @@ -78,7 +77,7 @@ def filter_biosphere_exchanges(lca, cutoff=0.005): finv = inv.multiply(abs(inv) > abs(lca.score / (1 / cutoff))) biosphere_exchange_indices = list(zip(*finv.nonzero())) explained_fraction = finv.sum() / lca.score - log.info( + logger.info( "BIOSPHERE {} filtering resulted in {} of {} exchanges ({}% of total impact) and took {} seconds.".format( inv.shape, finv.nnz, @@ -140,7 +139,7 @@ def drop_no_uncertainty_exchanges(excs, indices): if exc.get("uncertainty type") and exc.get("uncertainty type") >= 1: excs_no.append(exc) indices_no.append(ind) - log.info( + logger.info( "Dropping {} exchanges of {} with no uncertainty. {} remaining.".format( len(excs) - len(excs_no), len(excs), len(excs_no) ) @@ -214,7 +213,7 @@ def get_CF_dataframe(lca, only_uncertain_CFs=True): "CF: " + bio_act["name"] + str(bio_act["categories"]) ) - log.info( + logger.info( "CHARACTERIZATION FACTORS filtering resulted in including {} of {} characteriation factors.".format( len(data), len(lca.cf_params), @@ -230,10 +229,10 @@ def get_parameters_DF(mc): if bool(mc.parameter_data): # returns False if dict is empty dfp = pd.DataFrame(mc.parameter_data).T dfp["GSA name"] = "P: " + dfp["name"] - log.info(f"PARAMETERS: {len(dfp)}") + logger.info(f"PARAMETERS: {len(dfp)}") return dfp else: - log.info("PARAMETERS: None included.") + logger.info("PARAMETERS: None included.") return pd.DataFrame() # return emtpy df @@ -330,10 +329,10 @@ def perform_GSA( except Exception as e: traceback.print_exc() # todo: QMessageBox.warning(self, 'Could not perform Delta analysis', str(e)) - log.error("Initializing the GSA failed.") + logger.error("Initializing the GSA failed.") return None - log.info( + logger.info( f"-- GSA --\n Project: {bd.projects.current} CS: {self.mc.cs_name} " f"Activity: {self.activity} Method: {self.method}", ) @@ -421,12 +420,12 @@ def perform_GSA( # self.Y = np.log(np.abs(self.Y)) # this makes it more robust for very uneven distributions of LCA results if np.all(self.Y > 0): # all positive numbers self.Y = np.log(np.abs(self.Y)) - log.info("All positive LCA scores. Log-transformation performed.") + logger.info("All positive LCA scores. Log-transformation performed.") elif np.all(self.Y < 0): # all negative numbers self.Y = -np.log(np.abs(self.Y)) - log.info("All negative LCA scores. Log-transformation performed.") + logger.info("All negative LCA scores. Log-transformation performed.") else: # mixed positive and negative numbers - log.warning( + logger.warning( "Log-transformation cannot be applied as LCA scores overlap zero." ) @@ -440,7 +439,7 @@ def perform_GSA( # perform delta analysis time_delta = time() self.Si = delta.analyze(self.problem, self.X, self.Y, print_to_console=False) - log.info( + logger.info( "Delta analysis took {} seconds".format( np.round(time() - time_delta, 2), ) @@ -457,7 +456,7 @@ def perform_GSA( self.df_final.reset_index(inplace=True) self.df_final["pedigree"] = [str(x) for x in self.df_final["pedigree"]] - log.info("GSA took {} seconds".format(np.round(time() - start, 2))) + logger.info("GSA took {} seconds".format(np.round(time() - start, 2))) def get_save_name(self): save_name = ( @@ -474,11 +473,15 @@ def get_save_name(self): return save_name def export_GSA_output(self): + from ..settings import ab_settings + save_name = "gsa_output_" + self.get_save_name() self.df_final.to_excel(os.path.join(ab_settings.data_dir, save_name)) def export_GSA_input(self): """Export the input data to the GSA with a human readible index""" + from ..settings import ab_settings + X_with_index = pd.DataFrame(self.X.T, index=self.metadata.index) save_name = "gsa_input_" + self.get_save_name() X_with_index.to_excel(os.path.join(ab_settings.data_dir, save_name)) diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py new file mode 100644 index 000000000..43f54a161 --- /dev/null +++ b/activity_browser/bwutils/settings.py @@ -0,0 +1,110 @@ +import copy +import json +import bw2data as bd +import bw2data.signals as bw_signals +import blinker + +from activity_browser.bwutils.filesystem import get_project_ab_path, get_appdata_path + +defaults = { + "startup": { + "brightway_directory": str(bd.projects._base_data_dir), + "saved_brightway_directories": [str(bd.projects._base_data_dir)], + "startup_project": "default", + "shown_panes": ["Databases", "Impact Categories", "Calculation Setups"], + "shown_pages": ["Welcome", "Parameters", "Settings"], + }, + "appearance": { + "theme": "default", + "pane_tab_position": "bottom", + }, + "metadatastore": { + "caching_enabled": True, + "searcher_enabled": True, + }, + "plugins": { + "enabled_plugins": [], + } +} + + +class Settings: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + + self.global_config = {} + self.virtual_config = {} + self.project_config = {} + + self.load_global_settings() + self.load_virtual_settings() + self.load_project_settings() + + self.changed = blinker.Signal() + + bw_signals.project_changed.connect(self.load_project_settings) + + def __getitem__(self, key): + if key in self.virtual_config: + return self.virtual_config[key] + if key in self.project_config: + return self.project_config[key] + if key in self.global_config: + return self.global_config[key] + if key in defaults: + return defaults[key] + raise KeyError(f"Setting '{key}' not found in any configuration level.") + + def __setitem__(self, key, value): + if isinstance(key, tuple): + key, subkey = key + else: + subkey = "global" + + if subkey == "global": + self.global_config[key] = value + elif subkey == "project": + self.project_config[key] = value + else: + raise KeyError("Subkey must be 'global' or 'project'") + + def save(self): + global_path = get_appdata_path() / "settings.json" + json.dump(self.global_config, open(global_path, "w"), indent=4) + + project_path = get_project_ab_path() / "settings.json" + json.dump(self.project_config, open(project_path, "w"), indent=4) + + self.changed.send() + + def load_global_settings(self): + global_path = get_appdata_path() / "settings.json" + self.global_config = json.load(open(global_path)) if global_path.exists() else copy.deepcopy(defaults) + + def load_project_settings(self, *args, **kwargs): + project_path = get_project_ab_path() / "settings.json" + self.project_config = json.load(open(project_path)) if project_path.exists() else {} + + def load_virtual_settings(self): + pass # Implementation later based on environment variables + + def restore_defaults(self): + self.global_config = copy.deepcopy(defaults) + global_path = get_appdata_path() / "settings.json" + json.dump(self.global_config, open(global_path, "w"), indent=4) + + self.project_config = {} + project_path = get_project_ab_path() / "settings.json" + project_path.unlink(missing_ok=True) + + diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index 183402a2b..f23967fb7 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -2,7 +2,7 @@ import hashlib import json from typing import Collection -from logging import getLogger +from loguru import logger from bw2io.errors import StrategyError from bw2io.strategies.generic import (format_nonunique_key_error, @@ -15,7 +15,7 @@ from ..bwutils.errors import ExchangeErrorValues from .commontasks import clean_activity_name -log = getLogger(__name__) + TECHNOSPHERE_TYPES = {"technosphere", "substitution", "production"} BIOSPHERE_TYPES = {"economic", "emission", "natural resource", "social"} @@ -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.""" @@ -152,7 +172,7 @@ def relink_exchanges(exchanges: list, candidates: dict, duplicates: dict) -> tup # Commit changes every 10k exchanges. transaction.commit() except (StrategyError, bd.errors.ValidityError) as e: - log.error(e) + logger.error(e) transaction.rollback() return (remainder, altered, unlinked_exchanges) @@ -165,7 +185,7 @@ def relink_exchanges_existing_db( This means possibly doing a lot of sqlite update calls. """ if old == other.name: - log.info("No point relinking to same database.") + logger.info("No point relinking to same database.") return assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" assert other.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -195,7 +215,7 @@ def relink_exchanges_existing_db( exchanges, candidates, duplicates ) db.process() - log.info( + logger.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) @@ -205,7 +225,7 @@ def relink_exchanges_existing_db( def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: if old == other.name: - log.info("No point relinking to same database.") + logger.info("No point relinking to same database.") return db = bd.Database(act.key[0]) assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -232,7 +252,7 @@ def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: exchanges, candidates, duplicates ) db.process() - log.info( + logger.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) @@ -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/dataframe.py b/activity_browser/bwutils/superstructure/dataframe.py index 8ecf63d6e..d61a50979 100644 --- a/activity_browser/bwutils/superstructure/dataframe.py +++ b/activity_browser/bwutils/superstructure/dataframe.py @@ -10,13 +10,15 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QPushButton +from activity_browser.bwutils.metadata import MetaDataStore from ..errors import ScenarioDatabaseNotFoundError -from ..metadata import AB_metadata from ..utils import Index from .activities import data_from_index from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE +metadata = MetaDataStore() + def superstructure_from_arrays( samples: np.ndarray, indices: np.ndarray, names: List[str] = None @@ -45,7 +47,7 @@ def superstructure_from_arrays( def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float]]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf from bw2data import Edge scenarios = transpose_scenarios_to_exchange_ids(scenarios) @@ -61,7 +63,7 @@ def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float] def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf exc = bd.Edge(bd.Edge.ORMDataset.get_by_id(exchange_id)).as_dict() df = exchanges_to_sdf([exc]) @@ -73,7 +75,7 @@ def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): def mf_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf exc = bf.MFExchange(bf.MFExchange.ORMDataset.get_by_id(exchange_id)) @@ -123,7 +125,7 @@ def arrays_from_indexed_superstructure( ) -> Tuple[np.ndarray, np.ndarray]: result = np.zeros(df.shape[0], dtype=object) - meta = AB_metadata.dataframe["id"] + meta = metadata.dataframe["id"] meta.index = meta.index.to_flat_index() id_df = pd.merge(df, meta, left_on="input", right_index=True).rename(columns={"id":"input_id"}) @@ -285,8 +287,8 @@ def exchange_replace_database( changes = ["from database", "from key", "to database", "to key"] # Load all required databases into the metadata - AB_metadata.add_metadata(replacements.values()) - metadata = AB_metadata.dataframe + metadata.add_metadata(replacements.values()) + meta = metadata.dataframe for idx in df.index: df.loc[idx, changes] = exchange_replace_database( diff --git a/activity_browser/bwutils/superstructure/excel.py b/activity_browser/bwutils/superstructure/excel.py index d2cbb415e..2701b7c61 100644 --- a/activity_browser/bwutils/superstructure/excel.py +++ b/activity_browser/bwutils/superstructure/excel.py @@ -2,14 +2,14 @@ from ast import literal_eval from pathlib import Path from typing import List, Union -from logging import getLogger +from loguru import logger import openpyxl import pandas as pd from .utils import SUPERSTRUCTURE -log = getLogger(__name__) + def convert_tuple_str(x): @@ -24,7 +24,7 @@ def get_sheet_names(document_path: Union[str, Path]) -> List[str]: wb = openpyxl.load_workbook(filename=document_path, read_only=True) return wb.sheetnames except UnicodeDecodeError as e: - log.error("Given document uses an unknown encoding: {}".format(e)) + logger.error("Given document uses an unknown encoding: {}".format(e)) def get_header_index(document_path: Union[str, Path], import_sheet: int): @@ -45,7 +45,7 @@ def get_header_index(document_path: Union[str, Path], import_sheet: int): e.__traceback__ ) except UnicodeDecodeError as e: - log.error("Given document uses an unknown encoding: {}".format(e)) + logger.error("Given document uses an unknown encoding: {}".format(e)) wb.close() raise ValueError("Could not find required headers in given document sheet.") diff --git a/activity_browser/bwutils/superstructure/file_dialogs.py b/activity_browser/bwutils/superstructure/file_dialogs.py index 34bbf6b54..1ba5906dd 100644 --- a/activity_browser/bwutils/superstructure/file_dialogs.py +++ b/activity_browser/bwutils/superstructure/file_dialogs.py @@ -1,7 +1,6 @@ import pandas as pd from qtpy import QtCore, QtWidgets -from ...ui.icons import qicons """ The basic premise of this module is to contain a series of different popup menus that will allow the user @@ -227,6 +226,8 @@ def abQuestion(title, message, button1, button2): An ABPopup instance that provides the basic format and dialog for the popup window. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -270,6 +271,8 @@ def abWarning(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -320,6 +323,8 @@ def abCritical(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) diff --git a/activity_browser/bwutils/superstructure/file_imports.py b/activity_browser/bwutils/superstructure/file_imports.py index ea4ec3fae..5185cf7cc 100644 --- a/activity_browser/bwutils/superstructure/file_imports.py +++ b/activity_browser/bwutils/superstructure/file_imports.py @@ -2,13 +2,13 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Union -from logging import getLogger +from loguru import logger import pandas as pd from ..errors import * -log = getLogger(__name__) + class ABFileImporter(ABC): @@ -75,7 +75,7 @@ def database_and_key_check(data: pd.DataFrame) -> None: ) raise IncompatibleDatabaseNamingError() except IncompatibleDatabaseNamingError as e: - log.error(msg) + logger.error(msg) raise e @staticmethod @@ -103,7 +103,7 @@ def production_process_check(data: pd.DataFrame, scenario_names: list) -> None: ) raise ActivityProductionValueError() except ActivityProductionValueError as e: - log.error(msg) + logger.error(msg) raise e @staticmethod @@ -126,7 +126,7 @@ def na_value_check(data: pd.DataFrame, fields: list) -> None: ) raise InvalidSDFEntryValue() except InvalidSDFEntryValue as e: - log.error(msg) + logger.error(msg) raise e @staticmethod diff --git a/activity_browser/bwutils/superstructure/manager.py b/activity_browser/bwutils/superstructure/manager.py index 83167c589..fa007fb49 100644 --- a/activity_browser/bwutils/superstructure/manager.py +++ b/activity_browser/bwutils/superstructure/manager.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import itertools from typing import List, Optional, Union -from logging import getLogger +from loguru import logger import numpy as np import pandas as pd @@ -21,7 +21,7 @@ from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE, _time_it_, guess_flow_type -log = getLogger(__name__) + EXCHANGE_KEYS = pd.Index(["from key", "to key"]) INDEX_KEYS = pd.Index(["from key", "to key", "flow type"]) @@ -119,7 +119,7 @@ def _combine_columns_intersect(self) -> pd.Index: absent.update(cols.symmetric_difference(scenario_columns(df))) cols = cols.intersection(scenario_columns(df)) for name in absent: - log.warning( + logger.warning( "The following scenario is not found in all provided files and is being dropped: {}".format( name ) @@ -346,7 +346,7 @@ def remove_duplicates(df: pd.DataFrame) -> pd.DataFrame: """ duplicates = df.index.duplicated(keep="last") if duplicates.any(): - log.warning( + logger.warning( "Found and dropped {} duplicate exchanges.".format(duplicates.sum()) ) return df.loc[~duplicates, :] @@ -362,7 +362,7 @@ def build_index(df: pd.DataFrame) -> pd.MultiIndex: """ unknown_flows = df.loc[:, "flow type"].isna() if unknown_flows.any(): - log.warning( + logger.warning( "Not all flow types are known, guessing {} flows".format( unknown_flows.sum() ) @@ -499,7 +499,7 @@ def check_scenario_exchange_values(df: pd.DataFrame, cols: pd.Index): critical.exec_() raise ScenarioExchangeDataNotFoundError elif nas.any(axis=0).any(): - log.warning( + logger.warning( "Replacing empty values from the last loaded scenario difference file" ) if not is_numeric_dtype(np.array(_df.loc[:, cols])): diff --git a/activity_browser/bwutils/superstructure/mlca.py b/activity_browser/bwutils/superstructure/mlca.py index c5e5e8810..89396263a 100644 --- a/activity_browser/bwutils/superstructure/mlca.py +++ b/activity_browser/bwutils/superstructure/mlca.py @@ -3,15 +3,16 @@ import numpy as np import pandas as pd +import bw2data as bd from qtpy.QtWidgets import QPushButton from activity_browser.mod import bw2data as bd -from activity_browser.bwutils import AB_metadata from ..commontasks import format_activity_label from ..errors import ScenarioExchangeNotFoundError from ..multilca import MLCA, Contributions from ..utils import Index +from ..metadata import MetaDataStore from .dataframe import (arrays_from_indexed_superstructure, filter_databases_indexed_superstructure, scenario_names_from_df) @@ -22,6 +23,7 @@ except ModuleNotFoundError: pass # removed in bw25 +metadata = MetaDataStore() class SuperstructureMLCA(MLCA): """Subclass of the `MLCA` class which adds another dimension in the form diff --git a/activity_browser/bwutils/superstructure/utils.py b/activity_browser/bwutils/superstructure/utils.py index 76a3e00b1..f5a7c5cc8 100644 --- a/activity_browser/bwutils/superstructure/utils.py +++ b/activity_browser/bwutils/superstructure/utils.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- import time -from logging import getLogger +from loguru import logger import pandas as pd from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + # Different kinds of indexes, to allow for quick selection of data from # the Superstructure DataFrame. @@ -79,7 +79,7 @@ def _time_it_(func): def wrapper(*args): now = time.time() result = func(*args) - log.info(f"{func} -- {time.time() - now}") + logger.info(f"{func} -- {time.time() - now}") return result return wrapper diff --git a/activity_browser/bwutils/utils.py b/activity_browser/bwutils/utils.py index 95f972893..3b082a81f 100644 --- a/activity_browser/bwutils/utils.py +++ b/activity_browser/bwutils/utils.py @@ -4,7 +4,6 @@ import numpy as np import peewee as pw -from stats_arrays import UncertaintyBase import bw2data as bd from bw2data.backends import ActivityDataset, ExchangeDataset @@ -34,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 03af4642c..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 logging import getLogger - -from .utils import safe_link_fetch, sort_semantic_versions - -log = getLogger(__name__) # get AB version try: @@ -13,55 +6,5 @@ except PackageNotFoundError: __version__ = "0.0.0" - -def get_compatible_versions() -> list: - """Get compatible versions of ecoinvent for this AB version. - - Reads this file on github repo: activity-browser/better_biosphere_handling/compatible_ei_versions.txt'. - Converts file content to available ecoinvent versions for each version of AB. - Finds the correct available versions for this AB version, if failing to read version, - the lowest version in the file is chosen. - """ - try: - # read versions - versions_URL = "https://raw.githubusercontent.com/LCA-ActivityBrowser/activity-browser/main/activity_browser/bwutils/ecoinvent_biosphere_versions/compatible_ei_versions.txt" - page, error = safe_link_fetch(versions_URL) - if not error: - file = page.text - else: - # silently try a local fallback: - log.debug( - f"Reading online compatible ecoinvent versions failed " - f"-attempting local fallback- with this error: {error}" - ) - file_path = os.path.join( - os.path.dirname(__file__), - "bwutils", - "ecoinvent_biosphere_versions", - "compatible_ei_versions.txt", - ) - with open(file_path, "r") as f: - file = f.read() - all_versions = ast.literal_eval(file) - - # select either the latest lower version available or if none available the lowest version for safety - sorted_versions = sort_semantic_versions(all_versions.keys()) - for ab_version in sorted_versions: - if sort_semantic_versions([__version__, ab_version])[0] == __version__: - # current version is higher than or equal to tested AB version: - ei_versions = all_versions[ab_version] - break - else: - ei_versions = all_versions[sorted_versions[-1]] - - log.debug( - f"Following versions of ecoinvent are compatible with AB {__version__}: {ei_versions}" - ) - return ei_versions - - except Exception as error: - log.debug(f"Reading local fallback failed with: {error}") - return ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] - - -__ei_versions__ = get_compatible_versions() +# supported EI versions +__ei_versions__ = ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] diff --git a/activity_browser/layouts/__init__.py b/activity_browser/layouts/__init__.py deleted file mode 100644 index 40a96afc6..000000000 --- a/activity_browser/layouts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/activity_browser/layouts/pages/activity_details/parameters_tab.py b/activity_browser/layouts/pages/activity_details/parameters_tab.py deleted file mode 100644 index 79219bdd7..000000000 --- a/activity_browser/layouts/pages/activity_details/parameters_tab.py +++ /dev/null @@ -1,345 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui - -import pandas as pd -import bw2data as bd - -from activity_browser import signals, actions -from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import refresh_node, refresh_parameter, parameters_in_scope, Parameter, database_is_locked -from activity_browser.bwutils import node_group - - -class ParametersTab(QtWidgets.QWidget): - """ - A widget that displays parameters related to a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display parameters for. - model (ParametersModel): The model containing the data for the parameters. - view (ParametersView): The view displaying the parameters. - """ - def __init__(self, activity, parent=None): - """ - Initializes the ParametersTab widget. - - Args: - activity (tuple | int | bd.Node): The activity to display parameters for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - self.activity = refresh_node(activity) - - self.model = ParametersModel(self.build_df(), self.activity, self) - self.view = ParametersView() - self.view.setModel(self.model) - self.view.expandAll() - - self.view.resizeColumnToContents(0) - self.view.resizeColumnToContents(2) - - self.build_layout() - self.connect_signals() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.view) - - self.setLayout(layout) - - def connect_signals(self): - """ - Connects signals to their respective slots. - """ - signals.parameter.changed.connect(self.sync) - signals.parameter.recalculated.connect(self.sync) - signals.parameter.deleted.connect(self.sync) - - def sync(self): - """ - Synchronizes the widget with the current state of the activity. - """ - self.activity = refresh_node(self.activity) - self.model.setDataFrame(self.build_df()) - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from the parameters in scope of the activity. - - Returns: - pd.DataFrame: The DataFrame containing the parameters data. - """ - data = parameters_in_scope(self.activity) - - translated = [] - - for name, param in data.items(): - row = param._asdict() - row["uncertainty"] = param.data.get("uncertainty type") - row["formula"] = param.data.get("formula") - row["comment"] = param.data.get("comment") - row["_parameter"] = param - row["_activity"] = self.activity - - if param.param_type == "project": - row["_scope"] = f"Current project" - elif param.param_type == "database": - row["_scope"] = f"This database" - elif param.group == node_group(self.activity): - row["_scope"] = "This activity" - else: - row["_scope"] = f"Group: {param.group}" - - translated.append(row) - - columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_activity"] - return pd.DataFrame(translated, columns=columns) - - -class ParametersView(widgets.ABTreeView): - """ - A view that displays the 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(QtWidgets.QMenu): - """ - A context menu for the ParametersView. - - Attributes: - del_param_action (QAction): The action to delete a parameter. - """ - def __init__(self, pos, view: "ParametersView"): - """ - Initializes the ContextMenu. - - Args: - pos: The position of the context menu. - view (ParametersView): The view displaying the parameters. - """ - super().__init__(view) - - index = view.indexAt(pos) - if index.isValid() and isinstance(index.internalPointer(), ParametersItem): - item = index.internalPointer() - param = item.parameter.to_peewee_model() - self.del_param_action = 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 ParametersItem(widgets.ABDataItem): - """ - An item representing a parameter in the tree view. - """ - - @property - def scoped_parameters(self) -> dict[str, Parameter]: - """ - Returns the parameters in scope of this item's parameter. - - Returns: - dict: The parameters in scope. - """ - return parameters_in_scope(parameter=self["_parameter"]) - - @property - def parameter(self) -> Parameter: - """ - Returns the parameter associated with this item. - - Returns: - Parameter: The current parameter. - """ - return refresh_parameter(self["_parameter"]) - - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - flags = super().flags(col, key) - - if key in ["amount", "formula", "uncertainty", "name", "comment"] and not database_is_locked(self["_activity"]["database"]): - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags - - def setData(self, col: int, key: str, value) -> bool: - """ - Sets the data for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if key in ["amount", "formula", "name", "comment"]: - actions.ParameterModify.run(self.parameter, key, value) - - return False - - def decorationData(self, col, key): - """ - Provides decoration data for the item. - - Args: - col: The column index. - key: The key for which to provide decoration data. - - Returns: - The decoration data for the item. - """ - if key not in ["amount"]: - return - - if key == "amount": - if pd.isna(self["formula"]) or self["formula"] is None or self["formula"] == "": - return icons.qicons.empty # empty icon to align the values - return icons.qicons.parameterized - - -class NewParametersItem(widgets.ABDataItem): - """ - An item representing a new parameter in the tree view. - """ - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - flags = super().flags(col, key) - if key == "name": - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags - - def fontData(self, col: int, key: str): - """ - Returns the font data for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the font data. - - Returns: - QtGui.QFont: The font data. - """ - font = super().fontData(col, key) - font.setWeight(font.Weight.ExtraLight) - return font - - def setData(self, col: int, key: str, value) -> bool: - """ - Sets the data for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if key != "name" or value == "": - return False - - parameter = Parameter( - name=value, - group=self["_parameter"]["group"], - param_type=self["_parameter"]["param_type"] - ) - - actions.ParameterNewFromParameter.run(parameter) - return True - - -class ParametersModel(widgets.ABItemModel): - """ - A model representing the data for the parameters. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ParametersItem - - def __init__(self, dataframe, activity, parent=None): - """ - Initializes the ParametersModel. - - Args: - dataframe (pd.DataFrame): The DataFrame containing the parameters data. - activity (tuple | int | bd.Node): The activity to display parameters for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - self.activity = activity - super().__init__(parent, dataframe) - - def createItems(self, dataframe=None) -> list[widgets.ABAbstractItem]: - """ - Creates items from the given DataFrame. - - Args: - dataframe (pd.DataFrame, optional): The DataFrame containing the parameters data. Defaults to None. - - Returns: - list[widgets.ABAbstractItem]: The list of created items. - """ - if dataframe is None: - # If no DataFrame is provided, use the model's default DataFrame. - dataframe = self.dataframe - - items = [] - for scope in ["Current project", "This database", "This activity"]: - # Create a branch item for the current scope. - branch = self.branchItemClass(scope) - - # Iterate over the rows in the DataFrame that match the current scope. - for index, data in dataframe.loc[dataframe._scope == scope].to_dict(orient="index").items(): - # Create a data item for each row and add it to the branch. - self.dataItemClass(index, data, branch) - - # Determine the group and parameter type based on the current scope. - if scope == "Current project": - group, param_type = "project", "project" - elif scope == "This database": - group, param_type = self.activity["database"], "database" - else: - group, param_type = self.activity.id, "activity" - - # If the database is not read-only, add a placeholder for creating a new parameter. - if not bd.databases[self.activity["database"]].get("read_only", True): - NewParametersItem(None, {"name": "New parameter...", "_parameter": { - "group": group, "param_type": param_type - }}, branch) - - # Add the branch to the list of items. - items.append(branch) - - # Return the list of created items. - return items diff --git a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py b/activity_browser/layouts/pages/impact_category_details/impact_category_details.py deleted file mode 100644 index 7f11a6be7..000000000 --- a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py +++ /dev/null @@ -1,247 +0,0 @@ -from qtpy import QtWidgets, QtGui -from qtpy.QtCore import Qt - -import bw2data as bd -import pandas as pd - -from activity_browser import actions, signals -from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import AB_metadata, is_node_biosphere - -from .impact_category_header import ImpactCategoryHeader - - -class ImpactCategoryDetailsPage(QtWidgets.QWidget): - def __init__(self, name: tuple, parent=None): - super().__init__(parent) - self.name = name - self.impact_category = bd.Method(name) - self.is_editable = False - - self.setObjectName(" | ".join(name)) - - self.header = ImpactCategoryHeader(self) - - self.model = CharacterizationFactorsModel(self) - self.view = CharacterizationFactorsView(self) - self.view.setModel(self.model) - self.view.setSortingEnabled(True) - - self.build_layout() - self.connect_signals() - self.sync() - - # resizing name and categories columns - self.view.resizeColumnToContents(0) - self.view.resizeColumnToContents(1) - - def connect_signals(self): - signals.method.renamed.connect(self.on_method_renamed) - signals.method.deleted.connect(self.on_method_deleted) - signals.meta.methods_changed.connect(self.sync) - - def on_method_renamed(self, old_name, new_name): - if self.name == old_name: - self.name = new_name - self.setObjectName(" | ".join(new_name)) - self.setWindowTitle(" | ".join(new_name)) - - def on_method_deleted(self, method): - if method.name == self.name: - self.deleteLater() - - def sync(self): - if self.name not in bd.methods: - self.deleteLater() - return - - self.impact_category = bd.Method(self.name) - self.model.setDataFrame(self.build_df()) - self.header.sync() - - def build_layout(self): - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.header) - layout.addWidget(widgets.ABHLine(self)) - layout.addWidget(self.view) - self.setLayout(layout) - - 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")) - - other = AB_metadata.dataframe[["id", "name", "categories", "database", "unit"]] - - df = df.merge(other, left_on="id", right_on="id").rename(columns={"id": "_id", "data": "_cf"}) - df["_impact_category_name"] = [self.name for i in range(len(df))] - df["_editable"] = self.is_editable - - cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf", "_editable"] - return df[cols] - - -class CharacterizationFactorsView(widgets.ABTreeView): - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "categories": delegates.ListDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m: m.add(actions.CFRemove, m.impact_category_name, m.char_factors, - enable=bool(m.char_factors) and m.is_editable, - text="Remove characterization factor(s)"), - ] - - @property - def is_editable(self): - return self.parent().parent().is_editable - - @property - def impact_category_name(self): - return self.parent().parent().name - - @property - def char_factors(self): - indexes = self.parent().selectedIndexes() - return [(idx.internalPointer()["_id"], idx.internalPointer()["_cf"]) - for idx in indexes if idx.isValid() and idx.column() == 0] - - def __init__(self, parent): - super().__init__(parent) - self.setAcceptDrops(True) - self.setSortingEnabled(True) - self.overlay = None - - def dragEnterEvent(self, event): - """ - Handles the drag enter event. - - Args: - event: The drag enter event. - """ - if not self.parent().is_editable: - event.ignore() - return - - if event.mimeData().hasFormat("application/bw-nodekeylist"): - self.overlay = widgets.ABDropOverlay(self) - self.overlay.show() - event.accept() - else: - event.ignore() - - def dragMoveEvent(self, event): - """Handles the drag move event - required for proper drop indicator.""" - if not self.parent().is_editable: - event.ignore() - return - - if event.mimeData().hasFormat("application/bw-nodekeylist"): - event.accept() - else: - event.ignore() - - def dragLeaveEvent(self, event): - """ - Handles the drag leave event. - - Args: - event: The drag leave event. - """ - if not self.overlay is None: - # Reset the palette on drag leave - self.overlay.deleteLater() - self.overlay = None - - def dropEvent(self, event): - """ - Handles the drop event. - - Args: - event: The drop event. - """ - self.overlay.deleteLater() - self.overlay = None - - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - - # Filter to only biosphere flows - biosphere_keys = [key for key in keys if is_node_biosphere(key)] - - if biosphere_keys: - actions.CFNew.run(self.parent().name, biosphere_keys) - - -class ExchangesItem(widgets.ABDataItem): - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - flags = super().flags(col, key) - - if key in ["amount", "uncertainty"] and self["_editable"]: - return flags | Qt.ItemFlag.ItemIsEditable - return flags - - def decorationData(self, col, key): - """ - Provides decoration data for the item. - - Args: - col: The column index. - key: The key for which to provide decoration data. - - Returns: - The decoration data for the item. - """ - if key == "name": - return icons.qicons.biosphere - - def fontData(self, col: int, key: str): - """ - Returns the font data for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the font data. - - Returns: - QtGui.QFont: The font data. - """ - font = super().fontData(col, key) - - # set the font to bold if it's a production/functional exchange - if key == "name": - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - def setData(self, col: int, key: str, value) -> bool: - """ - Sets the data for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if key not in ["amount"]: - return False - - actions.CFAmountModify.run(self["_impact_category_name"], self["_id"], value) - - -class CharacterizationFactorsModel(widgets.ABItemModel): - dataItemClass = ExchangesItem diff --git a/activity_browser/layouts/pages/parameters/base.py b/activity_browser/layouts/pages/parameters/base.py deleted file mode 100644 index f8e1f86f0..000000000 --- a/activity_browser/layouts/pages/parameters/base.py +++ /dev/null @@ -1,1197 +0,0 @@ -import os -import datetime -from typing import Optional, Any -from logging import getLogger - -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 - -log = getLogger(__name__) - - -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 - log.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): - log.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: - log.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: - log.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/layouts/pages/parameters/parameter_models.py b/activity_browser/layouts/pages/parameters/parameter_models.py deleted file mode 100644 index 1ecb44e31..000000000 --- a/activity_browser/layouts/pages/parameters/parameter_models.py +++ /dev/null @@ -1,544 +0,0 @@ -import itertools -from typing import Iterable, Tuple -from logging import getLogger - -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 actions, signals, application, bwutils -from activity_browser.mod import bw2data as bd -from activity_browser.ui.wizards import UncertaintyWizard - -from .base import BaseTreeModel, EditablePandasModel, TreeItem, PandasModel - -log = getLogger(__name__) - - -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()] - - 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) - - actions.ParameterRename.run(param) - - def delete_parameter(self, proxy: QModelIndex) -> None: - param = self.get_parameter(proxy) - 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) - 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. - log.info( - "Activity {} no longer exists, removing parameter.".format(row["key"]) - ) - 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 - log.warning(f"Broken exchange: {exc}, removing.") - 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 bwutils.utils.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/layouts/pages/parameters/parameter_views.py b/activity_browser/layouts/pages/parameters/parameter_views.py deleted file mode 100644 index 6e6d18766..000000000 --- a/activity_browser/layouts/pages/parameters/parameter_views.py +++ /dev/null @@ -1,287 +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 actions, 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(2, delegates.FormulaDelegate(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(2, delegates.FormulaDelegate(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(2, delegates.FormulaDelegate(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() - 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/layouts/pages/parameters/parameters.py b/activity_browser/layouts/pages/parameters/parameters.py deleted file mode 100644 index 966b04ab0..000000000 --- a/activity_browser/layouts/pages/parameters/parameters.py +++ /dev/null @@ -1,495 +0,0 @@ -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 actions, 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 - - -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 - """ - - 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 = 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 = 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. - """ - - 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() - - -class ParameterScenariosTab(QtWidgets.QWidget): - def __init__(self, parent=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._construct_layout() - self._connect_signals() - - 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. - """ - 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. - """ - pm = manager.ParameterManager() - names, data = zip(*self.tbl.iterate_scenarios()) - - 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/layouts/panes/__init__.py b/activity_browser/layouts/panes/__init__.py deleted file mode 100644 index adc5b1fdc..000000000 --- a/activity_browser/layouts/panes/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -from .database_explorer import DatabaseExplorerPane -from .database_products import DatabaseProductsPane -from .project_manager import ProjectManagerPane -from .databases import DatabasesPane -from .impact_categories import ImpactCategoriesPane -from .calculation_setups import CalculationSetupsPane - - -registered_panes = [ - DatabaseExplorerPane, - DatabaseProductsPane, - ProjectManagerPane, - DatabasesPane, - ImpactCategoriesPane, - CalculationSetupsPane, -] - -shown_panes = [ - DatabasesPane, - ImpactCategoriesPane, - CalculationSetupsPane, -] - -hidden_panes = [ - ProjectManagerPane, -] - -default_panes = shown_panes + hidden_panes diff --git a/activity_browser/layouts/panes/database_explorer.py b/activity_browser/layouts/panes/database_explorer.py deleted file mode 100644 index 909512b98..000000000 --- a/activity_browser/layouts/panes/database_explorer.py +++ /dev/null @@ -1,183 +0,0 @@ -from logging import getLogger - -import pandas as pd -from qtpy import QtWidgets, QtCore, QtGui - -import bw2data as bd - -from activity_browser import signals -from activity_browser.bwutils import AB_metadata -from activity_browser.ui import widgets, application - -log = getLogger(__name__) - -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) - AB_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 = AB_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/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py deleted file mode 100644 index 676bfc28b..000000000 --- a/activity_browser/layouts/panes/database_products.py +++ /dev/null @@ -1,463 +0,0 @@ -from logging import getLogger -from time import time - -import pandas as pd -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - -import bw2data as bd - -from activity_browser import actions, ui, signals, application -from activity_browser.settings import project_settings -from activity_browser.ui import core, widgets, delegates -from activity_browser.bwutils import AB_metadata, database_is_locked, database_is_legacy - -log = getLogger(__name__) - -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 DatabaseProductsPane(widgets.ABAbstractPane): - """ - A widget that displays products related to a specific database. - - Attributes: - database (bd.Database): The database to display products for. - model (ProductModel): The model containing the data for the products. - table_view (ProductView): The view displaying the products. - search (widgets.ABLineEdit): The search bar for quick search. - """ - def __init__(self, parent, db_name: str): - """ - Initializes the DatabaseProductsPane widget. - - Args: - parent (QtWidgets.QWidget): The parent widget. - db_name (str): The name of the database to display products for. - """ - self.name = "database_products_pane_" + db_name - - super().__init__(parent) - self.database = bd.Database(db_name) - self.title = db_name - self.model = ProductModel(self) - - # Create the QTableView and set the model - self.table_view = ProductView(self, db_name=db_name) - self.table_view.setModel(self.model) - - self.search = widgets.ABLineEdit(self) - self.search.setMaximumHeight(30) - self.search.setPlaceholderText("Quick Search") - - # Create loading indicator with spinner - self.loading_spinner = QtWidgets.QProgressBar() - self.loading_spinner.setRange(0, 0) # Indeterminate/busy indicator - self.loading_spinner.setTextVisible(False) - self.loading_spinner.setMaximumWidth(200) - self.loading_spinner.setMaximumHeight(20) - - self.loading_label = widgets.ABLabel("Loading database...") - self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - font = self.loading_label.font() - font.setPointSize(14) - self.loading_label.setFont(font) - self.loading_label.setStyleSheet("color: gray; padding: 10px;") - - self.build_layout() - self.connect_signals() - self.update_loading_state() - self.sync() - - def build_layout(self): - # Create a stacked layout to switch between loading and table view - self.stacked_layout = QtWidgets.QStackedLayout() - - # Page 0: Loading indicator with spinner - loading_widget = QtWidgets.QWidget(self) - loading_layout = QtWidgets.QVBoxLayout(loading_widget) - loading_layout.addStretch() - loading_layout.addWidget(self.loading_spinner, alignment=Qt.AlignmentFlag.AlignCenter) - loading_layout.addWidget(self.loading_label) - loading_layout.addStretch() - self.stacked_layout.addWidget(loading_widget) - - # Page 1: Table view - table_widget = QtWidgets.QWidget(self) - table_layout = QtWidgets.QVBoxLayout(table_widget) - table_layout.setSpacing(0) - table_layout.setContentsMargins(0, 0, 0, 0) - table_layout.addWidget(self.table_view) - self.stacked_layout.addWidget(table_widget) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.search) - layout.addLayout(self.stacked_layout) - - # Set the table view as the central widget of the window - self.setLayout(layout) - - def connect_signals(self): - AB_metadata.synced.connect(self.on_metadata_changed) - signals.database.deleted.connect(self.on_database_deleted) - - self.table_view.filtered.connect(self.search_error) - self.search.textChangedDebounce.connect(self.table_view.setAllFilter) - - def on_metadata_changed(self, added, updated, deleted): - # Check if primary data has finished loading - self.update_loading_state() - - if any(db == self.database.name for db, code in added | updated | deleted): - self.sync() - - def update_loading_state(self): - """ - Updates the loading state based on whether primary metadata has loaded. - Shows the loading indicator if primary data is still loading, otherwise shows the table. - """ - if AB_metadata.loader.secondary_status == "done": - # Show table view - self.stacked_layout.setCurrentIndex(1) - else: - # Show loading indicator - self.stacked_layout.setCurrentIndex(0) - - def sync(self): - """ - Synchronizes the widget with the current state of the database. - """ - t = time() - df = self.build_df() - self.model.setDataFrame(df) - for col in df.columns: - index = self.model.columns().index(col) - if df[col].isna().all(): - self.table_view.hideColumn(index) - else: - self.table_view.showColumn(index) - - log.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from the database products. - - Returns: - pd.DataFrame: The DataFrame containing the products data. - """ - t = time() - cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] - df = AB_metadata.get_database_metadata(self.database.name, cols) - - processors = set(df["processor"].dropna().unique()) - df = df.drop(processors, errors="ignore") - - if not df.properties.isna().all(): - props_df = df[df.properties.notna()] - props_df = pd.DataFrame(list(props_df.get("properties")), index=props_df.key) - props_df.rename(lambda col: f"property_{col}", axis="columns", inplace=True) - - df = df.merge( - props_df, - left_on="key", - right_index=True, - how="left", - ) - - cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type",] - cols += [col for col in df.columns if col.startswith("property")] - - log.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") - - return df[cols] - - def on_database_deleted(self, db_name: str): - """ - Handles the database deleted signal by closing the widget if the database is deleted. - - Args: - db_name (str): The name of the deleted database. - """ - if db_name == self.database.name: - self.deleteLater() - - def event(self, event): - """ - Handles the event to save the state to settings on deferred delete. - - Args: - event: The event to handle. - - Returns: - bool: True if the event was handled, False otherwise. - """ - if event.type() == QtCore.QEvent.Type.DeferredDelete: - self.save_state_to_settings() - - return super().event(event) - - def save_state_to_settings(self): - """ - Saves the state of the table view to the project settings. - """ - project_settings.settings["database_explorer"] = project_settings.settings.get("database_explorer", {}) - project_settings.settings["database_explorer"][self.database.name] = self.table_view.saveState() - project_settings.write_settings() - - def get_state_from_settings(self): - """ - Gets the state from the project settings. - - Returns: - dict: The state of the table view. - """ - return DEFAULT_STATE - - def search_error(self, reset=False): - """ - Handles the search error by changing the search bar color. - - Args: - reset (bool, optional): Whether to reset the search bar color. Defaults to 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 ProductView(ui.widgets.ABTreeView): - """ - A view that displays the products in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "categories": delegates.ListDelegate, - "key": delegates.StringDelegate, - "processor": delegates.StringDelegate, - } - - class ContextMenu(ui.widgets.ABMenu): - menuSetup = [ - lambda m, p: m.add(actions.ActivityOpen, p.selected_activities, - text="Open process" if len(p.selected_activities) == 1 else "Open processes", - enable=len(p.selected_activities) > 0 - ), - lambda m, p: m.add(actions.ActivityGraph, p.selected_activities, - enable=len(p.selected_activities) > 0, - ), - lambda m: m.addSeparator(), - lambda m, p: m.add(actions.ActivityNewProcess, p.db_name, - enable=not database_is_locked(p.db_name), - ), - lambda m, p: m.add(actions.ActivityDuplicate, p.selected_activities, - text="Duplicate process" if len(p.selected_activities) == 1 else "Duplicate processes", - enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), - ), - lambda m, p: m.add(actions.ActivityDuplicateToDB, p.selected_activities, - text="Duplicate process to database" if len(p.selected_activities) == 1 else "Duplicate processes to database", - enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), - ), - lambda m: m.addSeparator(), - lambda m, p: m.add(actions.ActivityDelete, p.selected_activities, - text="Delete process" if len(p.selected_activities) == 1 else "Delete processes", - enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), - ), - lambda m, p: m.add(actions.ActivityDelete, p.selected_products, - text="Delete product" if len(p.selected_products) == 1 else "Delete products", - enable=len(p.selected_products) > 0 and not - database_is_locked(p.db_name) and not - database_is_legacy(p.db_name), - ), - lambda m: m.addSeparator(), - lambda m, p: m.add(actions.CSNew, - functional_units=[{prod: m.get_functional_unit_amount(prod)} for prod in p.selected_products], - enable=len(p.selected_products) > 0, - text="Create setup" - ), - lambda m, p: m.add(actions.ActivitySDFToClipboard, p.selected_products, - enable=len(p.selected_products) > 0, - ), - ] - - @staticmethod - def get_functional_unit_amount(key): - from activity_browser.bwutils import refresh_node - excs = list(refresh_node(key).upstream(["production"])) - exc = excs[0] if len(excs) == 1 else {} - return exc.get("amount", 1.0) - - def __init__(self, parent: DatabaseProductsPane, db_name: str): - """ - Initializes the ProductView. - - Args: - parent (DatabaseProductsPane): The parent widget. - db_name (str): The name of the database. - """ - super().__init__(parent) - self.setSortingEnabled(True) - self.setDragEnabled(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragOnly) - self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) - - self.db_name = db_name - - self.propertyDelegate = delegates.PropertyDelegate(self) - - def setDefaultColumnDelegates(self): - """ - Sets the default column delegates for the view. - """ - super().setDefaultColumnDelegates() - - columns = self.model().columns() - for i, col_name in enumerate(columns): - if not col_name.startswith("property_"): - continue - # Set the delegate for property columns - self.setItemDelegateForColumn(i, self.propertyDelegate) - - def mouseDoubleClickEvent(self, event) -> None: - """ - Handles the mouse double click event to open the selected activities. - - Args: - event: The mouse double click event. - """ - if self.selected_activities: - actions.ActivityOpen.run(self.selected_activities) - - @property - def selected_products(self) -> list[tuple]: - """ - Returns the selected products. - - Returns: - list[tuple]: The list of selected products. - """ - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ProductItem)] - return list({item["key"] for item in items if not item["type"] == "nonfunctional"}) - - @property - def selected_activities(self) -> list[tuple]: - """ - Returns the selected activities. - - Returns: - list[tuple]: The list of selected activities. - """ - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ProductItem)] - return list({item["processor"] if not pd.isna(item["processor"]) else item["key"] for item in items}) - - -class ProductItem(ui.widgets.ABDataItem): - """ - An item representing a product in the tree view. - """ - def decorationData(self, col, key): - """ - Provides decoration data for the item. - - Args: - col: The column index. - key: The key for which to provide decoration data. - - Returns: - The decoration data for the item. - """ - if key == "name" and self["name"]: - if self["type"] == "processwithreferenceproduct": - return ui.icons.qicons.processproduct - if self["type"] in NODETYPES["biosphere"]: - return ui.icons.qicons.biosphere - return ui.icons.qicons.process - if key == "product": - if self["type"] in ["product", "processwithreferenceproduct"]: - return ui.icons.qicons.product - elif self["type"] == "waste": - return ui.icons.qicons.waste - - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - return super().flags(col, key) | Qt.ItemFlag.ItemIsDragEnabled - - def displayData(self, col: int, key: str): - if key.startswith("property_") and not pd.isna(self[key]) and self[key]["normalize"]: - prop = self[key].copy() - prop["unit"] = prop['unit'] + f" / {self['unit']}" - return prop - return super().displayData(col, key) - - -class ProductModel(ui.widgets.ABItemModel): - """ - A model representing the data for the products. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ProductItem - - 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 = set(self.values_from_indices("key", indices)) - 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)) - return data - - @staticmethod - def values_from_indices(key: str, indices: list[QtCore.QModelIndex]): - """ - Returns the values from the given indices. - - Args: - key (str): The key to get the values for. - indices (list[QtCore.QModelIndex]): The indices to get the values for. - - Returns: - list: The list of values. - """ - values = [] - for index in indices: - item = index.internalPointer() - if not item or item[key] is None: - continue - values.append(item[key]) - return values diff --git a/activity_browser/layouts/panes/project_manager.py b/activity_browser/layouts/panes/project_manager.py deleted file mode 100644 index c6e88bd65..000000000 --- a/activity_browser/layouts/panes/project_manager.py +++ /dev/null @@ -1,162 +0,0 @@ -from logging import getLogger - -import pandas as pd -from qtpy import QtWidgets, QtCore - -import bw2data as bd -from bw2io import remote - -from activity_browser import actions, ui, signals, utils -from activity_browser.settings import ab_settings -from activity_browser.ui import widgets - - -log = getLogger(__name__) - - -class ProjectManagerPane(widgets.ABAbstractPane): - title = "Project Manager" - unique = True - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Project Manager") - - self.tabs = QtWidgets.QTabWidget(self) - - self.project_model = widgets.ABItemModel(self) - self.project_model.dataItemClass = ProjectItem - - self.template_model = widgets.ABItemModel(self) - self.template_model.dataItemClass = TemplateItem - - self.project_view = ProjectView(self) - self.project_view.setModel(self.project_model) - - self.template_view = TemplateView(self) - self.template_view.setModel(self.template_model) - - self.sync() - - self.tabs.addTab(self.project_view, "Projects") - self.tabs.addTab(self.template_view, "Templates") - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tabs) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - # connect signals - signals.project.changed.connect(self.sync) - signals.project.deleted.connect(self.sync) - - def sync(self): - self.project_model.setDataFrame(self.build_project_df()) - self.template_model.setDataFrame(self.build_template_df()) - - def build_project_df(self) -> pd.DataFrame: - data = {} - for proj_ds in sorted(bd.projects): - # if for any reason the project data is not a dictionary, log a warning and set it to an empty dict - if not isinstance(proj_ds.data, dict): - log.warning(f"Project {proj_ds.name} has no data dictionary") - proj_ds.data = {} - - data[proj_ds.name] = { - "Name": proj_ds.name, - "Path": proj_ds.dir, - "Version": "Brightway25" if proj_ds.data.get("25", False) else "Legacy" - } - - return pd.DataFrame.from_dict(data, orient="index") - - def build_template_df(self) -> pd.DataFrame: - data = {} - - templates = utils.get_templates() - remote_templates = remote.get_projects() - - for name in sorted(templates): - data[name] = { - "Name": name, - "Path": templates[name], - "Remote": "No" - } - - for name in sorted(remote_templates): - data[name] = { - "Name": name, - "Path": remote_templates[name], - "Remote": "Yes" - } - - return pd.DataFrame.from_dict(data, orient="index") - - -class ProjectView(widgets.ABTreeView): - - class ContextMenu(widgets.ABTreeView.ContextMenu): - def __init__(self, pos, view: "FunctionView"): - from activity_browser.ui.menu_bar import ProjectNewMenu - - super().__init__(pos, view) - items = list({index.internalPointer() for index in view.selectedIndexes()}) - - self.addMenu(ProjectNewMenu(self)) - - if len(items) == 0: - return - - if len(items) == 1: - self.dup_project = actions.ProjectDuplicate.get_QAction(items[0]["Name"]) - self.template_project = actions.ProjectCreateTemplate.get_QAction(items[0]["Name"], view.parent()) - self.addAction(self.dup_project) - self.addAction(self.template_project) - - if len(items) == 1 and len([i for i in items if i["Version"] == "Legacy"]) == 1: - self.migrate_project = actions.ProjectMigrate25.get_QAction(items[0]["Name"]) - self.addAction(self.migrate_project) - - self.del_project = actions.ProjectDelete.get_QAction(view.selected_projects) - self.addAction(self.del_project) - - - def __init__(self, parent: ProjectManagerPane): - super().__init__(parent) - self.setSortingEnabled(True) - self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) - - - @property - def selected_projects(self) -> [str]: - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ProjectItem)] - return list({item["Name"] for item in items if item["Name"] is not None}) - - -class ProjectItem(widgets.ABDataItem): - def decorationData(self, col, key): - if col != 0: - return - return ui.icons.qicons.forward if self["Name"] == ab_settings.startup_project else ui.icons.QIcons.forward - - -class TemplateView(widgets.ABTreeView): - - class ContextMenu(widgets.ABTreeView.ContextMenu): - def __init__(self, pos, view: "FunctionView"): - super().__init__(pos, view) - - items = list({index.internalPointer() for index in view.selectedIndexes()}) - - def __init__(self, parent: ProjectManagerPane): - super().__init__(parent) - self.setSortingEnabled(True) - self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) - - -class TemplateItem(widgets.ABDataItem): - def decorationData(self, col, key): - if col != 0: - return - return ui.icons.qicons.forward if self["Name"] == ab_settings.startup_project else ui.icons.QIcons.forward diff --git a/activity_browser/logger.py b/activity_browser/logger.py deleted file mode 100644 index 31c6402db..000000000 --- a/activity_browser/logger.py +++ /dev/null @@ -1,248 +0,0 @@ -import logging -import os -import time -import sys -from traceback import extract_tb -from types import TracebackType -from typing import Type - -import platformdirs - - -class ABFileHandler(logging.Handler): - """ - LogHandler for the log files. Formats them in semicolon separated CSV files for easy reading. - """ - - headers = [ - "time", - "type", - "thread", - "name", - "file location", - "line number", - "function name", - "message", - ] - - def __init__(self): - super().__init__() - - # create a unique filename based on the datetime - self.filename = "ab_logs" + self.timestamp() + ".csv" - - # set dir and create it if it doesn't exist yet - dir_path = str(platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="ActivityBrowser")) - os.makedirs(dir_path, exist_ok=True) - - # create final filepath of the logfile of this session - self.filepath = os.path.join(dir_path, self.filename) - - # set the global file location - global log_file_location - log_file_location = self.filepath - - # create the logfile and write the headers - with open(self.filepath, "a", encoding='utf-8') as log_file: - log_file.write(";".join(self.headers) + "\n") - - def emit(self, record: logging.LogRecord): - """Handle a new LogRecord""" - # format the message from the record - message = self.format(record) - - # append to the logfile - with open(self.filepath, "a", encoding='utf-8') as log_file: - log_file.write(message) - - # if there's exception info, write the exception traceback to the file as well - if record.exc_info: - exc_message = self.format_exception(record.exc_info[2]) - log_file.write(exc_message) - - def format(self, record: logging.LogRecord) -> str: - """Format a LogRecord""" - # format message to a single line - message = " ".join(str(record.msg).split("\n")) - message = message + " ".join([str(arg) for arg in record.args]) - - # if there is no message left, return nothing - if message == " ": - return "" - - # make sure there a no semicolons - message.replace(";", ":") - - # convert time - struct_time = time.localtime(record.created) - readable_time = time.strftime("%H:%M.%S", struct_time) - - line = f"{readable_time};{record.levelname};{record.threadName};{record.name};{record.pathname};{record.lineno};{record.funcName};{message}" - return f"{line}\n" - - def format_exception(self, traceback: TracebackType) -> str: - """Format the traceback of an exception""" - # extract the traceback - traceback = extract_tb(traceback) - message = "" - - # append a line for each frame in the traceback - for frame in traceback: - line = f";TRACEBACK;;;{frame.filename};{frame.lineno};{frame.name};{frame.line}" - message = f"{message}{line}\n" - - # return the string containing multiple lines - return message - - def timestamp(self) -> str: - """Return a timestamped string, the format provided is: - day of the year _ month _ day - hour _ minute _ second""" - stmp = time.localtime() - return f"-{stmp.tm_year}-{stmp.tm_mon}-{stmp.tm_mday}_{stmp.tm_hour}-{stmp.tm_min}-{stmp.tm_sec}" - - -class ABPycharmHandler(logging.Handler): - """ - LogHandler for the console. Make sure they are all in the same format. Adds badges, and if extended logs are enabled - also the time and a (shortened) logger name. - """ - - badge = { - "INFO": "\u001b[48;5;24m\u001b[38;5;255m INFO \u001b[0m", - "DEBUG": "\u001b[48;5;90m\u001b[38;5;255m DEBUG \u001b[0m", - "EXCEPTION": "\u001b[48;5;88m\u001b[38;5;255m EXCPT \u001b[0m", - "ERROR": "\u001b[48;5;88m\u001b[38;5;255m ERROR \u001b[0m", - "WARNING": "\u001b[48;5;130m\u001b[38;5;255m WARN \u001b[0m", - "PRINT": "\u001b[7m PRINT \u001b[0m", - } - - alias = {"activity_browser": "AB", "brightway2": "BW2"} - - def __init__(self): - super().__init__() - # create a unique filename based on the datetime - self.filename = "pycharm_logs.log" - - # set dir and create it if it doesn't exist yet - dir_path = platformdirs.user_log_dir("ActivityBrowser", "ActivityBrowser") - os.makedirs(dir_path, exist_ok=True) - - # create final filepath of the logfile of this session - self.filepath = os.path.join(dir_path, self.filename) - - def emit(self, record: logging.LogRecord): - """Handle a new LogRecord""" - # format message - message = self.format_log(record) - - # append to the logfile - with open(self.filepath, "a", encoding='utf-8') as log_file: - log_file.write(message) - - # if there's exception info, write the exception traceback to the file as well - if record.exc_info: - exc_message = self.format_exception(record.exc_info[2]) - log_file.write(exc_message) - - def format_log(self, record: logging.LogRecord) -> str: - """Format a LogRecord""" - # format message to a single line - message = " ".join(str(record.msg).split("\n")) - message = message + " ".join([str(arg) for arg in record.args]) - - # if there is no message left, return nothing - if message == " ": - return "" - - # clean-up if the message is a C++ error message - if message.startswith("[") and message.index(":") < message.index("]"): - # most likely a c++ error log, otherwise, very bad luck - i = message.index("]") + 2 - message = message[i:] - - # retrieve the badge - badge = self.badge[record.levelname] - if record.exc_info: - badge = self.badge["EXCEPTION"] - - # get a clean timestamp - time_stamp = time.asctime()[11:19] - - source_str = self.format_source(record.name) - - return f"{time_stamp} {source_str}{badge} {message}\n" - - def format_source(self, name: str) -> str: - """ - The entire source may be too long for the console window. Here we replace known sources with their alias, only - use the first two modules and cut it short if it's still too long - """ - # create list of the module string - module_split = name.split(".") - - # switch a possible alias - for key, alias in self.alias.items(): - if key == module_split[0]: - module_split[0] = alias - - # rebuild only the first two modules - source_str = ".".join(module_split[:2]) - - # adjust length - if len(source_str) >= 20: - source_str = source_str[:16] + "..." - - return source_str.ljust(20) - - def format_exception(self, traceback: TracebackType) -> str: - """Format the traceback of an exception""" - space = 37 - - traceback = extract_tb(traceback) - message = "\u001b[38;5;1m\u001b[1m" - for frame in traceback: - line1 = f'{space * " "}File "{frame.filename}", line {frame.lineno}, in {frame.name}\n' - line2 = f"{space * ' '} {frame.line}\n" - message = message + line1 + line2 - message = f"{message}\u001b[0m" - return message - - -def exception_hook( - error: Type[BaseException], message: BaseException, traceback: TracebackType -): - """Exception hook to catch and log exceptions""" - exc_info = (error, message, traceback) - log = logging.getLogger("exception_hook") - log.exception(f"{error.__name__}: {message}", exc_info=exc_info) - - -def setup_ab_logging(): - # set the root logger's level to 0, this gives us access to all logs - logging.root.setLevel(0) - - # peewee is mad, so set to info - logging.getLogger("peewee").setLevel("INFO") - - # setting up a basic stderr handler - stderr_handler = logging.StreamHandler() - formatter = logging.Formatter( - "%(asctime)s | %(levelname)s | %(message)s", "%H:%M:%S" - ) - stderr_handler.setFormatter(formatter) - stderr_handler.setLevel("DEBUG") - logging.root.addHandler(stderr_handler) - - # # setting up the pycharm handler - # pycharm_handler = ABPycharmHandler() - # logging.root.addHandler(pycharm_handler) - - # setting up the file handler - file_handler = ABFileHandler() - logging.root.addHandler(file_handler) - - # setting up the exception hook - sys.excepthook = exception_hook - - -log_file_location = None 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/bw2io/__init__.py b/activity_browser/mod/bw2io/__init__.py index 236e1b081..8f269f5f2 100644 --- a/activity_browser/mod/bw2io/__init__.py +++ b/activity_browser/mod/bw2io/__init__.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from bw2io import * @@ -7,33 +7,35 @@ -log = getLogger(__name__) + def ab_bw2setup(version): + + raise Exception("This function is deprecated.") + import bw2io as bi from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter from activity_browser.info import __ei_versions__ - from activity_browser.utils import sort_semantic_versions from .migrations import ab_create_core_migrations ab_create_core_migrations() version = version[:3] - if version == sort_semantic_versions(__ei_versions__)[0][:3]: - log.info(f"Installing biosphere version >{version}<") + if version == __ei_versions__[0][:3]: + logger.info(f"Installing biosphere version >{version}<") # most recent version bio_import = ABEcospold2BiosphereImporter() else: - log.info(f"Installing legacy biosphere version >{version}<") + logger.info(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB bio_import = ABEcospold2BiosphereImporter(version=version) bio_import.apply_strategies() - log.info("Writing biosphere database") + logger.info("Writing biosphere database") bio_import.write_database() - log.info("Writing LCIA methods") + logger.info("Writing LCIA methods") create_default_lcia_methods() # patching biosphere @@ -51,7 +53,7 @@ def ab_bw2setup(version): ] for patch in patches: - log.info(f"Applying biosphere patch: {patch}") + logger.info(f"Applying biosphere patch: {patch}") update_bio = getattr(bi.data, patch) update_bio() diff --git a/activity_browser/mod/bw2io/ecoinvent.py b/activity_browser/mod/bw2io/ecoinvent.py index adb5e6563..9f950ca3d 100644 --- a/activity_browser/mod/bw2io/ecoinvent.py +++ b/activity_browser/mod/bw2io/ecoinvent.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from bw2io.ecoinvent import * @@ -7,7 +7,7 @@ from activity_browser.mod.ecoinvent_interface.release import ABEcoinventRelease from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter -log = getLogger(__name__) + def ab_import_ecoinvent_release(version, system_model): @@ -32,27 +32,27 @@ def ab_import_ecoinvent_release(version, system_model): name="biosphere3", filepath=lci_path / "MasterData" / "ElementaryExchanges.xml", ) - log.info("Applying strategies") + logger.info("Applying strategies") bio_import.apply_strategies() - log.info("Writing biosphere database") + logger.info("Writing biosphere database") bio_import.write_database() bd.preferences["biosphere_database"] = "biosphere3" # importing ecoinvent through a ecospold2 importer that implements a progress_slot - log.info("Importing ecoinvent") + logger.info("Importing ecoinvent") db_name = f"ecoinvent-{version}-{system_model}" ei_import = SingleOutputEcospold2Importer( dirpath=str(lci_path / "datasets"), db_name=db_name, biosphere_database_name="biosphere3", ) - log.info("Applying strategies") + logger.info("Applying strategies") ei_import.apply_strategies() - log.info("Writing ecoinvent database") + logger.info("Writing ecoinvent database") ei_import.write_database() # importing all LCIA methods - log.info("Gathering LCIA methods") + logger.info("Gathering LCIA methods") lcia_file = ei.get_excel_lcia_file_for_version(release=release, version=version) sheet_names = get_excel_sheet_names(lcia_file) @@ -69,11 +69,11 @@ def ab_import_ecoinvent_release(version, system_model): raise ValueError( f"Can't find worksheet for characterization factors; expected `CFs`, found {sheet_names}" ) - log.info("Extracting LCIA methods") + logger.info("Extracting LCIA methods") data = dict(ExcelExtractor.extract(lcia_file)) units = header_dict(data[units_sheetname]) - log.info("Mapping LCIA methods") + logger.info("Mapping LCIA methods") cfs = header_dict(data["CFs"]) CF_COLUMN_LABELS = { diff --git a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py index f8d9fb065..b9f644479 100644 --- a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py +++ b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py @@ -2,11 +2,24 @@ from bw2io.importers.ecospold2_biosphere import * import pyprind -import logging +from loguru import logger import os from activity_browser.info import __ei_versions__ -from activity_browser.utils import sort_semantic_versions + + +def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: + """Return a sorted (default highest to lowest) list of semantic versions. + + Sorts based on the semantic versioning system. + """ + return list( + sorted( + versions, + key=lambda x: tuple(map(int, x.split("."))), + reverse=highest_to_lowest, + ) + ) class ABEcospold2BiosphereImporter(Ecospold2BiosphereImporter): @@ -58,7 +71,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(mod.__file__), "ecoinvent_biosphere_versions", "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in sort_semantic_versions(__ei_versions__): + for ei_version in __ei_versions__: use_version = ei_version zip_fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" @@ -111,5 +124,5 @@ def apply_strategies(self, strategies=None, verbose=True): self.apply_strategy(func, verbose) def write_database(self, *args, **kwargs): - logging.getLogger(__name__).info("Writing Biosphere database") + logger.info("Writing Biosphere database") super().write_database(*args, **kwargs) diff --git a/activity_browser/settings.py b/activity_browser/settings.py deleted file mode 100644 index 43ef58bb6..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 logging import getLogger - -import bw2data as bd - -import platformdirs -from qtpy.QtWidgets import QMessageBox - -from .signals import signals - -log = getLogger(__name__) -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(): - log.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. - """ - log.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/static/icons/exchanges/link.png b/activity_browser/static/icons/exchanges/link.png new file mode 100644 index 000000000..7742c7dbf Binary files /dev/null and b/activity_browser/static/icons/exchanges/link.png differ diff --git a/activity_browser/static/icons/exchanges/relink.png b/activity_browser/static/icons/exchanges/relink.png new file mode 100644 index 000000000..b01fc7120 Binary files /dev/null and b/activity_browser/static/icons/exchanges/relink.png differ diff --git a/activity_browser/static/icons/exchanges/unlink.png b/activity_browser/static/icons/exchanges/unlink.png new file mode 100644 index 000000000..6d681334f Binary files /dev/null and b/activity_browser/static/icons/exchanges/unlink.png differ diff --git a/activity_browser/static/icons/main/activitybrowser.ico b/activity_browser/static/icons/main/activitybrowser.ico new file mode 100644 index 000000000..29bb1b917 Binary files /dev/null and b/activity_browser/static/icons/main/activitybrowser.ico differ diff --git a/activity_browser/static/icons/main/star.png b/activity_browser/static/icons/main/star.png new file mode 100644 index 000000000..326905549 Binary files /dev/null and b/activity_browser/static/icons/main/star.png differ 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/__init__.py b/activity_browser/ui/core/__init__.py index 47cb5c6c7..ab50b2c48 100644 --- a/activity_browser/ui/core/__init__.py +++ b/activity_browser/ui/core/__init__.py @@ -1 +1,2 @@ from .mimedata import ABMimeData +from .tree_model import ABTreeModel, TreeNode diff --git a/activity_browser/ui/application.py b/activity_browser/ui/core/application.py similarity index 73% rename from activity_browser/ui/application.py rename to activity_browser/ui/core/application.py index f75a365ce..674bdf050 100644 --- a/activity_browser/ui/application.py +++ b/activity_browser/ui/core/application.py @@ -1,7 +1,6 @@ -import sys - +import os from pathlib import Path -from logging import getLogger +from loguru import logger from qtpy import QtGui, QtWidgets, QtCore, PYSIDE6 from qtpy.QtCore import Qt @@ -9,16 +8,23 @@ from activity_browser.static import fonts, icons -log = getLogger(__name__) - class ABApplication(QtWidgets.QApplication): _main_window = None - _controllers = None + _instance = None windows = [] + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance def __init__(self, *args, **kwargs): + if self._initialized: + return + QtCore.QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True) QtCore.QCoreApplication.setAttribute(Qt.AA_UseSoftwareOpenGL) @@ -31,6 +37,8 @@ def __init__(self, *args, **kwargs): if PYSIDE6: self.pyside6_setup() + + self._initialized = True def add_fonts(self): QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") @@ -60,13 +68,13 @@ def check_palette(self, color_scheme): plt.style.use("dark_background") - # os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--force-dark-mode" + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--force-dark-mode" else: palette = self.style().standardPalette() plt.style.use("default") - # os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "" + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "" self.setPalette(palette) @property @@ -111,28 +119,3 @@ def decorator(func): return decorator _global_shortcuts = {} - - -application = ABApplication() - - - -# -# if QSysInfo.productType() == "osx": -# # https://bugreports.qt.io/browse/QTBUG-87014 -# # https://bugreports.qt.io/browse/QTBUG-85546 -# # https://github.com/mapeditor/tiled/issues/2845 -# # https://doc.qt.io/qt-5/qoperatingsystemversion.html#MacOSBigSur-var -# supported = {"10.10", "10.11", "10.12", "10.13", "10.14", "10.15", "13.6"} -# if QSysInfo.productVersion() not in supported: -# os.environ["QT_MAC_WANTS_LAYER"] = "1" -# os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" -# log.info("Info: GPU hardware acceleration disabled") -# -# # on macos buttons silently crashes the renderer without any logs -# # confirmed that buttons works on the latest version of qt using pyside6 -# if QSysInfo.productType() in ["arch", "nixos", "osx"]: -# os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "{} --no-sandbox".format( -# os.getenv("QTWEBENGINE_CHROMIUM_FLAGS") -# ) -# log.info("Info: QtWebEngine sandbox disabled") diff --git a/activity_browser/ui/core/threading.py b/activity_browser/ui/core/threading.py index 6de0f0818..d2ce05b23 100644 --- a/activity_browser/ui/core/threading.py +++ b/activity_browser/ui/core/threading.py @@ -1,5 +1,5 @@ import threading -import logging +from loguru import logger from qtpy.QtCore import QThread, SignalInstance, Signal from qtpy import QtWidgets @@ -15,8 +15,8 @@ class ABThread(QThread): def __init__(self, parent=None): super().__init__(parent) - from activity_browser import application - self.exception.connect(application.main_window.dialog_on_exception) + from activity_browser import app + self.exception.connect(app.main_window.dialog_on_exception) def start(self, *args, priority=QThread.NormalPriority, **kwargs): """ @@ -84,30 +84,38 @@ def __exit__(self, *args): class InfoToSlot: def __init__(self, progress_slot=lambda progress, message: None): - self.handler = LoggingProgressHandler("INFO") + self.sink = LoggingProgressSink("INFO") thread_local.progress_slot = progress_slot + self._sink_id = None def __enter__(self): - logging.root.addHandler(self.handler) + # Attach a loguru sink which forwards INFO logs from this thread to the progress slot + self._sink_id = logger.add(self.sink, level="INFO") return def __exit__(self, *args): - logging.root.removeHandler(self.handler) + if self._sink_id is not None: + try: + logger.remove(self._sink_id) + except Exception: + pass return +class LoggingProgressSink: + def __init__(self, level="INFO"): + self.level = level -class LoggingProgressHandler(logging.Handler): - def filter(self, record: logging.LogRecord) -> bool: - if record.thread != threading.get_ident(): - return False - if record.levelname != "INFO": - return False - return True - - def emit(self, record: logging.LogRecord): + def __call__(self, message): + record = message.record try: - thread_local.progress_slot(None, record.message) + # Only handle messages from the current thread and matching level + if record["level"].name != self.level: + return + if record["thread"].id != threading.get_ident(): + return + thread_local.progress_slot(None, record.get("message", "")) except AttributeError: + # No progress slot set or malformed record pass diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py new file mode 100644 index 000000000..86a020446 --- /dev/null +++ b/activity_browser/ui/core/tree_model.py @@ -0,0 +1,585 @@ +from typing import Optional +from loguru import logger + +import pandas as pd + +from PySide6 import QtGui +from PySide6.QtCore import QModelIndex, Qt, QAbstractItemModel +from PySide6.QtWidgets import QWidget + +from activity_browser.ui.icons import qicons + + +class TreeNode: + """ + Optimized node object that combines children_map, row_indices, loaded_counts, + and DataFrame position for O(1) lookups. + """ + __slots__ = ('path', 'children', 'row_in_parent', 'loaded_count', 'df_position', 'is_leaf', '_child_lookup') + + def __init__(self, path: tuple, df_position: int = -1): + self.path: tuple = path # Full path tuple for this node + self.children: list['TreeNode'] = [] # List of child nodes + self.row_in_parent: int = -1 # Row index within parent's children list + self.loaded_count: int = 0 # Number of children currently loaded (for lazy loading) + self.df_position: int = df_position # Integer position in DataFrame (-1 for branch nodes) + self.is_leaf: bool = (df_position >= 0) # True if this is a leaf node + self._child_lookup: dict[tuple, TreeNode] = {} # Fast child lookup by path + + def add_child(self, child: 'TreeNode') -> None: + """Add a child node and update its row_in_parent.""" + child.row_in_parent = len(self.children) + self.children.append(child) + self._child_lookup[child.path] = child + + def get_child(self, path: tuple) -> Optional['TreeNode']: + """Get a child by its path (O(1) lookup).""" + return self._child_lookup.get(path) + + def get_child_at(self, row: int) -> Optional['TreeNode']: + """Get a child by its row index (O(1) lookup).""" + if 0 <= row < len(self.children): + return self.children[row] + return None + + def total_children(self) -> int: + """Total number of children (for lazy loading comparison).""" + return len(self.children) + + def can_fetch_more(self) -> bool: + """Check if more children can be loaded.""" + return self.loaded_count < len(self.children) + + +class ABTreeModel(QAbstractItemModel): + 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 + + # Single unified node map: path -> TreeNode + self.node_map: dict[tuple, TreeNode] = {} + self.root: TreeNode = TreeNode(tuple()) # Root node with empty path + + # Build the node hierarchy + self.build_node_hierarchy(self.df.index) + + def columns(self) -> list[str]: + """Return the list of column names, including the tree column.""" + return ["index"] + [col for col in self.df.columns if not col.startswith("_")] + + def column_name(self, index: QModelIndex) -> str: + """Return the name of the column at the given index, including the tree column.""" + return self.columns()[index.column()] + + def row(self, index: QModelIndex) -> pd.Series | None: + """ + Return the DataFrame row corresponding to the given index, or None for non-leaf nodes. + + Warning: This is a slow operation and should be avoided in methods called frequently like data(), *Data(), flags(), or index*(). + """ + if not index.isValid(): + return None + + node = index.internalPointer() + + if not isinstance(node, TreeNode) or not node.is_leaf: + return None + + # Use the pre-computed df_position for fast access + return self.df.iloc[node.df_position] + + def get(self, index: QModelIndex, column: str | int) -> any: + """ + Get the data for the given QModelIndex and column name or index. + """ + if not index.isValid(): + return None + + node = index.internalPointer() + + if not isinstance(node, TreeNode) or not node.is_leaf: + return None + + column_i = column if isinstance(column, int) else self.df.columns.get_loc(column) + + return self.df.iat[node.df_position, column_i] + + + # --- required model overrides --- + def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + child_node = parent_node.get_child_at(row) + + if child_node is None: + return QModelIndex() + + return self.createIndex(row, column, child_node) + + def parent(self, index: QModelIndex) -> QModelIndex: + if not index.isValid(): + return QModelIndex() + + node = index.internalPointer() + if not isinstance(node, TreeNode): + return QModelIndex() + + parent_path = self.parent_path(node.path) + + if len(parent_path) == 0: + return QModelIndex() + + parent_node = self.node_map.get(parent_path) + if parent_node is None: + return QModelIndex() + + return self.createIndex(parent_node.row_in_parent, 0, parent_node) + + def parent_path(self, path: tuple) -> tuple: + path = tuple(val for val in path if not pd.isna(val)) + return path[:-1] + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + # For tree models, when the parent is valid and column > 0, return 0 + if parent.isValid() and parent.column() != 0: + return 0 + + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + # Return the number of currently loaded children + return parent_node.loaded_count + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 (Qt signature) + # Always return the full column count for consistent tree structure + return len(self.columns()) + + #--- data overrides --- + def data(self, index: QModelIndex, role: int = Qt.DisplayRole): + # if not index.isValid() or self.df.empty: + # return None + + if role == Qt.DisplayRole: + return self.displayData(index) + elif role == Qt.EditRole: + return self.editData(index) + elif role == Qt.UserRole: + return self.userData(index) + elif role == Qt.DecorationRole: + return self.decorationData(index) + elif role == Qt.FontRole: + return self.fontData(index) + elif role == Qt.ToolTipRole: + return self.toolTipData(index) + + return None + + 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) + return node.path[-1] if index.column() == 0 else None + + if index.column() == 0: + return None # leaf node tree column is empty + + # 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] + + if not hasattr(val, "__iter__") and pd.isna(val): + return None + + return val + + def editData(self, index: QModelIndex) -> any: + return self.displayData(index) + + def userData(self, index: QModelIndex) -> any: + return self.displayData(index) + + def decorationData(self, index: QModelIndex) -> any: + return None + + def fontData(self, index: QModelIndex) -> any: + return None + + def toolTipData(self, index: QModelIndex) -> any: + return None + + #--- flag overrides --- + def flags(self, index): + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + + flags = Qt.ItemFlag.NoItemFlags + if self.indexEnabled(index): + flags |= Qt.ItemFlag.ItemIsEnabled + if self.indexSelectable(index): + flags |= Qt.ItemFlag.ItemIsSelectable + if self.indexEditable(index): + flags |= Qt.ItemFlag.ItemIsEditable + if self.indexDragEnabled(index): + flags |= Qt.ItemFlag.ItemIsDragEnabled + if self.indexDropEnabled(index): + flags |= Qt.ItemFlag.ItemIsDropEnabled + if self.indexUserCheckable(index): + flags |= Qt.ItemFlag.ItemIsUserCheckable + return flags + + def indexEnabled(self, index: QModelIndex) -> bool: + return True + + def indexSelectable(self, index: QModelIndex) -> bool: + return True + + def indexEditable(self, index: QModelIndex) -> bool: + return False + + def indexDragEnabled(self, index: QModelIndex) -> bool: + return False + + def indexDropEnabled(self, index: QModelIndex) -> bool: + return False + + def indexUserCheckable(self, index: QModelIndex) -> bool: + return False + + def isBranchNode(self, index: QModelIndex) -> bool: + """Check if the given index represents a branch node (non-leaf).""" + if not index.isValid(): + return False + node = index.internalPointer() + if not isinstance(node, TreeNode): + return False + return not node.is_leaf + + def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): + if orientation == Qt.Vertical: + return None + + 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.""" + if not self.lazy: + return False + + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + return parent_node.can_fetch_more() + + def fetchMore(self, parent: QModelIndex) -> None: + """Load the next chunk of children when user scrolls.""" + if not self.lazy: + return + + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + total_children = parent_node.total_children() + currently_loaded = parent_node.loaded_count + + if currently_loaded >= total_children: + return # Everything already loaded + + # Calculate how many more to load + remaining = total_children - currently_loaded + to_load = min(self.chunk_size, remaining) + + # Notify view that we're about to add rows + first_new_row = currently_loaded + last_new_row = currently_loaded + to_load - 1 + + self.beginInsertRows(parent, first_new_row, last_new_row) + parent_node.loaded_count = currently_loaded + to_load + 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 = df.dropna(how='all', axis=1) + 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: + - children relationships + - row indices + - 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 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 + for node in self.node_map.values(): + node.loaded_count = min(self.chunk_size, node.total_children()) + else: + # All children loaded + for node in self.node_map.values(): + node.loaded_count = node.total_children() + + def apply_filter(self): + pandas_query = " & ".join(self.df_query.values()) + filtered_df = self.df.query(pandas_query) + self.reset_hierarchy(filtered_df) + + def apply_sort(self): + if self.df.empty or not self.sorting_enabled: + return + + logger.debug(f"Applying sorting in : {self.__class__.__name__}") + + # Extract the unique order of higher levels + 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, 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=(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 + + def values_from_indices(self, key: str, indices: list[QModelIndex]): + """ + Returns the values from the given indices. + + Args: + key (str): The key to get the values for. + indices (list[QtCore.QModelIndex]): The indices to get the values for. + + Returns: + list: The list of values. + """ + df_positions = [] + for index in indices: + if not index.isValid(): + continue + node = index.internalPointer() + if isinstance(node, TreeNode) and node.is_leaf: + df_positions.append(node.df_position) + + if not df_positions: + return [] + + return self.df.iloc[df_positions][key].tolist() + 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 6ff8fd045..c80635da9 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -1,10 +1,8 @@ # -*- 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 .formula import FormulaDelegate from .json import JSONDelegate from .list import ListDelegate from .string import StringDelegate @@ -15,16 +13,15 @@ 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", - "FormulaDelegate", "JSONDelegate", "ListDelegate", "StringDelegate", @@ -35,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/formula.py b/activity_browser/ui/delegates/formula.py deleted file mode 100644 index f825d0c81..000000000 --- a/activity_browser/ui/delegates/formula.py +++ /dev/null @@ -1,263 +0,0 @@ -# -*- coding: utf-8 -*- -from os import devnull - -from asteval import Interpreter -from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import Signal, Slot - -from activity_browser import actions, signals - - -class CalculatorButtons(QtWidgets.QWidget): - """A custom layout containing calculator buttons, emits a signal - for each button pressed. - """ - - button_press = Signal(str) - clear = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - self.explain_text = """ -In addition to the other buttons on this calculator, the parameter formula -can make use of a large number of Python and Numpy functions, with Numpy -overriding Python where the function names are the same. - -For a more complete list see the `math` module in the Python documentation -or `ufuncs` in de Numpy documentation. - -Keep in mind that the result of a formula must be a scalar value! -""" - rows = [ - [ - ("+", "Add", lambda: self.button_press.emit(" + ")), - ("-", "Subtract", lambda: self.button_press.emit(" - ")), - ("*", "Multiply", lambda: self.button_press.emit(" * ")), - ], - [ - ("/", "Divide", lambda: self.button_press.emit(" / ")), - ("x²", "X to the power of 2", lambda: self.button_press.emit(" ** 2 ")), - ("More...", "Additional functions", self.explanation), - ], - ] - # Construct the layout from the list of lists above. - layout = QtWidgets.QHBoxLayout() - layout.addStretch(1) - for row in rows: - bar = QtWidgets.QToolBar() - bar.setOrientation(QtCore.Qt.Vertical) - for btn in row: - w = QtWidgets.QPushButton(btn[0]) - w.setToolTip(btn[1]) - w.pressed.connect(btn[2]) - w.setFixedSize(50, 50) - bar.addWidget(w) - layout.addWidget(bar) - layout.addStretch(1) - self.setLayout(layout) - - @Slot() - def explanation(self): - return QtWidgets.QMessageBox.question( - self, - "More...", - self.explain_text, - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - - -class FormulaDialog(QtWidgets.QDialog): - def __init__(self, parent=None, flags=QtCore.Qt.Window): - super().__init__(parent=parent, f=flags) - self.setWindowTitle("Build a formula") - self.setWindowModality(QtCore.Qt.ApplicationModal) - self.interpreter = None - self.key = ("", "") - - # 6 broad by 6 deep. - grid = QtWidgets.QGridLayout(self) - self.text_field = QtWidgets.QLineEdit(self) - self.text_field.textChanged.connect(self.validate_formula) - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel - ) - self.buttons.setSizePolicy( - QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Preferred, - QtWidgets.QSizePolicy.ButtonBox, - ) - ) - self.parameters = QtWidgets.QTableView(self) - model = QtGui.QStandardItemModel(self) - self.parameters.setModel(model) - completer = QtWidgets.QCompleter(model, self) - completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) - self.text_field.setCompleter(completer) - self.parameters.doubleClicked.connect(self.append_parameter_name) - - self.new_parameter_button = actions.ParameterNew.get_QButton(self.get_key) - - self.calculator = CalculatorButtons(self) - self.calculator.button_press.connect(self.text_field.insert) - self.calculator.clear.connect(self.text_field.clear) - - grid.addWidget(self.text_field, 0, 0, 5, 1) - grid.addWidget(self.buttons, 5, 0, 1, 1) - grid.addWidget(self.calculator, 0, 1, 5, 1) - grid.addWidget(self.parameters, 0, 2, 5, 1) - grid.addWidget(self.new_parameter_button, 5, 2, 1, 1) - - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - signals.added_parameter.connect(self.append_parameter) - self.show() - - def insert_parameters(self, items) -> None: - """Take the given list of parameter names, amounts and types, insert - them into the model. - """ - model = self.parameters.model() - model.clear() - model.setHorizontalHeaderLabels(["Name", "Amount", "Type"]) - for x, item in enumerate(items): - for y, value in enumerate(item): - model_item = QtGui.QStandardItem(str(value)) - model_item.setEditable(False) - model.setItem(x, y, model_item) - self.parameters.resizeColumnsToContents() - - @Slot(str, str, str, name="appendParameter") - def append_parameter(self, name: str, amount: str, p_type: str) -> None: - """Catch new parameters from the wizard and add them to the list.""" - model = self.parameters.model() - x = model.rowCount() - for y, i in enumerate([name, amount, p_type]): - item = QtGui.QStandardItem(i) - item.setEditable(False) - model.setItem(x, y, item) - - # Also include the new parameter in the interpreter. - if self.interpreter: - self.interpreter.symtable.update({name: float(amount)}) - - def insert_interpreter(self, interpreter: Interpreter) -> None: - self.interpreter = interpreter - - def insert_key(self, key: tuple) -> None: - """The key consists of two strings, no more, no less.""" - self.key = key - - def get_key(self) -> tuple: - return self.key - - @property - def formula(self) -> str: - """Look into the text_field and return the formula.""" - return self.text_field.text().strip() - - @formula.setter - def formula(self, value) -> None: - """Take the formula and set it to the text_field widget.""" - if value is None: - self.text_field.clear() - else: - self.text_field.setText(str(value)) - - @Slot(QtCore.QModelIndex) - def append_parameter_name(self, index: QtCore.QModelIndex) -> None: - """Take the index from the parameters table and append the parameter - name to the formula. - """ - param_name = self.parameters.model().index(index.row(), 0).data() - self.text_field.insert(param_name) - - @Slot() - def validate_formula(self) -> None: - """Qt slot triggered whenever a change is detected in the text_field.""" - self.text_field.blockSignals(True) - if self.interpreter: - formula = self.text_field.text().strip() - # Do not write massive amounts of errors to stderr if the user - # is busy writing. - with open(devnull, "w") as errfile: - self.interpreter.err_writer = errfile - self.interpreter(formula) - if len(self.interpreter.error) > 0: - self.buttons.button(QtWidgets.QDialogButtonBox.Save).setEnabled( - False - ) - else: - self.buttons.button(QtWidgets.QDialogButtonBox.Save).setEnabled( - True - ) - self.text_field.blockSignals(False) - - -class FormulaDelegate(QtWidgets.QStyledItemDelegate): - """An extensive delegate to allow users to build and validate formulas - The delegate spawns a dialog containing: - - An editable textfield for the formula. - - A listview containing parameter names that can be used in the formula - - Ok and Cancel buttons, on Ok, validate the formula before saving - For hardmode: also allow the user to create a new parameter from WITHIN - the delegate dialog itself. Requiring us to also include refreshing - for the parameter list. - """ - - ACCEPTED_TABLES = { - "project_parameter", - "database_parameter", - "activity_parameter", - "product", - "technosphere", - "biosphere", - } - - def __init__(self, parent=None): - super().__init__(parent) - - def createEditor(self, parent, option, index): - editor = QtWidgets.QWidget(parent) - dialog = FormulaDialog(editor, QtCore.Qt.Window) - dialog.accepted.connect(lambda: self.commitData.emit(editor)) - # dialog.rejected.connect(signals.parameters_changed.emit) - return editor - - def setEditorData(self, editor: QtWidgets.QWidget, index: QtCore.QModelIndex): - """Populate the editor with data if editing an existing field.""" - dialog = editor.findChild(FormulaDialog) - data = index.data(QtCore.Qt.DisplayRole) - - parent = self.parent() - # Check which table is asking for a list - if getattr(parent, "table_name", "") in self.ACCEPTED_TABLES: - items = parent.get_usable_parameters() - dialog.insert_parameters(items) - dialog.formula = data - interpreter = parent.get_interpreter() - dialog.insert_interpreter(interpreter) - # Now see if we can construct a (partial) key - if hasattr(parent, "key"): - # This works for exchange tables. - dialog.insert_key(parent.key) - elif hasattr(parent, "get_key"): - dialog.insert_key(parent.get_key()) - - def setModelData( - self, - editor: QtWidgets.QWidget, - model: QtCore.QAbstractItemModel, - index: QtCore.QModelIndex, - ): - """Take the editor, read the given value and set it in the model. - - If the new formula is the same as the existing one, do not call setData - """ - dialog = editor.findChild(FormulaDialog) - if dialog.result() == QtWidgets.QDialog.Rejected: - # Cancel was clicked, do not store anything. - return - model.setData(index, dialog.formula, QtCore.Qt.EditRole) diff --git a/activity_browser/ui/delegates/new_formula.py b/activity_browser/ui/delegates/new_formula.py index db929ff8c..b118624bb 100644 --- a/activity_browser/ui/delegates/new_formula.py +++ b/activity_browser/ui/delegates/new_formula.py @@ -1,3 +1,4 @@ +from loguru import logger from qtpy import QtCore, QtWidgets from qtpy.QtGui import QFontMetrics, QFont from qtpy.QtCore import Qt @@ -30,12 +31,14 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters + elif hasattr(index.model(), 'scoped_parameters'): + scope = index.model().scoped_parameters(index) else: scope = {} 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()) @@ -49,6 +52,8 @@ def createEditor(self, parent, option, index): from activity_browser.ui.widgets import ABFormulaEdit if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters + elif hasattr(index.model(), 'scoped_parameters'): + scope = index.model().scoped_parameters(index) else: scope = {} editor = ABFormulaEdit(parent, scope) 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 52ff819c1..cb782e719 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -2,9 +2,7 @@ from qtpy import QtCore, QtWidgets from stats_arrays import uncertainty_choices as uc -from activity_browser import actions - -from activity_browser.signals import signals +from activity_browser.ui.dialogs import UncertaintyDialog class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): @@ -12,46 +10,50 @@ class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): `setModelData` stores the integer id of the selected uncertainty distribution. """ - - def __init__(self, parent=None): - super().__init__(parent) - uc.check_id_uniqueness() - self.choices = {u.description: u.id for u in uc.choices} - def displayText(self, value, locale): """Take the given integer id and return the description. Will return the 'Unknown' uncertainty description if the given id either cannot be found or the value is 'nan' (when id is not set) """ - try: - return uc[int(value)].description - except (IndexError, ValueError): - return uc[0].description + if isinstance(value, (int, float)) and int(value) in uc.id_dict: + return uc.id_dict[int(value)].description + elif isinstance(value, dict) and value.get("uncertainty type") in uc.id_dict: + return uc[value["uncertainty type"]].description + return uc[0].description def createEditor(self, parent, option, index): """Simply use the wizard for updating uncertainties. Send a signal.""" - if hasattr(self.parent(), "modify_uncertainty_action"): - self.parent().modify_uncertainty_action.trigger() - elif hasattr(index.internalPointer(), "exchange"): - item = index.internalPointer() - actions.ExchangeUncertaintyModify.run([item.exchange]) - elif index.internalPointer()["_impact_category_name"] is not None: - item = index.internalPointer() - actions.CFUncertaintyModify.run( + from activity_browser import app + + item = index.internalPointer() + item_name = item.__class__.__name__ + + if item_name == "ParametersItem" or item_name == "ProjectParametersItem": + app.actions.ParameterUncertaintyModify.run(item["_parameter"].to_peewee_model()) + elif item_name == "ExchangesItem": + app.actions.ExchangeUncertaintyModify.run([item.exchange]) + elif item_name == "CharacterizationFactorsItem": + app.actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) + elif isinstance(index.data(), dict): + return UncertaintyDialog(parent=app.main_window, initial=index.data()) + + def setEditorData(self, editor, index: QtCore.QModelIndex): + pass - def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): - """Simply use the wizard for updating uncertainties.""" + def updateEditorGeometry(self, editor, option, index): pass def setModelData( self, - editor: QtWidgets.QComboBox, + editor: UncertaintyDialog, model: QtCore.QAbstractItemModel, index: QtCore.QModelIndex, ): """Read the current text and look up the actual ID of that uncertainty type.""" - uc_id = self.choices.get(editor.currentText(), 0) - model.setData(index, uc_id, QtCore.Qt.EditRole) + if not editor.result() == QtWidgets.QDialog.Accepted: + return + + model.setData(index, editor.result_dict, QtCore.Qt.EditRole) 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 new file mode 100644 index 000000000..bb65f2768 --- /dev/null +++ b/activity_browser/ui/dialogs/__init__.py @@ -0,0 +1,4 @@ +from .list_edit_dialog import ABListEditDialog +from .progress_dialog import ABProgressDialog +from .uncertainty_dialog import UncertaintyDialog + diff --git a/activity_browser/ui/widgets/list_edit_dialog.py b/activity_browser/ui/dialogs/list_edit_dialog.py similarity index 100% rename from activity_browser/ui/widgets/list_edit_dialog.py rename to activity_browser/ui/dialogs/list_edit_dialog.py diff --git a/activity_browser/ui/widgets/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py similarity index 92% rename from activity_browser/ui/widgets/progress_dialog.py rename to activity_browser/ui/dialogs/progress_dialog.py index 97e0faf11..5dec1df83 100644 --- a/activity_browser/ui/widgets/progress_dialog.py +++ b/activity_browser/ui/dialogs/progress_dialog.py @@ -1,6 +1,5 @@ from qtpy.QtWidgets import QProgressDialog -from activity_browser import application from activity_browser.mod.tqdm import qt_tqdm from activity_browser.mod.pyprind import qt_pyprind @@ -9,6 +8,8 @@ class ABProgressDialog(QProgressDialog): @classmethod def get_connected_dialog(cls, title: str) -> "ABProgressDialog": + from activity_browser.app import application + dialog = cls(application.main_window) dialog.setWindowTitle(title) dialog.setLabelText("Initializing") diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py new file mode 100644 index 000000000..bf0eaa434 --- /dev/null +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +from loguru import logger +from typing import Optional, Tuple + +import numpy as np +import seaborn as sns + +from qtpy import QtCore, QtGui, QtWidgets +import stats_arrays as sa + +from activity_browser.ui.widgets import ABPlot + + + + +EMPTY_UNCERTAINTY = { + "uncertainty type": sa.UndefinedUncertainty.id, + "loc": np.NaN, + "scale": np.NaN, + "shape": np.NaN, + "minimum": np.NaN, + "maximum": np.NaN, + "negative": False, +} + + +class UncertaintyDialog(QtWidgets.QDialog): + """Single-step dialog for defining a stats_arrays uncertainty. + + Mirrors the behavior of the UncertaintyWizard type page but returns a + stats_arrays structured array on accept. + + Usage: + ok, array = UncertaintyDialog.get_uncertainty(parent, initial=dict(...)) + if ok: + # array is a numpy structured array compatible with stats_arrays + """ + + def __init__(self, parent=None, initial: Optional[dict] = None): + super().__init__(parent) + self.setWindowTitle("Set Uncertainty") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + # State + self.dist = None + self.result_array = None # Filled on accept + self.result_dict = None # Filled on accept + self.previous_dist_id: Optional[int] = None + self.mean_is_calculated = { + sa.TriangularUncertainty.id, + sa.UniformUncertainty.id, + sa.DiscreteUniform.id, + sa.BetaUncertainty.id, + } + + # Top: distribution selection + box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") + self.distribution = QtWidgets.QComboBox(box1) + self.distribution.addItems([ud.description for ud in sa.uncertainty_choices]) + self.distribution.currentIndexChanged.connect(self._on_distribution_changed) + + header_layout = QtWidgets.QGridLayout() + header_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0) + header_layout.addWidget(self.distribution, 0, 1) + box1.setLayout(header_layout) + + # Middle: parameters + self.fields_box = QtWidgets.QGroupBox("Fill out 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) + + # loc/mean + self.loc_label = QtWidgets.QLabel("Loc:") + self.loc = QtWidgets.QLineEdit() + self.loc.setValidator(self.validator) + self.loc.textEdited.connect(self._sync_mean_from_loc) + self.loc.textEdited.connect(self._check_negative) + self.loc.textEdited.connect(self._generate_plot) + + self.mean_label = QtWidgets.QLabel("Mean:") + self.mean = QtWidgets.QLineEdit() + self.mean.setValidator(self.validator) + self.mean.textEdited.connect(self._sync_loc_from_mean) + self.mean.textEdited.connect(self._check_negative) + self.mean.textEdited.connect(self._generate_plot) + + # Calculated mean (read-only) for some dists + self.calc_mean_label = QtWidgets.QLabel("Mean:") + self.calc_mean = QtWidgets.QLineEdit("nan") + self.calc_mean.setDisabled(True) + + # Other parameters + self.scale_label = QtWidgets.QLabel("Sigma/scale:") + self.scale = QtWidgets.QLineEdit() + self.scale.setValidator(self.validator) + self.scale.textEdited.connect(self._generate_plot) + + self.shape_label = QtWidgets.QLabel("Shape:") + self.shape = QtWidgets.QLineEdit() + self.shape.setValidator(self.validator) + self.shape.textEdited.connect(self._generate_plot) + + self.min_label = QtWidgets.QLabel("Minimum:") + self.minimum = QtWidgets.QLineEdit() + self.minimum.setValidator(self.validator) + self.minimum.textEdited.connect(self._generate_plot) + + self.max_label = QtWidgets.QLabel("Maximum:") + self.maximum = QtWidgets.QLineEdit() + self.maximum.setValidator(self.validator) + self.maximum.textEdited.connect(self._generate_plot) + + # Hidden flag for negative mean on lognormal + self.negative = QtWidgets.QRadioButton(self) + self.negative.setChecked(False) + self.negative.setHidden(True) + + params_layout = QtWidgets.QGridLayout() + # row 0: read-only calculated mean (will be hidden for most dists) + params_layout.addWidget(self.calc_mean_label, 0, 0) + params_layout.addWidget(self.calc_mean, 0, 1) + # row 1: loc/mean pair + params_layout.addWidget(self.loc_label, 1, 0) + params_layout.addWidget(self.loc, 1, 1) + params_layout.addWidget(self.mean_label, 1, 3) + params_layout.addWidget(self.mean, 1, 4) + # row 2+: other params + params_layout.addWidget(self.scale_label, 2, 0) + params_layout.addWidget(self.scale, 2, 1) + params_layout.addWidget(self.shape_label, 3, 0) + params_layout.addWidget(self.shape, 3, 1) + params_layout.addWidget(self.min_label, 4, 0) + params_layout.addWidget(self.minimum, 4, 1) + params_layout.addWidget(self.max_label, 5, 0) + params_layout.addWidget(self.maximum, 5, 1) + self.fields_box.setLayout(params_layout) + + # Bottom: plot + self.plot = SimpleDistributionPlot(self) + + # Buttons + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.buttons.accepted.connect(self._on_accept) + self.buttons.rejected.connect(self.reject) + + # Layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(box1) + layout.addWidget(self.fields_box) + layout.addWidget(self.plot) + layout.addWidget(self.buttons) + self.setLayout(layout) + + # Initialize values (defaults or provided initial) + self._apply_initial(initial or {}) + self._on_distribution_changed(self.distribution.currentIndex()) + self._sync_mean_from_loc() + self._generate_plot() + + # ---------- Public API ---------- + @staticmethod + def get_uncertainty_array( + parent=None, initial: Optional[dict] = None + ) -> Tuple[bool, Optional[np.ndarray]]: + dlg = UncertaintyDialog(parent, initial=initial) + ok = dlg.exec_() == QtWidgets.QDialog.Accepted + return ok, dlg.result_array if ok else None + + @staticmethod + def get_uncertainty_dict( + parent=None, initial: Optional[dict] = None + ) -> Tuple[bool, Optional[dict]]: + dlg = UncertaintyDialog(parent, initial=initial) + ok = dlg.exec_() == QtWidgets.QDialog.Accepted + return ok, dlg.result_dict if ok else None + + # ---------- Internal helpers ---------- + def _apply_initial(self, initial: dict) -> None: + # Use EMPTY_UNCERTAINTY defaults, overridden by initial + data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} + data.update(initial or {}) + # Distribution + try: + uc_type = int(data.get("uncertainty type", 0)) + except Exception: + uc_type = 0 + self.distribution.setCurrentIndex(uc_type) + # Fields (string form for QLineEdit) + def to_str(val): + return "nan" if val is None or (isinstance(val, float) and np.isnan(val)) else str(val) + + self.loc.setText(to_str(data.get("loc", np.nan))) + self.scale.setText(to_str(data.get("scale", np.nan))) + self.shape.setText(to_str(data.get("shape", np.nan))) + self.minimum.setText(to_str(data.get("minimum", np.nan))) + self.maximum.setText(to_str(data.get("maximum", np.nan))) + self._check_negative() + + @property + def _distribution_loc_label(self) -> str: + if self.dist.id == sa.LognormalUncertainty.id: + return "Loc (ln(mean)):" + elif self.dist.id == sa.TriangularUncertainty.id: + return "Mode:" + elif self.dist.id == sa.BetaUncertainty.id: + return "Loc / alpha:" + elif self.dist.id in {sa.GammaUncertainty.id, sa.WeibullUncertainty.id}: + return "Loc / offset:" + else: + return "Mean:" + + def _hide_params(self, *params, hide: bool = True) -> None: + 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 _on_distribution_changed(self, index: int) -> None: + self.dist = sa.uncertainty_choices[index] + + # Show/hide fields per distribution (mirror wizard) + if self.dist.id in {0, 1}: # Undefined / NoUncertainty + self._hide_params("loc", "scale", "shape", "min", "max") + elif self.dist.id in {2, 3}: # Normal / Lognormal + self._hide_params("shape", "min", "max") + self._hide_params("loc", "scale", hide=False) + elif self.dist.id in {4, 7}: # Uniform / DiscreteUniform + self._hide_params("loc", "scale", "shape") + self._hide_params("min", "max", hide=False) + elif self.dist.id in {5, 6}: # Triangular / Bernoulli-like (min/max/loc) + self._hide_params("scale", "shape") + self._hide_params("loc", "min", "max", hide=False) + elif self.dist.id in {8, 9, 10, 11, 12}: # Other 3-param + self._hide_params("min", "max") + self._hide_params("loc", "scale", "shape", hide=False) + + # Special handling (lognormal and calculated mean label) + if self.dist.id == sa.LognormalUncertainty.id: + self.mean.setHidden(False) + self.mean_label.setHidden(False) + # Convert existing loc to log-space if coming from non-lognormal + if self.previous_dist_id is not None and self.previous_dist_id != sa.LognormalUncertainty.id: + self._extract_lognormal_loc_from_mean() + self._sync_mean_from_loc() + else: + self.mean.setHidden(True) + self.mean_label.setHidden(True) + # If switching away from lognormal, set loc to linear amount if mean present + if self.previous_dist_id == sa.LognormalUncertainty.id: + try: + mean_val = float(self.mean.text()) if self.mean.text() else np.nan + if not np.isnan(mean_val): + self.loc.setText(str(mean_val)) + except Exception: + pass + + # Calculated mean visibility + show_calc = self.dist.id in self.mean_is_calculated + self.calc_mean_label.setHidden(not show_calc) + self.calc_mean.setHidden(not show_calc) + + # Update labels + self.loc_label.setText(self._distribution_loc_label) + self.previous_dist_id = self.dist.id + self.fields_box.updateGeometry() + + # Update plot and OK state + self._generate_plot() + self._update_ok_state() + + def _extract_lognormal_loc_from_mean(self) -> None: + """Set loc to ln(mean) when switching to lognormal, if mean is known.""" + try: + mtxt = self.mean.text().strip() + if not mtxt: + return + val = float(mtxt) + if val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + except Exception: + self.loc.setText("nan") + + def _sync_mean_from_loc(self) -> None: + if not self.loc.text(): + return + try: + self.mean.setText(str(np.exp(float(self.loc.text())))) + except Exception: + self.mean.setText("nan") + self._update_ok_state() + + def _sync_loc_from_mean(self) -> None: + if not self.mean.hasAcceptableInput(): + self.loc.setText("nan") + self._update_ok_state() + return + try: + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + if np.isnan(val) or val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + self._update_ok_state() + + def _check_negative(self) -> None: + # Special case for lognormal negative mean + try: + if not self.mean.hasAcceptableInput(): + return + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + self.negative.setChecked(bool(not np.isnan(val) and val < 0)) + + def _standard_dist_fields(self, 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.distribution.currentIndex() + data["negative"] = bool(self.negative.isChecked()) + # Pull values from widgets + def as_float(txt: str) -> float: + try: + val = float(txt) + return val + except Exception: + return float("nan") + + for field in self._standard_dist_fields(data["uncertainty type"]): + widget = { + "loc": self.loc, + "scale": self.scale, + "shape": self.shape, + "minimum": self.minimum, + "maximum": self.maximum, + }[field] + data[field] = as_float(widget.text()) + return data + + def _completed_active_fields(self) -> bool: + # Mirror wizard validations + dist_id = self.dist.id + def ok_lineedit(le: QtWidgets.QLineEdit) -> bool: + return bool(le.hasAcceptableInput() and le.text()) + + if dist_id in {0, 1}: + return True + elif dist_id in {2, 3}: + return ok_lineedit(self.loc) and ok_lineedit(self.scale) + elif dist_id in {4, 7}: + return ok_lineedit(self.minimum) and ok_lineedit(self.maximum) + elif dist_id in {5, 6}: + if not (ok_lineedit(self.minimum) and ok_lineedit(self.maximum) and ok_lineedit(self.loc)): + return False + try: + return float(self.minimum.text()) < float(self.loc.text()) < float(self.maximum.text()) + except Exception: + return False + elif dist_id in {8, 9, 10, 11, 12}: + return ok_lineedit(self.scale) and ok_lineedit(self.shape) and ok_lineedit(self.loc) + return False + + def _update_ok_state(self) -> None: + ok_btn = self.buttons.button(QtWidgets.QDialogButtonBox.Ok) + ok_btn.setEnabled(self._completed_active_fields()) + + def _generate_plot(self) -> None: + # Update calculated mean if applicable and render sample + if self.dist is None: + return + complete = self._completed_active_fields() or self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id} + if not complete: + self._update_ok_state() + return + array = self.dist.from_dicts(self._uncertainty_info) + # Calculated mean display for specific distributions + if self.dist.id in self.mean_is_calculated: + try: + calc = self.dist.statistics(array).get("mean") + except TypeError: + # DiscreteUniform workaround + array = self.dist.fix_nan_minimum(array) + calc = (array["maximum"] + array["minimum"]) / 2 + calc = calc.mean() if isinstance(calc, np.ndarray) else calc + self.calc_mean.setText(str(float(calc))) + # Vertical line value + if self.dist.id == sa.LognormalUncertainty.id: + vline = self.dist.statistics(array).get("median") + elif self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id}: + # Best effort: use loc as "mean" placeholder + try: + vline = float(self.loc.text()) if self.loc.text() else np.nan + except Exception: + vline = np.nan + else: + vline = self.dist.statistics(array).get("mean") + # Sample data + data = self.dist.random_variables(array, 1000) + if not np.any(np.isnan(data)): + try: + self.plot.plot(data, vline) + except RuntimeError as e: + logger.error("%s: plotting failed, retry without KDE", e) + try: + sns.histplot(data.T, kde=False, stat="density", ax=self.plot.ax, edgecolor="none") + self.plot.ax.axvline(vline, label="Mean / amount", c="r", ymax=0.98) + self.plot.ax.legend(loc="upper right") + self.plot.canvas.draw() + except Exception: + pass + self._update_ok_state() + + def _on_accept(self) -> None: + try: + self.result_dict = self._uncertainty_info + self.result_array = self.dist.from_dicts(self._uncertainty_info) + except Exception as e: + QtWidgets.QMessageBox.warning( + self, + "Invalid uncertainty", + str(e), + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Ok, + ) + return + self.accept() + + +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("%s: Plotting without KDE.", 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() + + +__all__ = ["UncertaintyDialog"] + diff --git a/activity_browser/ui/figures.py b/activity_browser/ui/figures.py deleted file mode 100644 index 0b618fee1..000000000 --- a/activity_browser/ui/figures.py +++ /dev/null @@ -1,390 +0,0 @@ -import math -from logging import getLogger - -import numpy as np -import pandas as pd -import seaborn as sns -import bw2data as bd - -import matplotlib.pyplot as plt -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from qtpy import QtWidgets - -from activity_browser.utils import savefilepath -from activity_browser.bwutils.commontasks import wrap_text - -log = getLogger(__name__) - - -class Plot(QtWidgets.QWidget): - ALL_FILTER = "All Files (*.*)" - PNG_FILTER = "PNG (*.png)" - SVG_FILTER = "SVG (*.svg)" - - def __init__(self, parent=None): - super().__init__(parent) - # create figure, canvas, and axis - # self.figure = Figure(tight_layout=True) - self.figure = Figure(constrained_layout=True) - self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumHeight(0) - - self.canvas.destroyed.connect(self.check) - - self.ax = self.figure.add_subplot(111) # create an axis - self.plot_name = "Figure" - - # set the layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.canvas) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.updateGeometry() - - def check(self): - print("WHY DELETE") - - def plot(self, *args, **kwargs): - raise NotImplementedError - - def reset_plot(self) -> None: - self.figure.clf() - self.ax = self.figure.add_subplot(111) - - def get_canvas_size_in_inches(self): - # print("Canvas size:", self.canvas.get_width_height()) - return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) - - def to_png(self): - """Export to .png format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.PNG_FILTER - ) - if filepath: - if not filepath.endswith(".png"): - filepath += ".png" - self.figure.savefig(filepath) - - def to_svg(self): - """Export to .svg format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.SVG_FILTER - ) - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - self.figure.savefig(filepath) - - -class LCAResultsBarChart(Plot): - """ " Generate a bar chart comparing the absolute LCA scores of the products""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA scores" - - def plot(self, df: pd.DataFrame, method: tuple, labels: list): - self.reset_plot() - height_inches, width_inches = self.get_canvas_size_in_inches() - self.figure.set_size_inches(height_inches, width_inches) - - # https://github.com/LCA-ActivityBrowser/activity-browser/issues/489 - df.index = pd.Index(labels) # Replace index of tuples - show_legend = df.shape[1] != 1 # Do not show the legend for 1 column - df.plot.barh(ax=self.ax, legend=show_legend) - self.ax.invert_yaxis() - - # labels - self.ax.set_yticks(np.arange(len(labels))) - self.ax.set_xlabel(bd.methods[method].get("unit")) - self.ax.set_title(", ".join([m for m in method])) - # self.ax.set_yticklabels(labels, minor=False) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - - # draw - self.canvas.draw() - - -class LCAResultsPlot(Plot): - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA heatmap" - - def plot(self, df: pd.DataFrame, invert_plot: bool = False): - """Plot a heatmap grid of the different impact categories and reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - - dfp = df.copy() - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "amount" in dfp.columns: - dfp.drop(["amount"], axis=1, inplace=True) # Drop the 'amount' col - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - - # avoid figures getting too large horizontally - dfp.index = [wrap_text(i, max_length=40) for i in dfp.index] - dfp.columns = [wrap_text(i, max_length=20) for i in dfp.columns] - prop = dfp.divide(dfp.abs().max(axis=0)).multiply(100) - dfp.replace(np.nan, 0, inplace=True) - if invert_plot: - dfp = dfp.T - prop = prop.T - - # set different color palette depending on whether all values are positive or not - if ( - dfp.min(axis=None) < 0 and dfp.max(axis=None) > 0 - ): # has both negative AND positive values - cmap = sns.color_palette("vlag_r", as_cmap=True) - else: # has only positive OR negative values - cmap = sns.color_palette("Blues", as_cmap=True) - - sns.heatmap( - prop, - ax=self.ax, - cmap=cmap, - annot=dfp, - linewidths=0.05, - annot_kws={ - "size": 11 if dfp.shape[1] <= 8 else 9, - "rotation": 0 if dfp.shape[1] <= 8 else 60, - }, - cbar_kws={"format": "%.0f%%"}, - ) - self.ax.tick_params(labelsize=8) - if dfp.shape[1] > 5: - self.ax.set_xticklabels(self.ax.get_xticklabels(), rotation="vertical") - self.ax.set_yticklabels(self.ax.get_yticklabels(), rotation="horizontal") - - # refresh canvas - size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[0] * 0.55) - self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - - self.canvas.draw() - - -class ContributionPlot(Plot): - MAX_LEGEND = 30 - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Contributions" - self.parent = parent - - def plot(self, df: pd.DataFrame, unit: str = None): - """Plot a horizontal stacked bar chart of contributions, - add 'total' marker if both positive and negative results are present.""" - dfp = df.copy() - dfp = dfp.iloc[:, ::-1] # reverse column names so they align with calculation setup and rest of results - - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - # drop rows if all values are 0 - dfp = dfp.loc[~(dfp == 0).all(axis=1)] - - self.ax.clear() - canvas_width_inches, canvas_height_inches = self.get_canvas_size_in_inches() - optimal_height_inches = 4 + dfp.shape[1] * 0.55 - # print('Optimal Contribution plot height:', optimal_height_inches) - self.figure.set_size_inches(canvas_width_inches, optimal_height_inches) - - # avoid figures getting too large horizontally - dfp.index = pd.Index([wrap_text(str(i), max_length=40) for i in dfp.index]) - dfp.columns = pd.Index([wrap_text(i, max_length=40) for i in dfp.columns]) - # Strip invalid characters from the ends of row/column headers - dfp.index = dfp.index.str.strip("_ \n\t") - dfp.columns = dfp.columns.str.strip("_ \n\t") - - # set colormap to use - items = dfp.shape[0] # how many contribution items - # skip grey and black at start/end of cmap - cmap = plt.cm.nipy_spectral_r(np.linspace(0, 1, items + 2))[1:-1] - colors = {item: color for item, color in zip(dfp.index, cmap)} - # overwrite rest values to grey - colors["Rest (+)"] = [0.8, 0.8, 0.8, 1.] - colors["Rest (-)"] = [0.8, 0.8, 0.8, 1.] - - dfp.T.plot.barh( - stacked=True, - color=colors, - ax=self.ax, - legend=False if dfp.shape[0] >= self.MAX_LEGEND else True, - ) - self.ax.tick_params(labelsize=8) - if unit: - self.ax.set_xlabel(unit) - - # show legend if not too many items - if not dfp.shape[0] >= self.MAX_LEGEND: - plt.rc("legend", **{"fontsize": 8}) - ncols = math.ceil(dfp.shape[0] * 0.6 / optimal_height_inches) - # print('Ncols:', ncols, dfp.shape[0] * 0.55, optimal_height_inches) - self.ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), ncol=ncols) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - # make the zero line more present - grid = self.ax.get_xgridlines() - # get the 0 line from all gridlines - label_pos = [i for i, label in enumerate(self.ax.get_xticklabels()) if label.get_position()[0] == 0.0] - if len(label_pos) > 0: - zero_line = grid[label_pos[0]] - zero_line.set_color("black") - zero_line.set_linestyle("solid") - - # total marker when enabled and both negative and positive results are present in a column - if self.parent.score_marker: - marker_size = max(min(150 / dfp.shape[1], 35), 10) # set marker size dynamic between 10 - 35 - for i, col in enumerate(dfp): - total = np.sum(dfp[col]) - abs_total = np.sum(np.abs(dfp[col])) - if abs(total) != abs_total: - self.ax.plot(total, i, - markersize=marker_size, marker="d", fillstyle="left", - markerfacecolor="black", markerfacecoloralt="grey", markeredgecolor="white") - - # TODO review: remove or enable - - # refresh canvas - # size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[1] * 0.55) - # self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class CorrelationPlot(Plot): - def __init__(self, parent=None): - super().__init__(parent) - sns.set(style="darkgrid") - - def plot(self, df: pd.DataFrame): - """Plot a heatmap of correlations between different reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - canvas_size = self.canvas.get_width_height() - # print("Canvas size:", canvas_size) - size = (4 + df.shape[1] * 0.3, 4 + df.shape[1] * 0.3) - self.figure.set_size_inches(size[0], size[1]) - - corr = df.corr() - # Generate a mask for the upper triangle - mask = np.zeros_like(corr, dtype=bool) - mask[np.triu_indices_from(mask)] = True - # Draw the heatmap with the mask and correct aspect ratio - vmax = np.abs(corr.values[~mask]).max() - # vmax = np.abs(corr).max() - sns.heatmap( - corr, - mask=mask, - cmap=plt.cm.PuOr, - vmin=-vmax, - vmax=vmax, - square=True, - linecolor="lightgray", - linewidths=1, - ax=self.ax, - ) - - df_lte8_cols = df.shape[1] <= 8 - for i in range(len(corr)): - self.ax.text( - i + 0.5, - i + 0.5, - corr.columns[i], - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - for j in range(i + 1, len(corr)): - s = "{:.3f}".format(corr.values[i, j]) - self.ax.text( - j + 0.5, - i + 0.5, - s, - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - self.ax.axis("off") - - # refresh canvas - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class MonteCarloPlot(Plot): - """Monte Carlo plot.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Monte Carlo" - - def plot(self, df: pd.DataFrame, method: tuple): - self.ax.clear() - - for col in df.columns: - color = self.ax._get_lines.get_next_color() - df[col].hist( - ax=self.ax, - figure=self.figure, - label=col, - density=True, - color=color, - alpha=0.5, - ) # , histtype="step") - # self.ax.axvline(df[col].median(), color=color) - self.ax.axvline(df[col].mean(), color=color) - - self.ax.set_xlabel(bd.methods[method]["unit"]) - self.ax.set_ylabel("Probability") - self.ax.legend( - loc="upper center", - bbox_to_anchor=(0.5, -0.07), - ) # ncol=2 - - # lconfi, upconfi =mc['statistics']['interval'][0], mc['statistics']['interval'][1] - - self.canvas.draw() - - -class SimpleDistributionPlot(Plot): - 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: - log.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() \ No newline at end of file diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index 011f21576..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] @@ -18,95 +19,86 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: return QIcon(pixmap) -# CURRENTLY UNUSED ICONS - -# Modular LCA (keep until this is reintegrated) -# add_db = create_path('metaprocess', 'add_database.png') -# close_db = create_path('metaprocess', 'close_database.png') -# cut = create_path('metaprocess', 'cut.png') -# debug = create_path('main', 'ladybird.png') -# duplicate = create_path('metaprocess', 'duplicate.png') -# graph_lmp = create_path('metaprocess', 'graph_linkedmetaprocess.png') -# graph_mp = create_path('metaprocess', 'graph_metaprocess.png') -# load_db = create_path('metaprocess', 'open_database.png') -# metaprocess = create_path('metaprocess', 'metaprocess.png') -# new = create_path('metaprocess', 'new_metaprocess.png') -# save_db = create_path('metaprocess', 'save_database.png') -# save_mp = create_path('metaprocess', 'save_metaprocess.png') - -# key = create_path('main', 'key.png') -# search = create_path('main', 'search.png') -# switch = create_path('main', 'switch-state.png') - - -class Icons(object): +icons = dict( # Icons from href="https://www.flaticon.com/ # MAIN - ab = create_path("main", "activitybrowser.png") + ab = create_path("main", "activitybrowser.png"), # arrows - right = create_path("main", "right.png") - left = create_path("main", "left.png") - forward = create_path("main", "forward.png") - backward = create_path("main", "backward.png") + right = create_path("main", "right.png"), + left = create_path("main", "left.png"), + forward = create_path("main", "forward.png"), + backward = create_path("main", "backward.png"), # Simple actions - delete = create_path("context", "delete.png") - clear = create_path("context", "clear.png") - copy = create_path("context", "copy.png") - add = create_path("context", "add.png") - edit = create_path("main", "edit.png") - calculate = create_path("main", "calculate.png") - question = create_path("context", "question.png") - search = create_path("main", "search.png") - filter = create_path("main", "filter.png") - filter_outline = create_path("main", "filter_outline.png") + delete = create_path("context", "delete.png"), + clear = create_path("context", "clear.png"), + copy = create_path("context", "copy.png"), + add = create_path("context", "add.png"), + edit = create_path("main", "edit.png"), + calculate = create_path("main", "calculate.png"), + question = create_path("context", "question.png"), + search = create_path("main", "search.png"), + filter = create_path("main", "filter.png"), + filter_outline = create_path("main", "filter_outline.png"), # database - import_db = create_path("main", "import_database.png") - duplicate_database = create_path("main", "duplicate_database.png") + import_db = create_path("main", "import_database.png"), + duplicate_database = create_path("main", "duplicate_database.png"), # activity - duplicate_activity = create_path("main", "duplicate_activity.png") - duplicate_to_other_database = create_path("main", "import_database.png") - parameterized = create_path("main", "parameterized.png") + duplicate_activity = create_path("main", "duplicate_activity.png"), + duplicate_to_other_database = create_path("main", "import_database.png"), + parameterized = create_path("main", "parameterized.png"), # windows - graph_explorer = create_path("main", "graph_explorer.png") - issue = create_path("main", "idea.png") - settings = create_path("main", "settings.png") - history = create_path("main", "history.png") - welcome = create_path("main", "welcome.png") - main_window = create_path("main", "home.png") + graph_explorer = create_path("main", "graph_explorer.png"), + issue = create_path("main", "idea.png"), + settings = create_path("main", "settings.png"), + history = create_path("main", "history.png"), + welcome = create_path("main", "welcome.png"), + main_window = create_path("main", "home.png"), # plugins - plugin = create_path("main", "plugin.png") + plugin = create_path("main", "plugin.png"), # nodes - process = create_path("nodes", "process.png") - product = create_path("nodes", "product.png") - waste = create_path("nodes", "waste.png") - processproduct = create_path("nodes", "processproduct.png") - biosphere = create_path("nodes", "biosphere.png") - readonly_process = create_path("nodes", "read-only-process.png") + process = create_path("nodes", "process.png"), + product = create_path("nodes", "product.png"), + waste = create_path("nodes", "waste.png"), + processproduct = create_path("nodes", "processproduct.png"), + 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") - warning = create_path("context", "warning.png") - critical = create_path("context", "critical.png") - locked = create_path("main", "locked.png") - unlocked = create_path("main", "unlocked.png") - - -class QIcons(Icons): - """Using the Icons class, returns the same attributes, but as QIcon type""" - empty = empty_icon() - - def __getattribute__(self, item): - return QIcon(Icons.__getattribute__(self, item)) - - -icons = Icons() + superstructure = create_path("main", "superstructure.png"), + copy_to_clipboard = create_path("main", "copy_to_clipboard.png"), + warning = create_path("context", "warning.png"), + critical = create_path("context", "critical.png"), + locked = create_path("main", "locked.png"), + unlocked = create_path("main", "unlocked.png"), + star = create_path("main", "star.png"), +) + + +class QIcons: + """Lazily loads QIcon instances only when accessed.""" + def __getattribute__(self, name): + if name == 'empty': + return empty_icon() + elif name in icons: + 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 cd9ff967f..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 logging import getLogger - -import networkx as nx -from qtpy import QtWidgets -from qtpy.QtCore import Slot - -from activity_browser import signals -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 - -log = getLogger(__name__) - - -# 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: - log.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) - log.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: - log.info(f"New Graph for key: {key}") - self.graph.new_graph(key) - self.send_json() - - @Slot(name="reload_graph") - def reload_graph(self) -> None: - 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 - log.info(f"Deleting node: {key}") - self.graph.reduce_graph(key) - else: # expansion mode - log.info(f"Expanding graph: {key}") - if keyboard["shift"]: # downstream expansion - log.info("Adding downstream nodes.") - self.graph.expand_graph(key, down=True) - else: # upstream expansion - log.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: - log.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: - log.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) - log.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: - log.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/webengine_page.py b/activity_browser/ui/web/webengine_page.py deleted file mode 100644 index 64a01ab71..000000000 --- a/activity_browser/ui/web/webengine_page.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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 logging import getLogger - -from qtpy.QtWebEngineWidgets import QWebEnginePage - -log = getLogger(__name__) - - -class Page(QWebEnginePage): - def javaScriptConsoleMessage(self, level: QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): - if level == QWebEnginePage.InfoMessageLevel: - log.info(f"JS Info (Line {line}): {message}") - elif level == QWebEnginePage.WarningMessageLevel: - log.warning(f"JS Warning (Line {line}): {message}") - elif level == QWebEnginePage.ErrorMessageLevel: - log.error(f"JS Error (Line {line}): {message}") - else: - log.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/ui/web/webutils.py b/activity_browser/ui/web/webutils.py deleted file mode 100644 index 7da643e05..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.utils import get_base_path - -os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "3999" - - -def get_static_js_path(file_name: str = "") -> str: - return str(get_base_path().joinpath("static", "javascript", file_name)) - - -def get_static_css_path(file_name: str = "") -> str: - return str(get_base_path().joinpath("static", "css", file_name)) 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 d92fe1773..8f0434434 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -1,16 +1,14 @@ +from .plot import ABPlot from .abstract_pane import ABAbstractPane from .comparison_switch import SwitchComboBox 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 -from .progress_dialog import ABProgressDialog -from .combobox import ABComboBox +from .combobox import ABComboBox, CheckableComboBox from .button_collapser import ABRadioButtonCollapser from .wizard import ABWizard from .wizard_page import ABWizardPage, ABThreadedWizardPage @@ -18,9 +16,11 @@ from .database_name_edit import DatabaseNameEdit from .dock_widget import ABDockWidget from .label import ABLabel -from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu -from .list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay -from .database_selection_dialog import ABDatabaseSelectionDialog +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 86% rename from activity_browser/ui/web/base.py rename to activity_browser/ui/widgets/abstract_navigator.py index 955406fa5..065e8b563 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/widgets/abstract_navigator.py @@ -3,24 +3,18 @@ from abc import abstractmethod from copy import deepcopy from typing import Type -from logging import getLogger +from loguru import logger from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot -from activity_browser import signals -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 ... import utils -from ...ui.icons import qicons -from . import webutils -from .webengine_page import Page +from .web_engine_page import ABWebEnginePage -log = getLogger(__name__) - -class BaseNavigatorWidget(QtWidgets.QWidget): +class ABAbstractNavigator(QtWidgets.QWidget): HELP_TEXT = """ This is the text shown when the user presses 'help'. """ @@ -30,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) @@ -78,24 +72,21 @@ def toggle_help(self) -> None: def go_forward(self) -> None: if self.graph.forward(): - signals.new_statusbar_message.emit("Going forward.") self.send_json() - else: - signals.new_statusbar_message.emit("No data to go forward to.") def go_back(self) -> None: if self.graph.back(): - signals.new_statusbar_message.emit("Going back.") self.send_json() - else: - 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_code = utils.read_file_text(css_path) + css_path = get_static_css_path(self.css_file) + + with open(css_path, "r") as css_file: + css_code = css_file.read() + style_element = "" self.bridge.style.emit(style_element) @@ -111,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 @@ -151,7 +145,7 @@ def node_clicked(self, click_text: str): click_dict["database"], click_dict["id"], ) # since JSON does not know tuples - log.info(f"Click information: {click_dict}") # TODO click_dict needs correcting + logger.info(f"Click information: {click_dict}") # TODO click_dict needs correcting self.update_graph.emit(click_dict) @Slot(str, name="download_triggered") @@ -163,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 @@ -215,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/abstract_page.py b/activity_browser/ui/widgets/abstract_page.py new file mode 100644 index 000000000..6e017208a --- /dev/null +++ b/activity_browser/ui/widgets/abstract_page.py @@ -0,0 +1,8 @@ +from qtpy import QtWidgets + + +class ABAbstractPage(QtWidgets.QWidget): + + def toggleViewAction(self, main_window): + """Return the toggle view action for this page.""" + return diff --git a/activity_browser/ui/widgets/buttons.py b/activity_browser/ui/widgets/buttons.py new file mode 100644 index 000000000..a153cfd66 --- /dev/null +++ b/activity_browser/ui/widgets/buttons.py @@ -0,0 +1,63 @@ +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + + +class ABCloseButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + + self.label = QtWidgets.QLabel("×", self) + + 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() + + self.label.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(255, 0, 0, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) + + +class ABMinimizeButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + self.label = QtWidgets.QLabel("-", self) + + 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() + + self.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(42, 157, 244, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index 726cc09c6..4829b7239 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -1,14 +1,11 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import signals +from .tab_widget import ABTabWidget -log = getLogger(__name__) - - -class CentralTabWidget(QtWidgets.QTabWidget): +class CentralTabWidget(ABTabWidget): """ A custom QTabWidget that manages groups of tabs and their associated pages. @@ -16,17 +13,6 @@ class CentralTabWidget(QtWidgets.QTabWidget): and ensuring that each page has a unique object name. """ - def __init__(self, *args): - """ - Initialize the CentralTabWidget. - - Args: - *args: Positional arguments passed to the parent QTabWidget. - """ - super().__init__(*args) - # Connect to the project changed signal to reset the current index to 0 - signals.project.changed.connect(self.reset) - @property def groups(self): """ @@ -59,6 +45,7 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): self.addTab(GroupTabWidget(group, self), group) group = self.groups[group] + self.setCurrentWidget(group) # Check if the page already exists in the group page_names = [group.widget(i).objectName() for i in range(group.count())] @@ -68,22 +55,17 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): page.setWindowTitle(name) # make sure the page has a title page.setParent(group) group.addTab(page, name) + group.setCurrentWidget(page) page.windowTitleChanged.connect(lambda title: group.setTabText(group.indexOf(page), title)) else: # Set the existing page as the current tab index = page_names.index(page.objectName()) group.setCurrentIndex(index) - - # Set the group and page as the current widgets - self.setCurrentWidget(group) - group.setCurrentWidget(page) - - def reset(self): - self.setCurrentIndex(0) + page.deleteLater() # Clean up the newly created page since it already exists -class GroupTabWidget(QtWidgets.QTabWidget): +class GroupTabWidget(ABTabWidget): """ A custom QTabWidget that represents a group of tabs. @@ -100,9 +82,6 @@ def __init__(self, name: str, *args): *args: Additional positional arguments passed to the parent QTabWidget. """ super().__init__(*args) - self.setMovable(True) # Allow tabs to be rearranged. - self.setTabsClosable(True) # Allow tabs to be closed. - self.setDocumentMode(True) # Enable document mode for a more modern appearance. self.setObjectName(name) # Set the object name for the widget. @@ -115,6 +94,7 @@ def connect_signals(self): - Connects the `tabCloseRequested` signal to the `tabClosed` method. - Connects the `project.changed` signal to the `deleteLater` method to clean up the widget. """ + from activity_browser.app import signals self.tabCloseRequested.connect(self.tabClosed) signals.project.changed.connect(self.deleteLater) 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/dialog.py b/activity_browser/ui/widgets/dialog.py deleted file mode 100644 index b98d714fe..000000000 --- a/activity_browser/ui/widgets/dialog.py +++ /dev/null @@ -1,572 +0,0 @@ -from logging import getLogger - -from qtpy import QtGui, QtWidgets -from qtpy.QtCore import Qt -from activity_browser.ui import widgets, icons - -log = getLogger(__name__) - - -class FilterManagerDialog(QtWidgets.QDialog): - """Set filters for a table. - - Dialog has 1 tab per given column. Each tab has rows for filters, - where type/query/other is defined. User can add/remove filters as desired. - When multiple filters exist for 1 column, user can choose AND/OR combination of filters. - AND/OR for combining columns can also be chosen. - - Required inputs: - - column names: dict --> the column names and their indices in the table - format: {'col_name': i} - Optional inputs: - - filters: dict --> pre-apply filters in the dialog (see format example below) - - selected_column: int --> open the dialog with this column tab open - - column_types: dict --> show other filters for this column - format: {'col_name': 'num'} - options: str/num, defaults to str if no type is given - - Interaction: - - call 'start_filter_dialog' of 'ABFilterableDataFrameView' to launch dialog, - filters are only applied when OK is selected. This calls self.get_filters, - which returns filter data as dict. - - example of filters (see also ABMultiColumnSortProxyModel): - filters = { - 0: {'filters': [('contains', 'heat', False), ('contains', 'electricity', False)], - 'mode': 'OR'}, - 1: {'filters': [('contains', 'market', False)]} - } - """ - - def __init__( - self, - column_names: dict, - filter_types: dict, - filters: dict = None, - selected_column: int = 0, - column_types: dict = {}, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(icons.qicons.filter) - self.setWindowTitle("Manage table filters") - - # set given filters, if any - if isinstance(filters, dict): - self.filters = filters - else: - self.filters = {} - - # create a tab for every column in the table - self.tab_widget = QtWidgets.QTabWidget() - self.tabs = [] - - # we need this dict as we may have hidden columns (e.g. CFTable) - self.col_id_2_tab_id = {} - for tab_id, col_data in enumerate(column_names.items()): - col_name, col_id = col_data - self.col_id_2_tab_id[col_id] = tab_id - tab = ColumnFilterTab( - parent=self, - state=self.filters.get(col_id, None), - col_type=column_types.get(col_name, "str"), - filter_types=filter_types, - ) - self.tabs.append(tab) - self.tab_widget.addTab(tab, col_name) - - # add AND/OR choice button. - self.and_or_buttons = AndOrRadioButtons(label_text="Combine columns:") - # in the extremely unlikely event there is only 1 column, hide the AND/OR option. - if len(column_names) == 1: - self.and_or_buttons.hide() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - # assemble layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tab_widget) - layout.addWidget(self.and_or_buttons) - layout.addWidget(self.buttons) - self.setLayout(layout) - - # set the column that launched the dialog as the open tab - self.tab_widget.setCurrentIndex(self.col_id_2_tab_id[selected_column]) - self.tabs[selected_column].filter_rows[-1].filter_query_line.setFocus() - - @property - def get_filters(self) -> dict: - state = {} - t2c = {v: k for k, v in self.col_id_2_tab_id.items()} - for tab_id, tab in enumerate(self.tabs): - tab_state = tab.get_state - if isinstance(tab_state, dict): - state[t2c[tab_id]] = tab_state - if len(state) == 0: - return - state["mode"] = self.and_or_buttons.get_state - return state - - -class SimpleFilterDialog(QtWidgets.QDialog): - """Add one filter to a column. - - Related to FilterManagerDialog. - """ - - def __init__( - self, - column_name: dict, - filter_types: dict, - column_type: str = "str", - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(icons.qicons.filter) - self.setWindowTitle("Add filter") - - # Create filter label and buttons - label = QtWidgets.QLabel("Define a filter for column '{}'".format(column_name)) - - if column_type == "num": - self.filter_row = NumFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - else: - # if none of the above types, assume str - self.filter_row = StrFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - - self.filter_row.filter_query_line.setFocus() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(label) - layout.addWidget(self.filter_row) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def get_filter(self) -> tuple: - if self.filter_row.get_state: - return self.filter_row.get_state - - -class ColumnFilterTab(QtWidgets.QWidget): - """Content of column tab. - - Required inputs: - - None - Optional inputs: - - col_type: str --> the type of column, either 'str' or 'num'. defines the search type options. - defaults to 'str' - - state: dict --> dict of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter elements (filter rows, AND/OR menu) - returns: dict - - def set_state: Writes given state dict to UI elements (filter rows, AND/OR menu) - """ - - def __init__( - self, filter_types: dict, col_type: str = "str", state: dict = {}, parent=None - ): - super().__init__(parent) - self.filter_types = filter_types - self.col_type = col_type - - self.add = QtWidgets.QToolButton() - self.add.setIcon(icons.qicons.add) - self.add.setToolTip("Add a new filter for this column") - self.add.clicked.connect(self.add_row) - - self.and_or_buttons = AndOrRadioButtons( - label_text="Combine filters within column:" - ) - if self.col_type == "str": - self.and_or_buttons.set_state("OR") - - self.filter_rows = [] - self.filter_widget_layout = QtWidgets.QVBoxLayout() - self.filter_widget = QtWidgets.QWidget() - self.filter_widget.setLayout(self.filter_widget_layout) - - # set the state, adds 1 empty row if state=={} - self.set_state(state) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.filter_widget) - layout.addWidget(self.add) - layout.addStretch() - layout.addWidget(self.and_or_buttons) - self.setLayout(layout) - - def add_row(self, state: tuple = None) -> None: - """Add a new row to the self.filter_rows.""" - idx = len(self.filter_rows) - - if self.col_type == "num": - new_filter_row = NumFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - else: - # if none of the above types, assume str - new_filter_row = StrFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - - self.filter_rows.append(new_filter_row) - self.filter_widget_layout.addWidget(new_filter_row) - self.show_hide_and_or() - - def remove_row(self, idx: int) -> None: - """Remove the row from the setup""" - # remove the row from widget and self.filter_rows - self.filter_widget_layout.itemAt(idx).widget().deleteLater() - self.filter_rows.pop(idx) - # re-index the list of rows - for i, filter_row in enumerate(self.filter_rows): - filter_row.idx = i - # if there would be no remaining rows, add a new empty one - if len(self.filter_rows) == 0: - self.add_row() - self.show_hide_and_or() - - @property - def get_state(self) -> dict: - # check if there are filters - if len(self.filter_rows) == 0: - return None - # check if there are valid filters - valid_filters = [row.get_state for row in self.filter_rows if row.get_state] - if len(valid_filters) == 0: - return None - elif len(valid_filters) == 1: - return {"filters": valid_filters} - else: - return {"filters": valid_filters, "mode": self.and_or_buttons.get_state} - - def set_state(self, state: dict) -> None: - if not state: - self.add_row() - self.and_or_buttons.hide() - return - - # add one row per filter - filters = state["filters"] - self.filter_rows = [] - for filter_state in filters: - self.add_row(filter_state) - - # set state and show/hide the AND/OR widget - self.show_hide_and_or() - if state.get("mode", False): - self.and_or_buttons.set_state(state["mode"]) - - def show_hide_and_or(self) -> None: - if len(self.filter_rows) > 1: - self.and_or_buttons.show() - else: - self.and_or_buttons.hide() - - -class FilterRow(QtWidgets.QWidget): - """Convenience class for managing a filter input row. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - idx: int --> integer index in self.filter_rows of parent. Used as ID in parent - idx is the index position of this FilterRow in the list of rows in parent. - - filter_types: dict --> the types of filter available - Optional inputs: - - state: tuple --> tuple of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter fields (filter type, query, case sensitive) - returns: tuple - - def set_state: Writes given state tuple to UI elements (filter type, query, case sensitive) - """ - - def __init__( - self, - idx: int, - filter_types: dict, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - - self.idx = idx - self.filter_types = filter_types - self.filter_type = self.filter_types[self.column_type] - self.parent = parent - - self.row_layout = QtWidgets.QHBoxLayout() - - # create a 'filter type' combobox - self.filter_type_box = QtWidgets.QComboBox() - self.filter_type_box.addItems(self.filter_type) - # set a preset type if given - if isinstance(preset_type, str): - self.filter_type_box.setCurrentIndex(self.filter_type.index(preset_type)) - # add tooltip for every type option - for i, tt in enumerate(self.filter_types[self.column_type + "_tt"]): - self.filter_type_box.setItemData(i, tt, Qt.ToolTipRole) - - # create the filter input line - self.filter_query_line = QtWidgets.QLineEdit() - self.filter_query_line.setFocusPolicy(Qt.StrongFocus) - - if remove_option: - # add buttons to remove the row - self.remove = QtWidgets.QToolButton() - self.remove.setIcon(icons.qicons.delete) - self.remove.setToolTip("Remove this filter") - self.remove.clicked.connect(self.self_destruct) - - @property - def get_state(self) -> tuple: - raise NotImplementedError - - def set_state(self, state: tuple) -> None: - raise NotImplementedError - - def set_input_changes(self) -> None: - raise NotImplementedError - - def self_destruct(self) -> None: - """Remove this FilterRow object from parent.""" - self.parent.remove_row(self.idx) - - -class StrFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'str' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "str" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # create case-sensitive box - self.case_sensitive_text = QtWidgets.QLabel("Case Sensitive:") - self.filter_case_sensitive_check = QtWidgets.QCheckBox() - - # assemble the layout - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - self.row_layout.addWidget(self.case_sensitive_text) - self.row_layout.addWidget(self.filter_case_sensitive_check) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(widgets.ABVLine(self)) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", "\n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - case_sensitive = self.filter_case_sensitive_check.isChecked() - return selected_type, selected_query, case_sensitive - - def set_state(self, state: tuple) -> None: - selected_type, selected_query, case_sensitive = state - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - self.filter_query_line.setText(selected_query) - self.filter_case_sensitive_check.setChecked(case_sensitive) - - def set_input_changes(self) -> None: - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - -class NumFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'num' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "num" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # add an input line in case 'between' ('<= x <=') is selected - self.filter_query_line0 = QtWidgets.QLineEdit() - self.filter_query_line0.hide() - - # set 'double' validator for input lines - self.filter_query_line0.setValidator(QtGui.QDoubleValidator()) - self.filter_query_line.setValidator(QtGui.QDoubleValidator()) - - # assemble the layout - self.row_layout.addWidget(self.filter_query_line0) - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(widgets.ABVLine(self)) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", " \n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - if self.filter_type_box.currentText() == "<= x <=": - selected_query = ( - self.filter_query_line0.text(), - self.filter_query_line.text(), - ) - return selected_type, selected_query - - def set_state(self, state: tuple) -> None: - selected_type, selected_query = state - self.set_input_changes() - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - if selected_type == "<= x <=": - self.filter_query_line0.setText(selected_query[0]) - self.filter_query_line.setText(selected_query[1]) - else: - self.filter_query_line.setText(selected_query) - - def set_input_changes(self) -> None: - # enable whether the extra input line is visible - if self.filter_type_box.currentText() == "<= x <=": - self.filter_query_line0.show() - else: - self.filter_query_line0.hide() - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - -class AndOrRadioButtons(QtWidgets.QWidget): - """Convenience class for managing AND/OR buttons. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - None - Optional inputs: - - label_text: str --> - - state: str --> str of existing AND/OR state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of AND/OR radio buttons (string of 'AND' or 'OR') - returns: str - - def set_state: Writes given AND/OR state UI element (string of 'AND' or 'OR') - """ - - def __init__(self, label_text: str = "", state: str = None, parent=None): - super().__init__(parent) - # create an AND/OR widget - layout = QtWidgets.QHBoxLayout() - self.btn_group = QtWidgets.QButtonGroup() - self.AND = QtWidgets.QRadioButton("AND") - self.OR = QtWidgets.QRadioButton("OR") - self.btn_group.addButton(self.AND) - self.btn_group.addButton(self.OR) - layout.addStretch() - layout.addWidget(QtWidgets.QLabel(label_text)) - layout.addWidget(self.AND) - layout.addWidget(self.OR) - self.setLayout(layout) - self.setToolTip( - "Choose how filters combine with each other.\n" - "AND must satisfy all filters, OR must satisfy at least one filter." - ) - - # set the state if one was given, otherwise, assume AND - if isinstance(state, str): - self.set_state(state) - else: - self.set_state("AND") - - @property - def get_state(self) -> str: - return self.btn_group.checkedButton().text() - - def set_state(self, state: str) -> None: - x = True - if state == "OR": - x = False - self.AND.setChecked(x) - self.OR.setChecked(not x) diff --git a/activity_browser/ui/widgets/dock_widget.py b/activity_browser/ui/widgets/dock_widget.py index 1917d89f1..df428393c 100644 --- a/activity_browser/ui/widgets/dock_widget.py +++ b/activity_browser/ui/widgets/dock_widget.py @@ -1,6 +1,8 @@ -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt +from .buttons import ABCloseButton, ABMinimizeButton + class HideMode: Close = 1 @@ -32,10 +34,10 @@ def setWidget(self, widget): def button(self): if self._hide_mode == HideMode.Close: - button = CloseButton(self) + button = ABCloseButton(self) button.clicked.connect(self.close) else: - button = MinimizeButton(self) + button = ABMinimizeButton(self) button.clicked.connect(self.hide) return button @@ -64,82 +66,3 @@ def set_button(self, button): w.deleteLater() -class CloseButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - - self.label = QtWidgets.QLabel("×", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.label.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(255, 0, 0, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) - - -class MinimizeButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - self.label = QtWidgets.QLabel("-", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(42, 157, 244, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) - - -def mousePressEvent(self, event): - if event.button() == Qt.LeftButton: - self.drag_start_pos = event.pos() - - -def mouseMoveEvent(self, event): - if not self.drag_start_pos: - return - - # Check if mouse moved beyond threshold - if (event.pos() - self.drag_start_pos).manhattanLength() > QtWidgets.QApplication.startDragDistance(): - index = self.tabAt(self.drag_start_pos) - if index >= 0: - startDrag(self, index) - -def startDrag(self, index): - """Start dragging a tab.""" - print("Dragging success") 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 0a1c6e564..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 @@ -290,28 +294,45 @@ def get_cursor_position_from_x(self, x): x_offset = x - self.padding + self.scroll_offset cursor_pos = len(self.text) - for i in range(len(self.text)): - if font_metrics.horizontalAdvance(self.text[:i]) > x_offset: - cursor_pos = i - break + for i in range(len(self.text) + 1): + char_x = font_metrics.horizontalAdvance(self.text[:i]) + if i < len(self.text): + next_char_x = font_metrics.horizontalAdvance(self.text[:i + 1]) + mid_point = (char_x + next_char_x) / 2 + if x_offset < mid_point: + cursor_pos = i + break + else: + # Past the end of the text + if x_offset >= char_x: + cursor_pos = i + break return cursor_pos def mousePressEvent(self, event): """Handles mouse click events to set cursor position and start selection.""" - if 10 <= event.x() <= 390 and 10 <= event.y() <= 40: + if self.rect().contains(event.pos()): self.cursor_pos = self.get_cursor_position_from_x(event.x()) - self.selection_start = self.cursor_pos # Start selection + self.selection_start = None # Clear selection initially self.selection_end = None # Reset end position self.dragging = True # Start dragging self.adjust_scroll() - self.update() + self.cursor_visible = True # Show cursor immediately + self.update() # Force immediate redraw + self.timer.stop() # Stop the timer + self.timer.start(500) # Restart blink timer def mouseMoveEvent(self, event): """Handles mouse dragging for text selection.""" if self.dragging: - self.selection_end = self.get_cursor_position_from_x(event.x()) - self.cursor_pos = self.selection_end + new_pos = self.get_cursor_position_from_x(event.x()) + # Start selection on first move if not already started + if self.selection_start is None and new_pos != self.cursor_pos: + self.selection_start = self.cursor_pos + if self.selection_start is not None: + self.selection_end = new_pos + self.cursor_pos = new_pos self.adjust_scroll() self.update() @@ -333,6 +354,7 @@ def paintEvent(self, event): painter.setPen(Qt.NoPen) painter.fillRect(self.rect(), background_color) self.paint_text(painter) + painter.end() def paint_text(self, painter: QPainter): painter.setFont(self.font()) @@ -360,7 +382,7 @@ def paint_text(self, painter: QPainter): if not painter.pen() == Qt.NoPen: pass - if token_type == "NUMBER": + elif token_type == "NUMBER": painter.setPen(Colors.number) elif token_type in ["SQSTRING", "DQSTRING"]: painter.setPen(Colors.string) 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 655d269d5..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): @@ -49,12 +48,12 @@ def _text_changed(self, text: str) -> None: @Slot(name="customEditFinish") def _editing_finished(self) -> None: - from activity_browser import actions + from activity_browser import app after = self.text() if self._before != after: self._before = after - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) class SignalledPlainTextEdit(QtWidgets.QPlainTextEdit): @@ -78,11 +77,11 @@ def highlight(self): self.setExtraSelections([selection]) def focusOutEvent(self, event): - from activity_browser import actions + from activity_browser import app after = self.toPlainText() if self._before != after: - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) super().focusOutEvent(event) def refresh_text(self, text: str) -> None: @@ -104,19 +103,10 @@ def __init__(self, key, field, contents="", parent=None): self._field = field def focusOutEvent(self, event): - from activity_browser import actions + from activity_browser import app after = self.currentText() if self._before != after: self._before = after - actions.ActivityModify.run(self._key, self._field, 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/main_window.py b/activity_browser/ui/widgets/main_window.py deleted file mode 100644 index e5caac648..000000000 --- a/activity_browser/ui/widgets/main_window.py +++ /dev/null @@ -1,111 +0,0 @@ -import pickle -from logging import getLogger - -from qtpy import QtCore, QtWidgets, QtGui - -import bw2data as bd - -from activity_browser import signals, application -from activity_browser.ui import icons - -from activity_browser.ui.menu_bar import MenuBar - -log = getLogger(__name__) - - -class MainWindow(QtWidgets.QMainWindow): - - def __init__(self, parent=None): - super().__init__(parent) - - self.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) - self.setWindowTitle("Activity Browser") - self.setDockNestingEnabled(True) - - # Layout: extra items outside main layout - self.menu_bar = MenuBar(self) - self.setMenuBar(self.menu_bar) - - self.connect_signals() - - def sync(self): - """ - Synchronizes the main window layout with the current Brightway2 project. - - This method clears existing panes, initializes default panes, and arranges them - in the main window. Hidden panes are set to be invisible, and the first pane is - raised to the top. The window title is updated to reflect the current project. - - Steps: - - Clear all existing panes. - - Create and add default panes as dock widgets. - - Hide panes that are marked as hidden. - - Tabify dock widgets for better organization. - - Raise the first dock widget to the top. - - Update the window title with the current project name. - - Args: - self: The instance of the MainWindow class. - """ - from activity_browser.layouts import panes - - # Clear all existing panes in the main window - self.clearPanes() - - dws = [] - # Iterate through the default panes and add them as dock widgets - for pane_class in panes.default_panes: - pane = pane_class(parent=self) - dockwidget = pane.getDockWidget(self) - dws.append(dockwidget) - - # Add the dock widget to the left dock area - self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dockwidget) - # Add the toggle view action to the menu bar - self.menu_bar.view_menu.addAction(dockwidget.toggleViewAction()) - - # Hide the dock widget if it is marked as hidden - if pane_class in panes.hidden_panes: - dockwidget.hide() - - # Synchronize the pane - pane.sync() - - # Tabify the dock widgets for better organization - for dw in dws: - if dw == dws[0]: - continue - self.tabifyDockWidget(dws[0], dw) - - # Raise the first dock widget to the top - dws[0].raise_() - - # Update the window title to reflect the current project - self.setWindowTitle(f"Activity Browser - {bd.projects.current}") - - def connect_signals(self): - # Keyboard shortcuts - signals.project.changed.connect(self.sync) - - def clearPanes(self): - for pane in self.panes(): - pane.deleteLater() - - def panes(self): - """ - Return a list of all panes in the main window. - """ - from activity_browser.ui import widgets - return self.findChildren(widgets.ABAbstractPane) - - def set_titlebar(self): - self.setWindowTitle(f"Activity Browser - {bd.projects.current}") - - def dialog_on_exception(self, exception: Exception): - QtWidgets.QMessageBox.critical( - self, - f"An error occurred: {type(exception).__name__}", - f"An error occurred, check the logs for more information \n\n {str(exception)}", - QtWidgets.QMessageBox.Ok, - ) - diff --git a/activity_browser/ui/widgets/menu.py b/activity_browser/ui/widgets/menu.py index 52f332749..c0399b076 100644 --- a/activity_browser/ui/widgets/menu.py +++ b/activity_browser/ui/widgets/menu.py @@ -1,10 +1,10 @@ from qtpy import QtWidgets -from typing import Callable, Optional +from typing import Callable from inspect import signature class ABMenu(QtWidgets.QMenu): - menuSetup: list[Callable[["ABMenu", Optional[QtWidgets.QWidget]], None]] + menuSetup: list[Callable[["ABMenu", QtWidgets.QWidget], None]] title: str = None def __init__(self, pos=None, parent=None, title: str = None): @@ -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/new_node_dialog.py b/activity_browser/ui/widgets/new_node_dialog.py deleted file mode 100644 index 8e72ad144..000000000 --- a/activity_browser/ui/widgets/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/widgets/plot.py b/activity_browser/ui/widgets/plot.py new file mode 100644 index 000000000..b9bdf1f27 --- /dev/null +++ b/activity_browser/ui/widgets/plot.py @@ -0,0 +1,64 @@ +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)" + SVG_FILTER = "SVG (*.svg)" + + def __init__(self, parent=None): + super().__init__(parent) + # create figure, canvas, and axis + self.figure = Figure(constrained_layout=True) + self.canvas = FigureCanvasQTAgg(self.figure) + self.canvas.setMinimumHeight(0) + + self.ax = self.figure.add_subplot(111) # create an axis + self.plot_name = "Figure" + + # set the layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.canvas) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self.updateGeometry() + + def plot(self, *args, **kwargs): + raise NotImplementedError + + def reset_plot(self) -> None: + self.figure.clf() + self.ax = self.figure.add_subplot(111) + + def get_canvas_size_in_inches(self): + return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) + + def to_png(self): + """Export to .png format.""" + from activity_browser.bwutils.commontasks import savefilepath + + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.PNG_FILTER + ) + if filepath: + if not filepath.endswith(".png"): + filepath += ".png" + self.figure.savefig(filepath) + + def to_svg(self): + """Export to .svg format.""" + from activity_browser.bwutils.commontasks import savefilepath + + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.SVG_FILTER + ) + if filepath: + if not filepath.endswith(".svg"): + filepath += ".svg" + self.figure.savefig(filepath) + diff --git a/activity_browser/ui/widgets/tab_widget.py b/activity_browser/ui/widgets/tab_widget.py new file mode 100644 index 000000000..d3a00561b --- /dev/null +++ b/activity_browser/ui/widgets/tab_widget.py @@ -0,0 +1,61 @@ +from qtpy import QtWidgets + +from .buttons import ABCloseButton, ABMinimizeButton + + +class ABTabWidget(QtWidgets.QTabWidget): + def __init__(self, *args, **kwargs): + """ + Initialize the GroupTabWidget. + + 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, **kwargs) + self.setMovable(True) # Allow tabs to be rearranged. + self.setTabsClosable(True) # Allow tabs to be closed. + self.tabBar().setExpanding(False) + + + def resizeEvent(self, event): + super().resizeEvent(event) + # Force the tab bar to always fill the full width + self.tabBar().setMinimumWidth(self.width()) + + def addTab(self, widget, label, show_minimize=False): + """Override addTab to add custom buttons to each tab. + + Args: + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().addTab(widget, label) + self._set_buttons(index, widget, show_minimize) + return index + + def insertTab(self, index, widget, label, show_minimize=False): + """Override insertTab to add custom buttons to each tab. + + Args: + index: The index at which to insert the tab + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().insertTab(index, widget, label) + self._set_buttons(index, widget, show_minimize) + return index + + def _set_buttons(self, index, widget, show_minimize=False): + tab_bar = self.tabBar() + button = ABMinimizeButton() if show_minimize else ABCloseButton() + tab_bar.setTabButton(index, QtWidgets.QTabBar.ButtonPosition.RightSide, button) + button.clicked.connect(lambda w=widget: self.closeTabByWidget(w)) + + def closeTabByWidget(self, widget): + """Handle close button click using the widget reference.""" + index = self.indexOf(widget) + if index >= 0: + self.tabCloseRequested.emit(index) diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py new file mode 100644 index 000000000..7c1a20a4f --- /dev/null +++ b/activity_browser/ui/widgets/text_edit.py @@ -0,0 +1,255 @@ +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): + if not self.mds.searcher: + return + + 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): + if not self.mds.searcher: + return + + 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/treeview.py b/activity_browser/ui/widgets/tree_view.py similarity index 57% rename from activity_browser/ui/widgets/treeview.py rename to activity_browser/ui/widgets/tree_view.py index 6b6c6ef06..a94ee9777 100644 --- a/activity_browser/ui/widgets/treeview.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -1,13 +1,10 @@ -from logging import getLogger - -import pandas as pd +from loguru import logger from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt -from .item_model import ABItemModel +from activity_browser.ui import core -log = getLogger(__name__) +from .line_edit import ABLineEdit class ABTreeView(QtWidgets.QTreeView): @@ -25,16 +22,16 @@ def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): 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) - self.addAction(QtGui.QIcon(), "Group by column", lambda: model.group(col_index)) + self.addAction(QtGui.QIcon(), "Group by column", lambda: model.group([col_name])) 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", @@ -70,9 +67,11 @@ def __init__(self, pos, view): super().__init__(view) def __init__(self, parent=None): - super().__init__(parent) + from activity_browser.ui import delegates - self.setUniformRowHeights(True) + super().__init__(parent) + self.setIndentation(10) + self.setItemDelegate(delegates.StringDelegate(self)) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.showContextMenu) @@ -80,29 +79,33 @@ def __init__(self, parent=None): 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.header().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.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.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): - if not isinstance(model, ABItemModel): - raise TypeError("Model must be an instance of ABItemModel") super().setModel(model) - model.modelReset.connect(self.expand_after_reset) + 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, 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 @@ -135,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): @@ -143,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 @@ -154,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) and i not in self.model().grouped_columns: + if col == "index" or self.isColumnHidden(i): continue all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") @@ -162,17 +165,17 @@ def buildQuery(self) -> str: queries.append(q) query = " & ".join(queries) - log.debug(f"{self.__class__.__name__} built query: {query}") + logger.debug(f"{self.__class__.__name__} built query: {query}") return query def applyFilter(self): query = self.buildQuery() try: - self.model().setQuery(query) + self.model().filter("ABTreeView", query) self.filtered.emit(True) except Exception as e: - log.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") + logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") self.filtered.emit(False) @staticmethod @@ -190,80 +193,67 @@ def setDefaultColumnDelegates(self): 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() + elif col_name.startswith("property_"): + self.setItemDelegateForColumn(i, self.propertyDelegate) - 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(): - log.debug(f"{self.__class__.__name__}: Model must first be set on the treeview before using restoreState") + def updateIndexColumnVisibility(self): + """Hide the index column (column 0) if the dataframe index is only one level deep.""" + model = self.model() + if model is None: + return + + # Check if model has the df attribute (ABTreeModel style) + if hasattr(model, 'df') and hasattr(model.df, 'index'): + # Hide index column if it's only one level deep + hide_index = model.df.index.nlevels == 1 + self.setColumnHidden(0, hide_index) + + def updateBranchSpanning(self): + """Enable spanning for branch nodes so they span across all columns.""" + model = self.model() + if model is None or not hasattr(model, 'isBranchNode'): + return + + # 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 - 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: + # 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 - for index in indices: - self.expand(index) + # 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() + if model is None: + return + + row_count = model.rowCount(parent) + for row in range(row_count): + index = model.index(row, 0, parent) + if not index.isValid(): + continue + + # Check if this is a branch node + if hasattr(model, 'isBranchNode') and model.isBranchNode(index): + self.setFirstColumnSpanned(row, parent, True) + # Recursively process children + self._setSpanningRecursive(index) + else: + self.setFirstColumnSpanned(row, parent, False) diff --git a/activity_browser/ui/widgets/web_engine_page.py b/activity_browser/ui/widgets/web_engine_page.py new file mode 100644 index 000000000..07f0a67ea --- /dev/null +++ b/activity_browser/ui/widgets/web_engine_page.py @@ -0,0 +1,15 @@ +from loguru import logger + +from qtpy.QtWebEngineWidgets import 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}") + elif level == QWebEnginePage.WarningMessageLevel: + logger.warning(f"JS Warning (Line {line}): {message}") + elif level == QWebEnginePage.ErrorMessageLevel: + logger.error(f"JS Error (Line {line}): {message}") + else: + logger.debug(f"JS Log (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 122ebd2c8..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,12 +37,15 @@ 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"] def __init__(self, parent=None): - from activity_browser import application + from activity_browser.app import application super().__init__(parent) diff --git a/activity_browser/ui/wizards/__init__.py b/activity_browser/ui/wizards/__init__.py deleted file mode 100644 index c23411065..000000000 --- a/activity_browser/ui/wizards/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .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 bf899f8c2..000000000 --- a/activity_browser/ui/wizards/settings_wizard.py +++ /dev/null @@ -1,316 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from logging import getLogger -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 - -log = getLogger(__name__) - - -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 - log.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 - log.info(f"Saved startup project as: {new_startup_project}") - - ab_settings.write_settings() - projects.change_base_directories(Path(field), update=False) - - def cancel(self): - log.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: - log.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/ui/wizards/uncertainty.py b/activity_browser/ui/wizards/uncertainty.py deleted file mode 100644 index e78b184c5..000000000 --- a/activity_browser/ui/wizards/uncertainty.py +++ /dev/null @@ -1,715 +0,0 @@ -from logging import getLogger - -import numpy as np -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 import actions -from .. import application - -from ...bwutils import PedigreeMatrix, get_uncertainty_interface -from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -from ..figures import SimpleDistributionPlot - -log = getLogger(__name__) - - -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. - """ - self.amount_mean_test() - if self.obj.data_type == "exchange": - actions.ExchangeModify.run(self.obj.data, self.uncertainty_info) - if self.using_pedigree: - actions.ExchangeModify.run( - self.obj.data, {"pedigree": self.pedigree.matrix.factors} - ) - elif self.obj.data_type == "parameter": - actions.ParameterModify.run(self.obj.data, "data", self.uncertainty_info) - if self.using_pedigree: - 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. - """ - 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": - actions.ExchangeModify.run(self.obj.data, {"amount": mean}) - - elif self.obj.data_type == "parameter": - try: - actions.ParameterModify.run(self.obj.data, "amount", mean) - except Exception as e: - QtWidgets.QMessageBox.warning( - application.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: - log.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) diff --git a/activity_browser/utils.py b/activity_browser/utils.py deleted file mode 100644 index 655d8b1f9..000000000 --- a/activity_browser/utils.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -from pathlib import Path -from typing import Iterable, Tuple - -import requests -from qtpy import QtWidgets - -from activity_browser.mod import bw2data as bd - -from .settings import ab_settings - - -def get_base_path() -> Path: - return Path(__file__).resolve().parents[0] - - -def read_file_text(file_dir: str) -> str: - if not file_dir: - raise ValueError("File path passed is empty") - file = open(file_dir, mode="r", encoding="UTF-8") - if not file: - raise ValueError("File does not exist in the passed path:", file_dir) - text = file.read() - file.close() - return text - - -def savefilepath( - default_file_name: str = "AB_file", file_filter: str = "All Files (*.*)" -): - """A central function to get a safe file path.""" - safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=None, - caption="Choose location for saving", - dir=os.path.join(ab_settings.data_dir, safe_name), - filter=file_filter, - ) - return filepath - - -def safe_link_fetch(url: str) -> Tuple[object, object]: - """ - Get a web-page or file from the internet or the error of getting the link. - - Parameters - ---------- - url: a link - - Returns - ------- - object: error if any, otherwise None - object: response if no error, otherwise None - """ - try: - response = requests.get(url, timeout=2) # retrieve the page from the URL - response.raise_for_status() - except Exception as error: - return (None, error) - - return (response, None) - - -def sort_semantic_versions(versions: Iterable, highest_to_lowest: bool = True) -> list: - """Return a sorted (default highest to lowest) list of semantic versions. - - Sorts based on the semantic versioning system. - """ - return list( - sorted( - versions, - key=lambda x: tuple(map(int, x.split("."))), - reverse=highest_to_lowest, - ) - ) - - -def get_templates() -> dict: - import platformdirs, os - - base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") - template_dir = os.path.join(base_dir, "templates") - os.makedirs(template_dir, exist_ok=True) - - collection = {} - - for file in os.listdir(template_dir): - if file.endswith(".tar.gz"): - collection[file[:-7]] = os.path.join(template_dir, file) - - return collection - 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/docs/getting-started/installation.md b/docs/getting-started/installation.md index da0998ece..1c13a1944 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -35,8 +35,7 @@ For more elaborate installing instructions check out the page below for both [in ## 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. -At this moment the AB is compatible with Python versions 3.10, 3.11, and 3.12. +First make sure you have Python installed on your PC by entering the following command into your terminal or command prompt. ``` python --version @@ -52,7 +51,6 @@ Afterwards, you need to activate the virtual environment, which differs between ``` C:\Users\me\virtualenvs\ab-beta\Scripts\activate.bat ``` -Possibly double-check if the Python version of your virtual environment is compatible with the AB. 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 installation diff --git a/docs/img.png b/docs/img.png new file mode 100644 index 000000000..47db4e6ed Binary files /dev/null and b/docs/img.png differ diff --git a/pyinstaller.spec b/pyinstaller.spec new file mode 100644 index 000000000..b45256332 --- /dev/null +++ b/pyinstaller.spec @@ -0,0 +1,74 @@ +# -*- mode: python ; coding: utf-8 -*- +import sys +from pathlib import Path +from PyInstaller.utils.hooks import collect_data_files + + +if sys.platform == "win32": + pardiso_deps = [ + "libiomp5md.dll", + "mkl_core.2.dll", + "mkl_intel_thread.2.dll", + "mkl_avx2.2.dll", + "tbbmalloc.dll", + "mkl_vml_avx2.2.dll", + "mkl_rt.2.dll", + ] + + bin_dir = Path(sys.prefix) / "Library" / "bin" + binaries = [(str(bin_dir / dll), "lib") for dll in pardiso_deps if (bin_dir / dll).exists()] +else: + binaries = [] + +block_cipher = None + +# Collect all data files from activity_browser package +ab_datas = collect_data_files('activity_browser') + +a = Analysis( + ['run-activity-browser.py'], + pathex=[], + binaries=binaries, + datas=ab_datas, + hiddenimports=[ + 'activity_browser', + 'PySide6', + 'bw2data', + 'bw2io', + 'bw2calc', + 'pypardiso', + 'scikits.umfpack', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='activity-browser', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='activity_browser/static/icons/main/activitybrowser.ico', +) diff --git a/pyproject.toml b/pyproject.toml index 93cd012da..c6a80f712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "arrow", "bw2analyzer>=0.11.5", "bw2calc>=2.0", - "bw2data>=4.1", + "bw2data>=4.1, <4.5.2", "bw2parameters>=1.1", "bw2io>=0.9.3", "bw_graph_tools>=0.5", @@ -43,19 +43,20 @@ dependencies = [ "bw_simapro_csv >=0.2.6", "ecoinvent_interface", "matrix_utils>=0.5", - "bw-functional==0b94", + "bw-functional==0b97", "networkx", "numpy>=1.23.5,<2", "pandas>=2.2.1", "pint<=0.21", "py7zr==0.22.0", "pyperclip", - "pyside6>=6.5.0, <6.10", + "pyside6", "pypardiso ; platform_system == 'Windows'", "pyprind", "qtpy", "salib>=1.4", "seaborn", + "loguru>=0.7", ] diff --git a/recipe/README.md b/recipe/README.md new file mode 100644 index 000000000..bb76dc36b --- /dev/null +++ b/recipe/README.md @@ -0,0 +1,192 @@ +# 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 +``` + +### 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 +``` + +## 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/recipe/meta.yaml b/recipe/meta.yaml index 4cbf5ba87..82364aabd 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -21,13 +21,13 @@ requirements: - python >=3.10, <3.12 - arrow - bw2analyzer >=0.11.5 - - bw2data >=4.1 + - bw2data >=4.1, <4.5.2 - bw2parameters >=1.1 - bw2io >=0.9.3 - bw_graph_tools >=0.5 - bw_processing >=1.0 - bw_simapro_csv >=0.2.6 - - bw_functional=0.b.94 + - bw_functional=0.b.97 - ecoinvent_interface - matrix_utils >=0.5 - numpy >=1.23.5, <2 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 ffba13834..33c57dcf6 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -3,7 +3,7 @@ from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import actions, application +from activity_browser import app, app def test_activity_delete(monkeypatch, basic_database): @@ -16,7 +16,7 @@ def test_activity_delete(monkeypatch, basic_database): process = basic_database.get("process") - actions.ActivityDelete.run([process.key]) + app.actions.ActivityDelete.run([process.key]) assert len(basic_database) == 1 # removed process and products @@ -28,7 +28,7 @@ def test_activity_duplicate(basic_database): assert len(basic_database) == 4 process = basic_database.get("process") - actions.ActivityDuplicate.run([process.key]) + app.actions.ActivityDuplicate.run([process.key]) assert len(basic_database) == 7 @@ -42,13 +42,13 @@ def test_activity_duplicate(basic_database): # assert get_activity(key) # assert key not in panel.tabs # -# actions.ActivityGraph.run([key]) +# app.actions.ActivityGraph.run([key]) # # assert key in panel.tabs # # def test_activity_new(monkeypatch, basic_database): - from activity_browser.ui.widgets.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) @@ -62,7 +62,7 @@ def test_activity_new(monkeypatch, basic_database): assert len(basic_database) == 4 - actions.ActivityNewProcess.run(basic_database.name) + app.actions.ActivityNewProcess.run(basic_database.name) assert len(basic_database) == 6 assert len([p for p in basic_database if p["name"] == "new_process"]) == 2 @@ -72,16 +72,16 @@ def test_activity_new(monkeypatch, basic_database): def test_process_open(basic_database): process = basic_database.get("process") - actions.ActivityOpen.run([process.key]) + app.actions.ActivityOpen.run([process.key]) - group = application.main_window.centralWidget().groups["Activity Details"] + group = app.main_window.centralWidget().groups["Activity Details"] assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] # def test_product_open(application_instance, basic_database): # product = basic_database.get("product_1") # -# actions.ActivityOpen.run([product.key]) +# app.actions.ActivityOpen.run([product.key]) # # group = application_instance.main_window.centralWidget().groups["Activity Details"] # assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] @@ -104,6 +104,6 @@ def test_process_open(basic_database): # assert projects.current == "default" # assert list(get_activity(key).exchanges())[1].input.key == from_key # -# actions.ActivityRelink.run([key]) +# app.actions.ActivityRelink.run([key]) # # assert list(get_activity(key).exchanges())[1].input.key == to_key diff --git a/tests/actions/test_calculation_setup_actions.py b/tests/actions/test_calculation_setup_actions.py index e8f40a946..2b2eba52c 100644 --- a/tests/actions/test_calculation_setup_actions.py +++ b/tests/actions/test_calculation_setup_actions.py @@ -1,9 +1,7 @@ -import pytest import bw2data as bd -from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import actions +from activity_browser import app @@ -20,7 +18,7 @@ def test_cs_delete(monkeypatch, basic_database): assert cs_name in bd.calculation_setups - actions.CSDelete.run(cs_name) + app.actions.CSDelete.run(cs_name) assert cs_name not in bd.calculation_setups @@ -38,7 +36,7 @@ def test_cs_duplicate(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert duplicated not in bd.calculation_setups - actions.CSDuplicate.run(cs_name) + app.actions.CSDuplicate.run(cs_name) assert cs_name in bd.calculation_setups assert duplicated in bd.calculation_setups @@ -55,7 +53,7 @@ def test_cs_new(monkeypatch, basic_database): assert new_cs not in bd.calculation_setups - actions.CSNew.run() + app.actions.CSNew.run() assert new_cs in bd.calculation_setups @@ -73,7 +71,7 @@ def test_cs_rename(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert renamed_cs not in bd.calculation_setups - actions.CSRename.run(cs_name) + app.actions.CSRename.run(cs_name) assert cs_name not in bd.calculation_setups assert renamed_cs in bd.calculation_setups diff --git a/tests/actions/test_database_actions.py b/tests/actions/test_database_actions.py index 5914362ac..5882d4178 100644 --- a/tests/actions/test_database_actions.py +++ b/tests/actions/test_database_actions.py @@ -1,7 +1,7 @@ import bw2data as bd from qtpy import QtWidgets -from activity_browser import actions, application +from activity_browser import app, app def test_database_delete(monkeypatch, basic_database): @@ -11,13 +11,13 @@ def test_database_delete(monkeypatch, basic_database): staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - actions.DatabaseDelete.run([basic_database.name]) + app.actions.DatabaseDelete.run([basic_database.name]) assert basic_database.name not in bd.databases def test_database_duplicate(monkeypatch, qtbot, basic_database): - from activity_browser.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog + from activity_browser.app.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog dup_db = "db_that_is_duplicated" @@ -29,9 +29,9 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): assert dup_db not in bd.databases - actions.DatabaseDuplicate.run(basic_database.name) + app.actions.DatabaseDuplicate.run(basic_database.name) - dialog = application.main_window.findChild(DuplicateDatabaseDialog) + dialog = app.main_window.findChild(DuplicateDatabaseDialog) with qtbot.waitSignal(dialog.dup_thread.finished, timeout=60 * 1000): pass @@ -41,7 +41,7 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to Excel format.""" - from activity_browser.actions.database.database_export_excel import ExportExcelSetup + from activity_browser.app.actions.database.database_export_excel import ExportExcelSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.xlsx") @@ -52,10 +52,10 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): ) # Call the action - actions.DatabaseExportExcel.run([basic_database.name]) + app.actions.DatabaseExportExcel.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = application.main_window.findChild(ExportExcelSetup) + wizard = app.main_window.findChild(ExportExcelSetup) assert wizard is not None # Wait for the export thread to finish @@ -69,7 +69,7 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to BW2Package format.""" - from activity_browser.actions.database.database_export_bw2package import ExportBW2PackageSetup + from activity_browser.app.actions.database.database_export_bw2package import ExportBW2PackageSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.bw2package") @@ -80,10 +80,10 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path ) # Call the action - actions.DatabaseExportBW2Package.run([basic_database.name]) + app.actions.DatabaseExportBW2Package.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = application.main_window.findChild(ExportBW2PackageSetup) + wizard = app.main_window.findChild(ExportBW2PackageSetup) assert wizard is not None # Wait for the export thread to finish @@ -96,7 +96,7 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path def test_database_new(monkeypatch, basic_database): - from activity_browser.actions.database.database_new import NewDatabaseDialog + from activity_browser.app.actions.database.database_new import NewDatabaseDialog new_db = "db_that_is_new" @@ -112,20 +112,20 @@ def test_database_new(monkeypatch, basic_database): assert new_db not in bd.databases - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert new_db in bd.databases db_number = len(bd.databases) - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert db_number == len(bd.databases) def test_database_delete_multiple(monkeypatch, basic_database): """Test that multiple databases can be deleted at once.""" - from activity_browser.actions.database.database_new import NewDatabaseDialog + from activity_browser.app.actions.database.database_new import NewDatabaseDialog # Create two additional databases db2 = "test_db_2" @@ -140,7 +140,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): monkeypatch.setattr( QtWidgets.QMessageBox, "information", staticmethod(lambda *args, **kwargs: True) ) - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert db2 in bd.databases assert db3 in bd.databases @@ -153,7 +153,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): ) # Delete both databases at once - actions.DatabaseDelete.run([db2, db3]) + app.actions.DatabaseDelete.run([db2, db3]) assert db2 not in bd.databases assert db3 not in bd.databases @@ -180,7 +180,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): # assert from_db in Database(db).find_dependents() # assert to_db not in Database(db).find_dependents() # -# actions.DatabaseRelink.run(db) +# app.actions.DatabaseRelink.run(db) # # assert db in databases # assert from_db in databases diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index 632fff067..341b0ae17 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -1,8 +1,8 @@ import pytest -from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty +from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty, UniformUncertainty -from activity_browser import actions, application -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser import app +from activity_browser.ui.dialogs import UncertaintyDialog # def test_exchange_copy_sdf(basic_database): @@ -26,7 +26,7 @@ # assert len(exchange) == 1 # assert clipboard.text() == "FAILED" # -# actions.ExchangeCopySDF.run(exchange) +# app.actions.ExchangeCopySDF.run(exchange) # # assert clipboard.text() != "FAILED" # @@ -46,7 +46,7 @@ def test_exchange_delete(basic_database): assert len(exchange) == 1 num_exchanges = len(process.exchanges()) - actions.ExchangeDelete.run(exchange) + app.actions.ExchangeDelete.run(exchange) assert len(process.exchanges()) == num_exchanges - 1 @@ -64,7 +64,7 @@ def test_exchange_formula_remove(basic_database): assert len(exchange) == 1 assert exchange[0].as_dict().get("formula") == "5+5" - actions.ExchangeFormulaRemove.run(exchange) + app.actions.ExchangeFormulaRemove.run(exchange) with pytest.raises(KeyError): assert exchange[0].as_dict()["formula"] @@ -85,7 +85,7 @@ def test_exchange_modify(basic_database): assert len(exchange) == 1 assert exchange[0].amount == 10.0 - actions.ExchangeModify.run(exchange[0], new_data) + app.actions.ExchangeModify.run(exchange[0], new_data) assert exchange[0].amount == 200.0 @@ -102,7 +102,7 @@ def test_exchange_new(basic_database): if exchange.input == other ] - actions.ExchangeNew.run([other.key], process.key, "technosphere") + app.actions.ExchangeNew.run([other.key], process.key, "technosphere") assert ( len( @@ -116,7 +116,7 @@ def test_exchange_new(basic_database): ) -def test_exchange_uncertainty_modify(basic_database): +def test_exchange_uncertainty_modify(monkeypatch, basic_database): process = basic_database.get("process") elementary = basic_database.get("elementary") @@ -126,14 +126,35 @@ def test_exchange_uncertainty_modify(basic_database): if exchange.input == elementary ] assert len(exchange) == 1 + + # Initial state: should have NoUncertainty + assert exchange[0].uncertainty_type == NoUncertainty + + # Create mock uncertainty data to be returned by the dialog + mock_uncertainty = { + "uncertainty type": UniformUncertainty.id, + "loc": float("nan"), + "scale": float("nan"), + "shape": float("nan"), + "minimum": 5.0, + "maximum": 15.0, + "negative": False, + } + + # Monkeypatch the dialog to return our mock data + monkeypatch.setattr( + UncertaintyDialog, + "get_uncertainty_dict", + lambda *args, **kwargs: (True, mock_uncertainty), + ) - actions.ExchangeUncertaintyModify.run(exchange) - - wizard = application.main_window.findChild(UncertaintyWizard) - - assert wizard.isVisible() + app.actions.ExchangeUncertaintyModify.run(exchange) - wizard.destroy() + # Verify the exchange was updated with the new uncertainty values + assert exchange[0].uncertainty_type == UniformUncertainty + assert exchange[0]["minimum"] == 5.0 + assert exchange[0]["maximum"] == 15.0 + assert exchange[0]["negative"] == False def test_exchange_uncertainty_remove(basic_database): @@ -149,6 +170,6 @@ def test_exchange_uncertainty_remove(basic_database): assert exchange[0].uncertainty_type == NoUncertainty - actions.ExchangeUncertaintyRemove.run(exchange) + app.actions.ExchangeUncertaintyRemove.run(exchange) assert exchange[0].uncertainty_type == UndefinedUncertainty diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index 284d94b93..81e7921f7 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -5,11 +5,9 @@ from stats_arrays.distributions import ( NoUncertainty, UndefinedUncertainty, - UniformUncertainty, ) -from activity_browser import actions -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser import app def test_cf_amount_modify(basic_database): @@ -21,7 +19,7 @@ def test_cf_amount_modify(basic_database): assert len(cf) == 1 assert cf[0][1] == 1.0 or cf[0][1]["amount"] == 1.0 - actions.CFAmountModify.run(method, elementary.id, 200) + app.actions.CFAmountModify.run(method, elementary.id, 200) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert cf[0][1] == 200.0 or cf[0][1]["amount"] == 200.0 @@ -36,7 +34,7 @@ def test_cf_new(basic_database): cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] assert len(cf) == 0 - actions.CFNew.run(method, [new_elementary.key]) + app.actions.CFNew.run(method, [new_elementary.key]) cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] @@ -57,7 +55,7 @@ def test_cf_remove(monkeypatch, basic_database): assert len(cf) == 1 - actions.CFRemove.run(method, cf) + app.actions.CFRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert len(cf) == 0 @@ -84,14 +82,14 @@ def test_cf_remove(monkeypatch, basic_database): # assert len(cf) == 1 # assert cf[0][1].get("uncertainty type") == NoUncertainty.id # -# actions.CFUncertaintyModify.run(method, cf) +# app.actions.CFUncertaintyModify.run(method, cf) # # wizard = application_instance.main_window.findChild(UncertaintyWizard) # # assert wizard.isVisible() # # wizard.destroy() -# actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) +# app.actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) # # cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] # @@ -107,7 +105,7 @@ def test_cf_uncertainty_remove(basic_database): assert cf[0][1].get("uncertainty type") == NoUncertainty.id - actions.CFUncertaintyRemove.run(method, cf) + app.actions.CFUncertaintyRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert ( @@ -126,13 +124,13 @@ def test_method_delete(monkeypatch, basic_database): assert method in methods - actions.MethodDelete.run([method]) + app.actions.MethodDelete.run([method]) assert method not in methods def test_method_duplicate(monkeypatch, basic_database): - from activity_browser.actions.method.method_duplicate import TupleNameDialog + from activity_browser.app.actions.method.method_duplicate import TupleNameDialog method = ("basic_method",) duplicated_method = ("basic_method - Copy",) @@ -148,14 +146,14 @@ def test_method_duplicate(monkeypatch, basic_database): assert method in methods assert duplicated_method not in methods - actions.MethodDuplicate.run([method], "leaf") + app.actions.MethodDuplicate.run([method], "leaf") assert method in methods assert duplicated_method in methods def test_method_new(monkeypatch, basic_database): - from activity_browser.ui.widgets import ABListEditDialog + from activity_browser.ui.dialogs import ABListEditDialog new_method = ("New Test Method", "Test Category") @@ -174,7 +172,7 @@ def test_method_new(monkeypatch, basic_database): assert new_method not in methods - actions.MethodNew.run() + app.actions.MethodNew.run() assert new_method in methods @@ -204,7 +202,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - actions.MethodDelete.run([method]) + app.actions.MethodDelete.run([method]) # method removed assert method not in bw_methods @@ -215,7 +213,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database): # prepare rename dialog to accept and return new name - from activity_browser.ui.widgets import ABListEditDialog + from activity_browser.ui.dialogs import ABListEditDialog import bw2data as bd old = ("basic_method",) @@ -238,7 +236,7 @@ def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: new), ) - actions.MethodRename.run(old) + app.actions.MethodRename.run(old) # setups reference the new method name cs = bd.calculation_setups["basic_calculation_setup"] diff --git a/tests/conftest.py b/tests/conftest.py index f2e36a1e0..7939d4477 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,19 @@ 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 -from activity_browser import application -from activity_browser.ui.widgets import MainWindow, CentralTabWidget -from activity_browser.layouts import pages +os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" +os.environ["AB_NO_SEARCHER"] = "1" @pytest.fixture @@ -21,32 +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.""" - main_window = MainWindow() - central_widget = CentralTabWidget(main_window) + from activity_browser import app + from activity_browser.bwutils.metadata import metadata - qtbot.addWidget(main_window) - setattr(application, "main_window", main_window) + # Reload modules to ensure a clean state for each test + reload(metadata) + reload(app.main) + reload(app) + metadata.dataframe = pd.DataFrame() - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") - - 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 @@ -59,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"]