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