Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix IDAKLU solver crash when running in multiple threads + when telemetry is prompted for #4583

Open
wants to merge 21 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- 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 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), [#4583](https://github.com/pybamm-team/PyBaMM/pull/4583))
- 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))
Expand Down
1 change: 1 addition & 0 deletions docs/source/user_guide/installation/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Package Minimum supp
`pandas <https://pypi.org/project/pandas/>`__ 1.5.0
`pooch <https://www.fatiando.org/pooch/>`__ 1.8.1
`posthog <https://posthog.com/>`__ 3.6.5
`platformdirs <https://pypi.org/project/platformdirs/>`__ 4.0.0
=================================================================== ==========================

.. _install.optional_dependencies:
Expand Down
6 changes: 4 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,11 @@ def run_tests(session):
set_environment_variables(PYBAMM_ENV, session=session)
session.install("setuptools", silent=False)
session.install("-e", ".[all,dev,jax]", silent=False)
specific_test_files = session.posargs if session.posargs else []
session.run(
"python", "-m", "pytest", *specific_test_files, "-m", "unit or integration"
"python",
"-m",
"pytest",
*(session.posargs if session.posargs else ["-m", "unit or integration"]),
)


Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies = [
"pandas>=1.5.0",
"pooch>=1.8.1",
"posthog",
"platformdirs",
]

[project.urls]
Expand Down
4 changes: 2 additions & 2 deletions src/pybamm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
demote_expressions_to_32bit = False

# Utility classes and methods
from .util import root_dir
from .util import Timer, TimerTime, FuzzyDict
from .util import (
root_dir,
is_notebook,
load,
is_constant_and_can_evaluate,
)
Expand Down Expand Up @@ -190,7 +190,7 @@
from .plotting.dynamic_plot import dynamic_plot

# Simulation
from .simulation import Simulation, load_sim, is_notebook
from .simulation import Simulation, load_sim

# Batch Study
from .batch_study import BatchStudy
Expand Down
178 changes: 130 additions & 48 deletions src/pybamm/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import uuid
import os
import platformdirs
from pathlib import Path
import pybamm
import sys
import threading
import time
import uuid

from pathlib import Path

import pybamm
import platformdirs

from pybamm.util import is_notebook


def is_running_tests(): # pragma: no cover
Expand Down Expand Up @@ -45,20 +48,118 @@ def is_running_tests(): # pragma: no cover
return False


def ask_user_opt_in(timeout=10):
def get_input_or_timeout(timeout): # pragma: no cover
"""
Ask the user if they want to opt in to telemetry.
Cross-platform input with timeout, using various methods depending on the
environment. Works in Jupyter notebooks, Windows, and Unix-like systems.

Args:
timeout (float): Timeout in seconds

Returns:
str | None: The user input if received before the timeout, or None if:
- The timeout was reached
- Telemetry is disabled via environment variable
- Running in a non-interactive environment
- An error occurred

The caller can distinguish between an empty response (user just pressed Enter)
which returns '', and a timeout/error which returns None.
"""
# Check for telemetry disable flag
if os.getenv("PYBAMM_DISABLE_TELEMETRY", "false").lower() != "false":
return None

if not (sys.stdin.isatty() or is_notebook()):
return None

# 1. Handling for Jupyter notebooks. This is a simplified
# implementation in comparison to widgets because notebooks
# are usually run interactively, and we don't need to worry
# about the event loop. The input is disabled when running
# tests due to the environment variable set.
if is_notebook(): # pragma: no cover
try:
from IPython.display import clear_output

user_input = input("Do you want to enable telemetry? (Y/n): ")
clear_output()

return user_input

Parameters
----------
timeout : float, optional
The timeout for the user to respond to the prompt. Default is 10 seconds.
except Exception: # pragma: no cover
return None

Returns
-------
bool
True if the user opts in, False otherwise.
# 2. Windows-specific handling
if sys.platform == "win32":
try:
import msvcrt

start_time = time.time()
input_chars = []
sys.stdout.write("Do you want to enable telemetry? (Y/n): ")
sys.stdout.flush()

while time.time() - start_time < timeout:
if msvcrt.kbhit():
char = msvcrt.getwche()
if char in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(input_chars)
input_chars.append(char)
time.sleep(0.1)
return None
except Exception:
return None

# 3. POSIX-like systems will need to use termios
else: # pragma: no cover
try:
import termios
import tty
import select

# Save terminal settings for later
old_settings = termios.tcgetattr(sys.stdin)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems excessive that we need to change the terminal settings for this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What this allows you to do is that you don't get new indentation when you write to the terminal again. I wrapped this in a try-finally block based on the Python docs: https://docs.python.org/3/library/termios.html#example.

So, by saving the attributes, I'm trying to disable the fact that sys.stdout.write("\n") will not return the cursor to the leftmost position (because we are in raw mode and we need to do that manually instead – for which I want to avoid).

We end up with this, otherwise:

❯ python -c "import pybamm; print(pybamm.IDAKLUSolver())"
PyBaMM can collect usage data and send it to the PyBaMM team to help us improve the software.
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.
This is entirely optional and does not impact the functionality of PyBaMM.
For more information, see https://docs.pybamm.org/en/latest/source/user_guide/index.html#telemetry
Do you want to enable telemetry? (Y/n): n

                                         Telemetry disabled.
                                                            <pybamm.solvers.idaklu_solver.IDAKLUSolver object at 0x102de3ad0>
                                                                                                                             %

and this would break one's terminal until one restarts it – a more robust solution could be to use TCSAFLUSH instead of TCSADRAIN, I'll switch to that.

try:
# Set terminal to raw mode
tty.setraw(sys.stdin.fileno())

sys.stdout.write("Do you want to enable telemetry? (Y/n): ")
sys.stdout.flush()

input_chars = []
start_time = time.time()

while time.time() - start_time < timeout:
rlist, _, _ = select.select([sys.stdin], [], [], 0.1)
Comment on lines +134 to +135
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are already using select, do we need the loop? The timeout isn't very long

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I checked and this loop ended up being required – since while normal input() in Python blocks indefinitely, I found that there's no (built-in) way to timeout if we are in raw terminal mode (set above in tty.setraw()). So we need to manually handle everything, which is to read the "y" or "n" or empty characters and flush them to the screen.

select lets us say "wake me up when, either":

  • there's input to read, or
  • one-tenth of a second has passed

which the loop apparently allows us to do. But if you have something else in mind, I'm happy to try that.

if rlist:
char = sys.stdin.read(1)
if char in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(input_chars)
input_chars.append(char)
sys.stdout.write(char)
sys.stdout.flush()
return None

finally:
# Restore saved terminal settings
termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, old_settings)
sys.stdout.write("\n")
sys.stdout.flush()

except Exception: # pragma: no cover
return None

return None


def ask_user_opt_in(timeout=10): # pragma: no cover
"""
Ask the user if they want to opt in to telemetry.
"""

print(
"PyBaMM can collect usage data and send it to the PyBaMM team to "
"help us improve the software.\n"
Expand All @@ -69,44 +170,25 @@ def ask_user_opt_in(timeout=10):
"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
user_input = get_input_or_timeout(timeout)

time_start = time.time()
if user_input is None:
print("\nTimeout reached. Defaulting to not enabling telemetry.")
return False

while True:
if time.time() - time_start > timeout:
print("\nTimeout reached. Defaulting to not enabling telemetry.")
if user_input.lower() in ["y", "yes", ""]:
print("Telemetry enabled.\n")
return True
elif user_input.lower() in ["n", "no"]:
print("Telemetry disabled.")
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
print("Invalid input. Please enter 'Y/y' for yes or 'n/N' for no:")
user_input = get_input_or_timeout(timeout)
if user_input is None:
print("\nTimeout reached. Defaulting to not enabling telemetry.")
return False


def generate():
Expand Down
20 changes: 1 addition & 19 deletions src/pybamm/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,6 @@
from pybamm.expression_tree.operations.serialise import Serialise


def is_notebook():
try:
shell = get_ipython().__class__.__name__
if shell == "ZMQInteractiveShell": # pragma: no cover
# Jupyter notebook or qtconsole
cfg = get_ipython().config
nb = len(cfg["InteractiveShell"].keys()) == 0
return nb
elif shell == "TerminalInteractiveShell": # pragma: no cover
return False # Terminal running IPython
elif shell == "Shell": # pragma: no cover
return True # Google Colab notebook
else: # pragma: no cover
return False # Other type (?)
except NameError:
return False # Probably standard Python interpreter


class Simulation:
"""A Simulation class for easy building and running of PyBaMM simulations.

Expand Down Expand Up @@ -141,7 +123,7 @@ def __init__(
self._set_random_seed()

# ignore runtime warnings in notebooks
if is_notebook(): # pragma: no cover
if pybamm.is_notebook(): # pragma: no cover
import warnings

warnings.filterwarnings("ignore")
Expand Down
18 changes: 18 additions & 0 deletions src/pybamm/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ def root_dir():
return str(pathlib.Path(pybamm.__path__[0]).parent.parent)


def is_notebook():
try:
shell = get_ipython().__class__.__name__
if shell == "ZMQInteractiveShell": # pragma: no cover
# Jupyter notebook or qtconsole
cfg = get_ipython().config
nb = len(cfg["InteractiveShell"].keys()) == 0
return nb
elif shell == "TerminalInteractiveShell": # pragma: no cover
return False # Terminal running IPython
elif shell == "Shell": # pragma: no cover
return True # Google Colab notebook
else: # pragma: no cover
return False # Other type (?)
except NameError:
return False # Probably standard Python interpreter


def get_git_commit_info():
"""
Get the git commit info for the current PyBaMM version, e.g. v22.8-39-gb25ce8c41
Expand Down
Loading