diff --git a/.gitignore b/.gitignore index a3bd3b502c..8632f96d30 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,6 @@ input/* # simulation outputs out/ -config.py matplotlibrc *.pickle *.sav diff --git a/.lycheeignore b/.lycheeignore index 55a4a4c623..929fe36475 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -15,3 +15,6 @@ file:///home/runner/work/PyBaMM/PyBaMM/docs/source/user_guide/fundamentals/pybam # Errors in docs/source/user_guide/index.md file:///home/runner/work/PyBaMM/PyBaMM/docs/source/user_guide/api_docs + +# Telemetry +https://us.i.posthog.com diff --git a/CHANGELOG.md b/CHANGELOG.md index 9167391c10..bf376c1a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,11 @@ - Adds an option "voltage as a state" that can be "false" (default) or "true". If "true" adds an explicit algebraic equation for the voltage. ([#4507](https://github.com/pybamm-team/PyBaMM/pull/4507)) - Improved `QuickPlot` accuracy for simulations with Hermite interpolation. ([#4483](https://github.com/pybamm-team/PyBaMM/pull/4483)) - Added Hermite interpolation to the (`IDAKLUSolver`) that improves the accuracy and performance of post-processing variables. ([#4464](https://github.com/pybamm-team/PyBaMM/pull/4464)) +- Added basic telemetry to record which functions are being run. See [Telemetry section in the User Guide](https://docs.pybamm.org/en/latest/source/user_guide/index.html#telemetry) for more information. ([#4441](https://github.com/pybamm-team/PyBaMM/pull/4441)) - Added `BasicDFN` model for sodium-ion batteries ([#4451](https://github.com/pybamm-team/PyBaMM/pull/4451)) - Added sensitivity calculation support for `pybamm.Simulation` and `pybamm.Experiment` ([#4415](https://github.com/pybamm-team/PyBaMM/pull/4415)) - Added OpenMP parallelization to IDAKLU solver for lists of input parameters ([#4449](https://github.com/pybamm-team/PyBaMM/pull/4449)) -- Added phase-dependent particle options to LAM - ([#4369](https://github.com/pybamm-team/PyBaMM/pull/4369)) +- Added phase-dependent particle options to LAM ([#4369](https://github.com/pybamm-team/PyBaMM/pull/4369)) - Added a lithium ion equivalent circuit model with split open circuit voltages for each electrode (`SplitOCVR`). ([#4330](https://github.com/pybamm-team/PyBaMM/pull/4330)) - Added the `pybamm.DiscreteTimeSum` expression node to sum an expression over a sequence of data times, and accompanying `pybamm.DiscreteTimeData` class to store the data times and values ([#4501](https://github.com/pybamm-team/PyBaMM/pull/4501)) diff --git a/conftest.py b/conftest.py index 7ac6cf3c74..77513d56db 100644 --- a/conftest.py +++ b/conftest.py @@ -51,3 +51,8 @@ def set_random_seed(): @pytest.fixture(autouse=True) def set_debug_value(): pybamm.settings.debug_mode = True + + +@pytest.fixture(autouse=True) +def disable_telemetry(): + pybamm.telemetry.disable() diff --git a/docs/source/user_guide/index.md b/docs/source/user_guide/index.md index 79e61936b2..b497ed1a01 100644 --- a/docs/source/user_guide/index.md +++ b/docs/source/user_guide/index.md @@ -73,3 +73,12 @@ glob: ../examples/notebooks/creating_models/5-half-cell-model.ipynb ../examples/notebooks/creating_models/6-a-simple-SEI-model.ipynb ``` + +# Telemetry + +PyBaMM optionally collects anonymous usage data to help improve the library. This telemetry is opt-in and can be easily disabled. Here's what you need to know: + +- **What is collected**: Basic usage information like PyBaMM version, Python version, and which functions are run. +- **Why**: To understand how PyBaMM is used and prioritize development efforts. +- **Opt-out**: To disable telemetry, set the environment variable `PYBAMM_DISABLE_TELEMETRY=true` (or any value other than `false`) or use `pybamm.telemetry.disable()` in your code. +- **Privacy**: No personal information (name, email, etc) or sensitive information (parameter values, simulation results, etc) is ever collected. diff --git a/docs/source/user_guide/installation/index.rst b/docs/source/user_guide/installation/index.rst index 18e62c5dfa..9225f1ee98 100644 --- a/docs/source/user_guide/installation/index.rst +++ b/docs/source/user_guide/installation/index.rst @@ -71,6 +71,7 @@ Package Minimum supp `typing-extensions `__ 4.10.0 `pandas `__ 1.5.0 `pooch `__ 1.8.1 +`posthog `__ 3.6.5 =================================================================== ========================== .. _install.optional_dependencies: diff --git a/noxfile.py b/noxfile.py index 5ab32f463f..d65812b8ed 100644 --- a/noxfile.py +++ b/noxfile.py @@ -49,6 +49,7 @@ def set_iree_state(): "IREE_INDEX_URL": os.getenv( "IREE_INDEX_URL", "https://iree.dev/pip-release-links.html" ), + "PYBAMM_DISABLE_TELEMETRY": "true", } VENV_DIR = Path("./venv").resolve() diff --git a/pyproject.toml b/pyproject.toml index 1db7c927a3..be07105f75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "typing-extensions>=4.10.0", "pandas>=1.5.0", "pooch>=1.8.1", + "posthog", ] [project.urls] diff --git a/src/pybamm/__init__.py b/src/pybamm/__init__.py index 3de52e5724..bdd91aefce 100644 --- a/src/pybamm/__init__.py +++ b/src/pybamm/__init__.py @@ -194,8 +194,8 @@ # Batch Study from .batch_study import BatchStudy -# Callbacks -from . import callbacks +# Callbacks, telemetry, config +from . import callbacks, telemetry, config # Pybamm Data manager using pooch from .pybamm_data import DataLoader @@ -204,12 +204,14 @@ import os import pathlib import sysconfig -os.environ["CASADIPATH"] = str(pathlib.Path(sysconfig.get_path('purelib')) / 'casadi') + +os.environ["CASADIPATH"] = str(pathlib.Path(sysconfig.get_path("purelib")) / "casadi") __all__ = [ "batch_study", "callbacks", "citations", + "config", "discretisations", "doc_utils", "experiment", @@ -225,8 +227,11 @@ "simulation", "solvers", "spatial_methods", + "telemetry", "type_definitions", "util", "version", "pybamm_data", ] + +pybamm.config.generate() diff --git a/src/pybamm/config.py b/src/pybamm/config.py new file mode 100644 index 0000000000..ba7171e5d2 --- /dev/null +++ b/src/pybamm/config.py @@ -0,0 +1,163 @@ +import uuid +import os +import platformdirs +from pathlib import Path +import pybamm +import sys +import threading +import time + + +def is_running_tests(): # pragma: no cover + """ + Detect if the code is being run as part of a test suite or building docs with Sphinx. + + Returns: + bool: True if running tests or building docs, False otherwise. + """ + # Check if pytest or unittest is running + if any( + test_module in sys.modules for test_module in ["pytest", "unittest", "nose"] + ): + return True + + # Check for GitHub Actions environment variable + if "GITHUB_ACTIONS" in os.environ: + return True + + # Check for other common CI environment variables + ci_env_vars = ["CI", "TRAVIS", "CIRCLECI", "JENKINS_URL", "GITLAB_CI"] + if any(var in os.environ for var in ci_env_vars): + return True + + # Check for common test runner names in command-line arguments + test_runners = ["pytest", "unittest", "nose", "trial", "nox", "tox"] + if any(runner in arg.lower() for arg in sys.argv for runner in test_runners): + return True + + # Check if building docs with Sphinx + if any(mod == "sphinx" or mod.startswith("sphinx.") for mod in sys.modules): + print( + f"Found Sphinx module: {[mod for mod in sys.modules if mod.startswith('sphinx')]}" + ) + return True + + return False + + +def ask_user_opt_in(timeout=10): + """ + Ask the user if they want to opt in to telemetry. + + Parameters + ---------- + timeout : float, optional + The timeout for the user to respond to the prompt. Default is 10 seconds. + + Returns + ------- + bool + True if the user opts in, False otherwise. + """ + print( + "PyBaMM can collect usage data and send it to the PyBaMM team to " + "help us improve the software.\n" + "We do not collect any sensitive information such as models, parameters, " + "or simulation results - only information on which parts of the code are " + "being used and how frequently.\n" + "This is entirely optional and does not impact the functionality of PyBaMM.\n" + "For more information, see https://docs.pybamm.org/en/latest/source/user_guide/index.html#telemetry" + ) + + def get_input(): # pragma: no cover + try: + user_input = ( + input("Do you want to enable telemetry? (Y/n): ").strip().lower() + ) + answer.append(user_input) + except Exception: + # Handle any input errors + pass + + time_start = time.time() + + while True: + if time.time() - time_start > timeout: + print("\nTimeout reached. Defaulting to not enabling telemetry.") + return False + + answer = [] + # Create and start input thread + input_thread = threading.Thread(target=get_input) + input_thread.daemon = True + input_thread.start() + + # Wait for either timeout or input + input_thread.join(timeout) + + if answer: + if answer[0] in ["yes", "y", ""]: + print("\nTelemetry enabled.\n") + return True + elif answer[0] in ["no", "n"]: + print("\nTelemetry disabled.\n") + return False + else: + print("\nInvalid input. Please enter 'yes/y' for yes or 'no/n' for no.") + else: + print("\nTimeout reached. Defaulting to not enabling telemetry.") + return False + + +def generate(): + if is_running_tests(): + return + + # Check if the config file already exists + if read() is not None: + return + + # Ask the user if they want to opt in to telemetry + opt_in = ask_user_opt_in() + config_file = Path(platformdirs.user_config_dir("pybamm")) / "config.yml" + write_uuid_to_file(config_file, opt_in) + + if opt_in: + pybamm.telemetry.capture("user-opted-in") + + +def read(): + config_file = Path(platformdirs.user_config_dir("pybamm")) / "config.yml" + return read_uuid_from_file(config_file) + + +def write_uuid_to_file(config_file, opt_in): + # Create the directory if it doesn't exist + config_file.parent.mkdir(parents=True, exist_ok=True) + + # Write the UUID to the config file in YAML format + with open(config_file, "w") as f: + f.write("pybamm:\n") + f.write(f" enable_telemetry: {opt_in}\n") + if opt_in: + unique_id = uuid.uuid4() + f.write(f" uuid: {unique_id}\n") + + +def read_uuid_from_file(config_file): + # Check if the config file exists + if not config_file.exists(): + return None + + # Read the UUID from the config file + with open(config_file) as f: + content = f.read().strip() + + # Extract the UUID using YAML parsing + try: + import yaml + + config = yaml.safe_load(content) + return config["pybamm"] + except (yaml.YAMLError, ValueError): + return None diff --git a/src/pybamm/simulation.py b/src/pybamm/simulation.py index ab22972741..75799d6334 100644 --- a/src/pybamm/simulation.py +++ b/src/pybamm/simulation.py @@ -10,6 +10,7 @@ import warnings from functools import lru_cache from datetime import timedelta +import pybamm.telemetry from pybamm.util import import_optional_dependency from pybamm.expression_tree.operations.serialise import Serialise @@ -450,6 +451,8 @@ def solve( Additional key-word arguments passed to `solver.solve`. See :meth:`pybamm.BaseSolver.solve`. """ + pybamm.telemetry.capture("simulation-solved") + # Setup if solver is None: solver = self._solver diff --git a/src/pybamm/telemetry.py b/src/pybamm/telemetry.py new file mode 100644 index 0000000000..af91deca9d --- /dev/null +++ b/src/pybamm/telemetry.py @@ -0,0 +1,36 @@ +from posthog import Posthog +import os +import pybamm +import sys + +_posthog = Posthog( + # this is the public, write only API key, so it's ok to include it here + project_api_key="phc_bLZKBW03XjgiRhbWnPsnKPr0iw0z03fA6ZZYjxgW7ej", + host="https://us.i.posthog.com", +) + + +def disable(): + _posthog.disabled = True + + +_opt_out = os.getenv("PYBAMM_DISABLE_TELEMETRY", "false").lower() +if _opt_out != "false": # pragma: no cover + disable() + + +def capture(event): # pragma: no cover + # don't capture events in automated testing + if pybamm.config.is_running_tests() or _posthog.disabled: + return + + properties = { + "python_version": sys.version, + "pybamm_version": pybamm.__version__, + } + + config = pybamm.config.read() + if config: + if config["enable_telemetry"]: + user_id = config["uuid"] + _posthog.capture(user_id, event, properties=properties) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000000..62906b348d --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,157 @@ +import pytest +import select +import sys + +import pybamm +import uuid +from pathlib import Path +import platformdirs + + +class TestConfig: + @pytest.mark.parametrize("write_opt_in", [True, False]) + def test_write_read_uuid(self, tmp_path, write_opt_in): + # Create a temporary file path + config_file = tmp_path / "config.yml" + + # Call the function to write UUID to file + pybamm.config.write_uuid_to_file(config_file, write_opt_in) + + # Check that the file was created + assert config_file.exists() + + # Read the UUID using the read_uuid_from_file function + config_dict = pybamm.config.read_uuid_from_file(config_file) + # Check that the UUID was read successfully + if write_opt_in: + assert config_dict["enable_telemetry"] is True + assert "uuid" in config_dict + + # Verify that the UUID is valid + try: + uuid.UUID(config_dict["uuid"]) + except ValueError: + pytest.fail("Invalid UUID format") + else: + assert config_dict["enable_telemetry"] is False + + @pytest.mark.parametrize("user_opted_in, user_input", [(True, "y"), (False, "n")]) + def test_ask_user_opt_in(self, monkeypatch, capsys, user_opted_in, user_input): + # Mock select.select to simulate user input + def mock_select(*args, **kwargs): + return [sys.stdin], [], [] + + monkeypatch.setattr(select, "select", mock_select) + + # Mock sys.stdin.readline to return the desired input + monkeypatch.setattr(sys.stdin, "readline", lambda: user_input + "\n") + + # Call the function to ask the user if they want to opt in + opt_in = pybamm.config.ask_user_opt_in() + + # Check the result + assert opt_in is user_opted_in + + # Check that the prompt was printed + captured = capsys.readouterr() + assert "Do you want to enable telemetry? (Y/n):" in captured.out + + def test_ask_user_opt_in_invalid_input(self, monkeypatch, capsys): + # Mock select.select to simulate user input and then timeout + def mock_select(*args, **kwargs): + nonlocal call_count + if call_count == 0: + call_count += 1 + return [sys.stdin], [], [] + else: + return [], [], [] + + monkeypatch.setattr(select, "select", mock_select) + + # Mock sys.stdin.readline to return invalid input + monkeypatch.setattr(sys.stdin, "readline", lambda: "invalid\n") + + # Initialize call count + call_count = 0 + + # Call the function to ask the user if they want to opt in + opt_in = pybamm.config.ask_user_opt_in(timeout=1) + + # Check the result (should be False for timeout after invalid input) + assert opt_in is False + + # Check that the prompt, invalid input message, and timeout message were printed + captured = capsys.readouterr() + assert "Do you want to enable telemetry? (Y/n):" in captured.out + assert ( + "Invalid input. Please enter 'yes/y' for yes or 'no/n' for no." + in captured.out + ) + assert "Timeout reached. Defaulting to not enabling telemetry." in captured.out + + def test_ask_user_opt_in_timeout(self, monkeypatch, capsys): + # Mock select.select to simulate a timeout + def mock_select(*args, **kwargs): + return [], [], [] + + monkeypatch.setattr(select, "select", mock_select) + + # Call the function to ask the user if they want to opt in + opt_in = pybamm.config.ask_user_opt_in(timeout=1) + + # Check the result (should be False for timeout) + assert opt_in is False + + # Check that the prompt and timeout message were printed + captured = capsys.readouterr() + assert "Do you want to enable telemetry? (Y/n):" in captured.out + assert "Timeout reached. Defaulting to not enabling telemetry." in captured.out + + def test_generate_and_read(self, monkeypatch, tmp_path): + # Mock is_running_tests to return False + monkeypatch.setattr(pybamm.config, "is_running_tests", lambda: False) + + # Mock ask_user_opt_in to return True + monkeypatch.setattr(pybamm.config, "ask_user_opt_in", lambda: True) + + # Mock telemetry capture + capture_called = False + + def mock_capture(event): + nonlocal capture_called + assert event == "user-opted-in" + capture_called = True + + monkeypatch.setattr(pybamm.telemetry, "capture", mock_capture) + + # Mock config directory + monkeypatch.setattr(platformdirs, "user_config_dir", lambda x: str(tmp_path)) + + # Test generate() creates new config + pybamm.config.generate() + + # Verify config was created + config = pybamm.config.read() + assert config is not None + assert config["enable_telemetry"] is True + assert "uuid" in config + assert capture_called is True + + # Test generate() does nothing if config exists + capture_called = False + pybamm.config.generate() + assert capture_called is False + + def test_read_uuid_from_file_no_file(self): + config_dict = pybamm.config.read_uuid_from_file(Path("nonexistent_file.yml")) + assert config_dict is None + + def test_read_uuid_from_file_invalid_yaml(self, tmp_path): + # Create a temporary directory and file with invalid YAML content + invalid_yaml = tmp_path / "invalid_yaml.yml" + with open(invalid_yaml, "w") as f: + f.write("invalid: yaml: content:") + + config_dict = pybamm.config.read_uuid_from_file(invalid_yaml) + + assert config_dict is None