├── MANIFEST.in ├── .flake8 ├── examples ├── envs │ ├── __init__.py │ ├── assets │ │ ├── ballonstring.xml │ │ ├── arm.xml │ │ └── hopper.xml │ ├── muscled_hopper.py │ ├── mujoco_env.py │ └── pymunk_arm.py ├── minimal-physio-example.py └── pymunk-gym-example.py ├── pymuscle ├── vis │ ├── __init__.py │ └── potvin_charts.py ├── __version__.py ├── model.py ├── __init__.py ├── hill_type.py ├── pymuscle_fibers.py ├── muscle.py ├── potvin_fuglevand_2017_motor_neuron_pool.py └── potvin_fuglevand_2017_muscle_fibers.py ├── docs ├── src │ ├── images │ │ ├── motor-unit-diagram.png │ │ ├── minimal-example-chart.png │ │ └── minimal-physio-example-chart.png │ └── readme-template.md ├── Makefile ├── index.rst ├── assemble.py └── conf.py ├── tests ├── test_module.py ├── test_model.py ├── benchmarks │ ├── util.py │ └── bench_potvin_fuglevand_muscle.py ├── test_potvin_fuglevand_muscle.py ├── test_potvin_fuglevand_2017_muscle_fibers.py ├── test_pymuscle_fibers.py ├── test_potvin_fuglevand_2017_motor_neuron_pool.py └── test_standard_muscle.py ├── .pyre_configuration ├── .travis.yml ├── requirements-dev.txt ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── enhancement-request.md │ ├── research-submission.md │ └── bug_report.md ├── .gitignore ├── LICENSE ├── setup.py ├── physio-readme.md └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = D203, E125, E121, W503 3 | -------------------------------------------------------------------------------- /examples/envs/__init__.py: -------------------------------------------------------------------------------- 1 | from .pymunk_arm import PymunkArmEnv # noqa -------------------------------------------------------------------------------- /pymuscle/vis/__init__.py: -------------------------------------------------------------------------------- 1 | from .potvin_charts import PotvinChart # noqa: F401 2 | -------------------------------------------------------------------------------- /docs/src/images/motor-unit-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandanforth/pymuscle/HEAD/docs/src/images/motor-unit-diagram.png -------------------------------------------------------------------------------- /docs/src/images/minimal-example-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandanforth/pymuscle/HEAD/docs/src/images/minimal-example-chart.png -------------------------------------------------------------------------------- /pymuscle/__version__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyMuscle Library 3 | """ 4 | VERSION = (0, 1, 2) 5 | 6 | __version__ = '.'.join(map(str, VERSION)) 7 | -------------------------------------------------------------------------------- /docs/src/images/minimal-physio-example-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandanforth/pymuscle/HEAD/docs/src/images/minimal-physio-example-chart.png -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | import re 2 | import pymuscle 3 | 4 | 5 | def test_version(): 6 | assert re.match(r'\d\.\d\.\d', pymuscle.__version__) 7 | -------------------------------------------------------------------------------- /.pyre_configuration: -------------------------------------------------------------------------------- 1 | { 2 | "binary": "/Users/iandanforth/.pyenv/versions/3.6.4/bin/pyre.bin", 3 | "source_directories": [ 4 | "pymuscle" 5 | ], 6 | "typeshed": "/Users/iandanforth/.pyenv/versions/3.6.4/lib/pyre_check/typeshed/stdlib/" 7 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: required 3 | dist: xenial 4 | python: 5 | - "3.6" 6 | - "3.7" 7 | # command to install dependencies 8 | install: 9 | - pip install . 10 | - pip install -r requirements-dev.txt 11 | # command to run tests 12 | script: 13 | - pytest # or py.test for Python versions 3.5 and below 14 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Why only a requirements-dev.txt? 2 | # https://caremad.io/posts/2013/07/setup-vs-requirement/ 3 | # Standard install: 4 | # `pip install pymuscle` # From PyPi 5 | # `pip install .` # Locally 6 | # `python setup.py install` # Same as above 7 | pytest >= 3.6 8 | pylint 9 | coverage 10 | pytest-cov 11 | sphinx-rtd-theme 12 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pymuscle import Model 3 | 4 | 5 | def test_init(): 6 | with pytest.raises(TypeError): 7 | m = Model() 8 | 9 | motor_unit_count = 120 10 | m = Model(120) 11 | 12 | assert m.motor_unit_count == motor_unit_count 13 | 14 | 15 | def test_step(): 16 | m = Model(100) 17 | 18 | with pytest.raises(TypeError): 19 | m.step() 20 | 21 | with pytest.raises(NotImplementedError): 22 | m.step(20, 1) 23 | -------------------------------------------------------------------------------- /pymuscle/model.py: -------------------------------------------------------------------------------- 1 | from numpy import ndarray 2 | 3 | 4 | class Model(object): 5 | """ 6 | Base model class from which other models should inherit 7 | """ 8 | def __init__( 9 | self, 10 | motor_unit_count: int 11 | ): 12 | self.motor_unit_count = motor_unit_count 13 | 14 | def step(self, inputs: ndarray, step_size: float): 15 | """ 16 | Child classes must implement this method. 17 | """ 18 | raise NotImplementedError 19 | -------------------------------------------------------------------------------- /tests/benchmarks/util.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from time import time 3 | 4 | 5 | def timing(f): 6 | """ 7 | From https://stackoverflow.com/a/27737385/1775741 8 | """ 9 | @wraps(f) 10 | def wrap(*args, **kw): 11 | ts = time() 12 | result = f(*args, **kw) 13 | te = time() 14 | dur = te - ts 15 | print('func:{!r} args:[{!r}, {!r}] took: {:2.4f} sec'.format( 16 | f.__name__, args, kw, dur) 17 | ) 18 | return dur, result 19 | return wrap 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /pymuscle/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import __version__ # noqa: F401 2 | from .model import Model # noqa: F401 3 | from .muscle import Muscle # noqa: F401 4 | from .muscle import PotvinFuglevandMuscle # noqa: F401 5 | from .muscle import StandardMuscle # noqa: F401 6 | from .potvin_fuglevand_2017_muscle_fibers import PotvinFuglevand2017MuscleFibers # noqa: F401 7 | from .potvin_fuglevand_2017_motor_neuron_pool import PotvinFuglevand2017MotorNeuronPool # noqa: F401 8 | from .pymuscle_fibers import PyMuscleFibers # noqa: F401 9 | from .hill_type import ( 10 | contractile_element_force_length_curve, 11 | contractile_element_force_velocity_curve 12 | ) 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/research-submission.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Research Submission 3 | about: Submit a reference to new or existing research. 4 | 5 | --- 6 | 7 | **What aspect of the model do you feel should be changed?** 8 | A clear and concise description of what the issue with the current behavior and how you feel it should be changed. 9 | 10 | **Describe the expected impact on the performance of the library here.** 11 | 12 | **Please link to supporting research papers here.** 13 | (Do not submit sci-hub links, sci-hub is too convenient and useful and is bad for other reasons too) 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the proposal here. 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = PyMuscle 8 | SOURCEDIR = . 9 | BUILDDIR = ../../pymuscle-docs/ 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Python Version [e.g. 3.4] 26 | - PyMuscle Version [e.g. 0.01] 27 | 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. PyMuscle documentation master file, created by 2 | sphinx-quickstart on Fri Jun 29 10:59:38 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to PyMuscle's documentation! 7 | ==================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | .. automodule:: pymuscle 14 | 15 | .. autoclass:: Muscle 16 | :members: 17 | 18 | .. autoclass:: PotvinFuglevandMuscle 19 | :members: 20 | 21 | .. autoclass:: Model 22 | :members: 23 | 24 | .. autoclass:: PotvinFuglevand2017MotorNeuronPool 25 | :members: 26 | 27 | .. autoclass:: PotvinFuglevand2017MuscleFibers 28 | :members: 29 | 30 | .. automodule:: pymuscle.hill_type 31 | :members: 32 | 33 | Indices and tables 34 | ================== 35 | 36 | * :ref:`genindex` 37 | * :ref:`modindex` 38 | * :ref:`search` 39 | -------------------------------------------------------------------------------- /docs/assemble.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env/python 2 | 3 | """ 4 | Adds the ability to 'include' files in markdown templates. Just main README.md 5 | for now. 6 | """ 7 | 8 | import os 9 | import re 10 | 11 | file_pairs = [ 12 | # Source -------------------- Destination 13 | ["src/readme-template.md", "../README.md"], 14 | ] 15 | 16 | include_syntax = r'\{\{\'(.+)\'\}\}' 17 | 18 | for source, destination in file_pairs: 19 | source_path = os.path.abspath(os.path.dirname(source)) 20 | # Read the template 21 | with open(source) as f: 22 | template_contents = f.read() 23 | m = re.search(include_syntax, template_contents) 24 | include_path = m.group(1) 25 | include_path = os.path.join(source_path, include_path) 26 | # Read the included file 27 | with open(include_path) as f: 28 | include_contents = f.read() 29 | # Write the combined file 30 | with open(destination, "w+") as f: 31 | output = re.sub(include_syntax, include_contents, template_contents) 32 | f.write(output) 33 | -------------------------------------------------------------------------------- /tests/test_potvin_fuglevand_muscle.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from pymuscle import PotvinFuglevandMuscle as Muscle 4 | 5 | 6 | def test_init(): 7 | with pytest.raises(TypeError): 8 | m = Muscle() 9 | 10 | motor_unit_count = 120 11 | m = Muscle(motor_unit_count) 12 | 13 | assert m.motor_unit_count == motor_unit_count 14 | 15 | 16 | def test_step(): 17 | motor_unit_count = 120 18 | m = Muscle(motor_unit_count) 19 | 20 | with pytest.raises(TypeError): 21 | m.step() 22 | 23 | with pytest.raises(AssertionError): 24 | m.step(np.ones(3), 1) 25 | 26 | # No excitation 27 | output = m.step(np.zeros(motor_unit_count), 1.0) 28 | assert output == pytest.approx(0.0) 29 | 30 | # Moderate 31 | m = Muscle(motor_unit_count) 32 | moderate_input = 40.0 33 | moderate_output = 1311.86896 34 | output = m.step(np.full(motor_unit_count, moderate_input), 1.0) 35 | assert output == pytest.approx(moderate_output) 36 | 37 | # Moderate - single value 38 | m = Muscle(motor_unit_count) 39 | output = m.step(moderate_input, 1.0) 40 | assert output == pytest.approx(moderate_output) 41 | 42 | # Max 43 | m = Muscle(motor_unit_count) 44 | max_input = m.max_excitation 45 | max_output = 2215.98114 46 | output = m.step(np.full(motor_unit_count, max_input), 1.0) 47 | assert output == pytest.approx(max_output) 48 | 49 | # Above 50 | m = Muscle(motor_unit_count) 51 | output = m.step(np.full(motor_unit_count, max_input + 40), 1.0) 52 | assert output == pytest.approx(max_output) 53 | -------------------------------------------------------------------------------- /tests/benchmarks/bench_potvin_fuglevand_muscle.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pymuscle import PotvinFuglevandMuscle as Muscle 3 | import plotly.graph_objs as go 4 | from plotly.offline import plot 5 | from util import timing 6 | 7 | 8 | @timing 9 | def instantiation(a, n): 10 | for _ in range(a): 11 | m = Muscle(n) 12 | 13 | 14 | @timing 15 | def step(a, n): 16 | moderate_input = 40.0 17 | in_vec = np.full(n, moderate_input) 18 | m = Muscle(n) 19 | for _ in range(a): 20 | m.step(in_vec, 1.0) 21 | 22 | 23 | def main(): 24 | step_durations = [] 25 | instantiation_durations = [] 26 | loops = 10000 27 | exps = list(range(10, 16)) 28 | for i in exps: 29 | n = 2 ** i 30 | inst_dur, _ = instantiation(loops, n) 31 | instantiation_durations.append(inst_dur) 32 | step_dur, _ = step(loops, n) 33 | step_durations.append(step_dur) 34 | 35 | instantiation_durations = np.log(instantiation_durations) 36 | step_durations = np.log(step_durations) 37 | 38 | # Log plot of durations by exponents 39 | 40 | layout = go.Layout( 41 | title="Log Plot of Durations for N=2^X", 42 | xaxis={"title": "Number of Motor Units - N=2^X"}, 43 | yaxis={"title": "Duration (log(seconds)) of 10000 loops"} 44 | ) 45 | data = [ 46 | {'y': instantiation_durations, 'x': exps, 'title': 'Instantiation'}, 47 | {'y': step_durations, 'x': exps, 'title': '.step()'} 48 | ] 49 | fig = go.Figure( 50 | data=data, 51 | layout=layout 52 | ) 53 | plot(fig) 54 | 55 | 56 | if __name__ == '__main__': 57 | main() 58 | -------------------------------------------------------------------------------- /tests/test_potvin_fuglevand_2017_muscle_fibers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from pymuscle import PotvinFuglevand2017MuscleFibers as Fibers 4 | 5 | 6 | def test_init(): 7 | # Missing arguments 8 | with pytest.raises(TypeError): 9 | f = Fibers() 10 | 11 | motor_unit_count = 120 12 | f = Fibers(motor_unit_count) 13 | assert f.motor_unit_count == motor_unit_count 14 | 15 | 16 | def test_step(): 17 | motor_unit_count = 120 18 | f = Fibers(motor_unit_count) 19 | 20 | # Missing arguments 21 | with pytest.raises(TypeError): 22 | f.step() 23 | 24 | # Bad type 25 | with pytest.raises(TypeError): 26 | f.step(33.0, 1) 27 | 28 | # Wrong shape 29 | with pytest.raises(AssertionError): 30 | f.step(np.ones(3), 1) 31 | 32 | # No excitation 33 | output = f.step(np.zeros(motor_unit_count), 1.0) 34 | assert output == pytest.approx(0.0) 35 | 36 | # Moderate 37 | f = Fibers(motor_unit_count) 38 | moderate_input = 40.0 39 | moderate_output = 2586.7530897 40 | output = f.step(np.full(motor_unit_count, moderate_input), 1.0) 41 | output_sum = np.sum(output) 42 | assert output_sum == pytest.approx(moderate_output) 43 | 44 | # Max 45 | f = Fibers(motor_unit_count) 46 | max_input = 67.0 47 | max_output = 2609.0308816 48 | output = f.step(np.full(motor_unit_count, max_input), 1.0) 49 | output_sum = np.sum(output) 50 | assert output_sum == pytest.approx(max_output) 51 | 52 | # Above 53 | f = Fibers(motor_unit_count) 54 | output = f.step(np.full(motor_unit_count, max_input + 40), 1.0) 55 | output_sum = np.sum(output) 56 | assert output_sum == pytest.approx(max_output) 57 | -------------------------------------------------------------------------------- /examples/minimal-physio-example.py: -------------------------------------------------------------------------------- 1 | from pymuscle import PotvinFuglevandMuscle as Muscle 2 | from pymuscle.vis import PotvinChart 3 | 4 | # Create a Muscle with small number of motor units. 5 | motor_unit_count = 120 6 | muscle = Muscle(motor_unit_count) 7 | 8 | # Set up the simulation parameters 9 | sim_duration = 200 # seconds 10 | frames_per_second = 50 11 | step_size = 1 / frames_per_second 12 | total_steps = int(sim_duration / step_size) 13 | 14 | # Use a constant level of excitation to more easily observe fatigue 15 | excitation = 40.0 16 | 17 | total_outputs = [] 18 | outputs_by_unit = [] 19 | print("Starting simulation ...") 20 | for i in range(total_steps): 21 | # Calling step() updates the simulation and returns the total output 22 | # produced by the muscle during this step for the given excitation level. 23 | total_output = muscle.step(excitation, step_size) 24 | total_outputs.append(total_output) 25 | # You can also introspect the muscle to see the forces being produced 26 | # by each motor unit. 27 | output_by_unit = muscle.current_forces 28 | outputs_by_unit.append(output_by_unit) 29 | if (i % (frames_per_second * 10)) == 0: 30 | print("Sim time - {} seconds ...".format(int(i / frames_per_second))) 31 | 32 | # Visualize the behavior of the motor units over time 33 | print("Creating chart ...") 34 | chart = PotvinChart( 35 | outputs_by_unit, 36 | step_size 37 | ) 38 | # Things to note in the chart: 39 | # - Some motor units (purple) are never recruited at this level of excitation 40 | # - Some motor units become completely fatigued in this short time 41 | # - Some motor units stabilize and decrease their rate of fatigue 42 | # - Forces from the weakest motor units are almost constant the entire time 43 | chart.display() 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # PyMuscle Specific 2 | *.html 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | .pyre/ 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # dotenv 88 | .env 89 | 90 | # virtualenv 91 | .venv 92 | venv/ 93 | ENV/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | # OSX 109 | .DS_Store 110 | 111 | # VSCode 112 | .vscode 113 | -------------------------------------------------------------------------------- /examples/pymunk-gym-example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Arm Curl 3 | 4 | The goal here is to keep the arm tracking a moving target smoothly. 5 | Watch as the effort required slowly increases for all portions of the motion, 6 | until the muscle is no longer able to support the arm. Here a hand tuned 7 | PID controller takes care of managing this effort. 8 | """ 9 | 10 | import sys 11 | import os 12 | sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) 13 | from envs import PymunkArmEnv 14 | 15 | 16 | def main(): 17 | env = PymunkArmEnv(apply_fatigue=True) 18 | 19 | # Set up the simulation parameters 20 | sim_duration = 120 # seconds 21 | frames_per_second = 50 22 | step_size = 1 / frames_per_second 23 | total_steps = int(sim_duration / step_size) 24 | 25 | # Here we are going to send a constant excitation to the tricep and 26 | # vary the excitation of the bicep as we try to hit a target location. 27 | brachialis_input = 0.4 # Percent of max input 28 | tricep_input = 0.2 29 | hand_target_y = 360 30 | target_delta = 10 31 | # Hand tuned PID params. 32 | Kp = 0.0001 33 | Ki = 0.00004 34 | Kd = 0.0001 35 | prev_y = None 36 | print("Fraction of max excitation ...") 37 | for i in range(total_steps): 38 | hand_x, hand_y = env.step( 39 | [brachialis_input, tricep_input], 40 | step_size, debug=False 41 | ) 42 | 43 | # PID Control 44 | if prev_y is None: 45 | prev_y = hand_y 46 | 47 | # Proportional component 48 | error = hand_target_y - hand_y 49 | alpha = Kp * error 50 | # Add integral component 51 | i_c = Ki * (error * step_size) 52 | alpha -= i_c 53 | # Add in differential component 54 | d_c = Kd * ((hand_y - prev_y) / step_size) 55 | alpha -= d_c 56 | 57 | prev_y = hand_y 58 | brachialis_input += alpha 59 | 60 | if brachialis_input > 1.0: 61 | brachialis_input = 1.0 62 | if brachialis_input < 0.0: 63 | brachialis_input = 0.0 64 | 65 | # Vary our set point and display the excitation required 66 | if i % frames_per_second == 0: 67 | print(brachialis_input) 68 | hand_target_y += target_delta 69 | 70 | # Switch directions every 5 seconds 71 | if i > 0 and i % (frames_per_second * 5) == 0: 72 | target_delta *= -1 73 | 74 | env.render() 75 | 76 | 77 | if __name__ == '__main__': 78 | main() 79 | -------------------------------------------------------------------------------- /pymuscle/vis/potvin_charts.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import colorlover as cl 3 | from numpy import ndarray 4 | from plotly.offline import plot 5 | 6 | 7 | class PotvinChart(object): 8 | 9 | def __init__(self, time_by_forces: ndarray, step_size: float): 10 | 11 | forces_by_time = np.array(time_by_forces).T 12 | motor_unit_count, steps = forces_by_time.shape 13 | sim_duration = steps * step_size 14 | times = np.arange(0.0, sim_duration, step_size) 15 | 16 | # Setting colors for plot. 17 | potvin_scheme = [ 18 | 'rgb(115, 0, 0)', 19 | 'rgb(252, 33, 23)', 20 | 'rgb(230, 185, 43)', 21 | 'rgb(107, 211, 100)', 22 | 'rgb(52, 211, 240)', 23 | 'rgb(36, 81, 252)', 24 | 'rgb(0, 6, 130)' 25 | ] 26 | 27 | # It's hacky but also sorta cool. 28 | c = cl.to_rgb(cl.interp(potvin_scheme, motor_unit_count)) 29 | c = [val.replace('rgb', 'rgba') for val in c] 30 | c = [val.replace(')', ',{})') for val in c] 31 | 32 | # Assing non-public attributes 33 | self._step_size = step_size 34 | self._forces_by_time = forces_by_time 35 | self._c = c 36 | self._times = times 37 | 38 | # Assign public attributes 39 | self.motor_unit_count = motor_unit_count 40 | 41 | def _get_color(self, trace_index: int) -> str: 42 | # The first and every 20th trace should be full opacity 43 | alpha = 0.2 44 | if trace_index == 0 or ((trace_index + 1) % 20 == 0): 45 | alpha = 1.0 46 | color = self._c[trace_index].format(alpha) 47 | return color 48 | 49 | def display(self) -> None: 50 | # Per Motor Unit Force 51 | data = [] 52 | for i, t in enumerate(self._forces_by_time): 53 | trace = dict( 54 | x=self._times, 55 | y=t, 56 | name=i + 1, 57 | marker=dict( 58 | color=self._get_color(i) 59 | ), 60 | ) 61 | data.append(trace) 62 | 63 | layout = dict( 64 | title='Motor Unit Forces by Time', 65 | yaxis=dict( 66 | title='Motor unit force (relative to MU1 tetanus)' 67 | ), 68 | xaxis=dict( 69 | title='Time (s)' 70 | ) 71 | ) 72 | 73 | fig = dict( 74 | data=data, 75 | layout=layout 76 | ) 77 | plot(fig, filename='forces-by-time.html', validate=False) 78 | -------------------------------------------------------------------------------- /examples/envs/assets/ballonstring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License + No Military Use 2 | 3 | Copyright (c) 2018 Ian Danforth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | Using this software in source or binary format, with or without modification, 16 | to develop, modify or improve software, products or programs for military use 17 | is not permitted without specific prior written permission. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | 27 | Explanation of the No Military Use Clause 28 | 29 | This meaning of this phrase includes any use by a military, not just for 30 | combat. It also includes work by contractors explicitly for a military. 31 | 32 | Example Use 33 | 34 | You may incorporate this code into your own products so long as those products 35 | are not primarily for military use. If you use this code in a drafting program 36 | and the military used it, this would be permitted use. If you used it in a 37 | drafting program for nuclear submarines, this would be a prohibited use. 38 | 39 | Developing a general use program, product or piece of software and then 40 | actively selling or marketing that program, product or software to a military 41 | organization would be a violation of the No Military Use clause. 42 | 43 | OSI / FSF 44 | 45 | The MIT + No Military Use License is not OSI or FSF approved. Users of the 46 | MIT + No Military Use License believe that software development is not a 47 | neutral act. In addition users believe that software development imposes a 48 | responsiblity on developers to consider the consequences of the use of their 49 | software and a responsibility to prevent its misuse. The unlimited freedoms of 50 | use codified by the OSI and FSF are incompatible with this view and are 51 | violated by this license. 52 | -------------------------------------------------------------------------------- /examples/envs/muscled_hopper.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from gym import utils 3 | from . import mujoco_env 4 | from pymuscle import PotvinFuglevandMuscle as Muscle 5 | 6 | 7 | class MuscledHopperEnv(mujoco_env.MujocoEnv, utils.EzPickle): 8 | def __init__(self, apply_fatigue=False): 9 | # Instantiate the PyMuscles (not real muscle names) 10 | hamstring_motor_unit_count = 300 11 | self.hamstring_muscle = Muscle(hamstring_motor_unit_count, apply_fatigue) 12 | thigh_motor_unit_count = 300 13 | self.thigh_muscle = Muscle(thigh_motor_unit_count, apply_fatigue) 14 | calf_motor_unit_count = 200 15 | self.calf_muscle = Muscle(calf_motor_unit_count, apply_fatigue) 16 | shin_motor_unit_count = 100 17 | self.shin_muscle = Muscle(shin_motor_unit_count, apply_fatigue) 18 | self.muscles = [ 19 | self.hamstring_muscle, 20 | self.thigh_muscle, 21 | self.calf_muscle, 22 | self.shin_muscle 23 | ] 24 | 25 | # Initialize parents 26 | mujoco_env.MujocoEnv.__init__(self, 'hopper.xml', 4) 27 | utils.EzPickle.__init__(self) 28 | 29 | def step(self, a): 30 | posbefore = self.sim.data.qpos[0] 31 | 32 | # Get output from muscles 33 | outputs = [muscle.step(a[i], 0.002 * self.frame_skip) for 34 | i, muscle in enumerate(self.muscles)] 35 | 36 | self.do_simulation(outputs, self.frame_skip) 37 | posafter, height, ang = self.sim.data.qpos[0:3] 38 | alive_bonus = 1.0 39 | reward = (posafter - posbefore) / self.dt 40 | reward += alive_bonus 41 | reward -= 1e-3 * np.square(a).sum() 42 | s = self.state_vector() 43 | done = not (np.isfinite(s).all() and (np.abs(s[2:]) < 100).all() and 44 | (height > .7) and (abs(ang) < .2)) 45 | ob = self._get_obs() 46 | return ob, reward, done, {} 47 | 48 | def _get_obs(self): 49 | return np.concatenate([ 50 | self.sim.data.qpos.flat[1:], 51 | np.clip(self.sim.data.qvel.flat, -10, 10) 52 | ]) 53 | 54 | def reset_model(self): 55 | qpos = self.init_qpos + self.np_random.uniform( 56 | low=-.005, 57 | high=.005, 58 | size=self.model.nq 59 | ) 60 | qvel = self.init_qvel + self.np_random.uniform( 61 | low=-.005, 62 | high=.005, 63 | size=self.model.nv 64 | ) 65 | self.set_state(qpos, qvel) 66 | return self._get_obs() 67 | 68 | def viewer_setup(self): 69 | self.viewer.cam.trackbodyid = 2 70 | self.viewer.cam.distance = self.model.stat.extent * 0.75 71 | self.viewer.cam.lookat[2] += .8 72 | self.viewer.cam.elevation = -20 73 | -------------------------------------------------------------------------------- /pymuscle/hill_type.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementations of equations used in Hill-Type muscle models 3 | 4 | Not all equations for a traditional Hill-Type model are provided here. It is 5 | expected that passive elements will be modeled by a physics simulator selected 6 | by PyMuscle users. 7 | 8 | Also it should be noted that these equations do not take pennetation angle 9 | into account (which is common in Hill-type models). Users should check to 10 | make sure their physics simulator is handling forces correctly to provide values 11 | which vary by angle. 12 | """ 13 | import numpy as np 14 | 15 | 16 | def contractile_element_force_length_curve( 17 | rest_length: float, 18 | current_length: float, 19 | curve_width_factor: float=17.33, 20 | peak_force_length: float=1.1 21 | ) -> float: 22 | """ 23 | This normalizes length to the resting length of the tendon 24 | Anderson and others normalize by the length that would generate the 25 | most force. This is a gaussian-like relationship. 26 | 27 | :param rest_length: The resting length of the muscle 28 | :param current_length: The current length of the muscle 29 | :param curve_width_factor: 30 | A curve shape paramater which describes where force begins to approach 31 | 0 for muscles shorter or longer than resting length. 32 | See https://www.desmos.com/calculator/qosweyyqfk for graph. 33 | :param peak_force_length: 34 | The ratio of the length of the muscle when it can generate maximum 35 | contractile force (sometimes known as the optimal length) and the 36 | resting length of the muscle. 37 | """ 38 | norm_length = current_length / rest_length 39 | peak_force_length = 1.10 # Aubert 1951 40 | exponent = curve_width_factor * (-1 * (abs(norm_length - peak_force_length) ** 3)) 41 | percentage_of_max_force = np.exp(exponent) 42 | return percentage_of_max_force 43 | 44 | 45 | def contractile_element_force_velocity_curve( 46 | rest_length: float, 47 | current_length: float, 48 | prev_length: float, 49 | time_step: float, 50 | max_velocity: float = 3.0, 51 | max_exccentric_multiple: float = 1.8 52 | ) -> float: 53 | """ 54 | Returns a value (0 < value < max_eccentric_multiple). This is a sigmoidal 55 | relationship. 56 | 57 | :param rest_length: The resting length of the muscle 58 | :param current_length: The current length of the muscle 59 | 60 | See https://www.desmos.com/calculator/gkcdsdcuyh for graph 61 | """ 62 | velocity = ((prev_length - current_length) / rest_length) / time_step 63 | norm_velocity = velocity / max_velocity 64 | exponent = (0.04 - norm_velocity) / 0.18 # Values to make (0, 1) 65 | denominator = 1 + np.exp(exponent) 66 | force_multiple = max_exccentric_multiple - (max_exccentric_multiple / denominator) 67 | return force_multiple 68 | -------------------------------------------------------------------------------- /tests/test_pymuscle_fibers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from copy import copy 4 | from pymuscle import PyMuscleFibers as Fibers 5 | 6 | 7 | def test_init(): 8 | motor_unit_count = 120 9 | f = Fibers(120) 10 | 11 | # Check calculated number of motor units 12 | assert f.motor_unit_count == 120 13 | assert np.equal(f.current_forces, np.zeros(motor_unit_count)).all() 14 | 15 | 16 | def test_step(): 17 | motor_unit_count = 120 18 | f = Fibers(motor_unit_count) 19 | 20 | # Missing arguments 21 | with pytest.raises(TypeError): 22 | f.step() 23 | 24 | with pytest.raises(TypeError): 25 | f.step(33.0) 26 | 27 | # Bad type 28 | with pytest.raises(TypeError): 29 | f.step(33.0, 1) 30 | 31 | # Wrong shape 32 | with pytest.raises(AssertionError): 33 | f.step(np.ones(3), 1) 34 | 35 | # No excitation 36 | output = f.step(np.zeros(motor_unit_count), 1.0) 37 | assert output == pytest.approx(0.0) 38 | 39 | # Moderate 40 | f = Fibers(motor_unit_count) 41 | moderate_input = 40.0 42 | moderate_output = 2586.7530897 43 | output = f.step(np.full(motor_unit_count, moderate_input), 1.0) 44 | output_sum = np.sum(output) 45 | assert output_sum == pytest.approx(moderate_output) 46 | 47 | # Max 48 | f = Fibers(motor_unit_count) 49 | max_input = 67.0 50 | max_output = 2609.0308816 51 | output = f.step(np.full(motor_unit_count, max_input), 1.0) 52 | output_sum = np.sum(output) 53 | assert output_sum == pytest.approx(max_output) 54 | 55 | # Above 56 | f = Fibers(motor_unit_count) 57 | output = f.step(np.full(motor_unit_count, max_input + 40), 1.0) 58 | output_sum = np.sum(output) 59 | assert output_sum == pytest.approx(max_output) 60 | 61 | 62 | def test_fatigue(): 63 | motor_unit_count = 120 64 | f = Fibers(motor_unit_count) 65 | 66 | # With zero inputs fatigue shouldn't change 67 | f = Fibers(motor_unit_count) 68 | ctf_before = copy(f.current_peak_forces) 69 | for i in range(100): 70 | f.step(np.zeros(motor_unit_count), 1.0) 71 | 72 | ctf_after = f.current_peak_forces 73 | assert np.equal(ctf_before, ctf_after).all() 74 | 75 | # With max input all ctfs should decrease 76 | f = Fibers(motor_unit_count) 77 | ctf_before = copy(f.current_peak_forces) 78 | max_input = 67.0 79 | for i in range(100): 80 | f.step(np.full(motor_unit_count, max_input), 1.0) 81 | 82 | ctf_after = f.current_peak_forces 83 | assert np.greater(ctf_before, ctf_after).all() 84 | 85 | # With fatigue off no ctfs should change 86 | f = Fibers(motor_unit_count, apply_fatigue=False) 87 | max_input = 67.0 88 | for i in range(100): 89 | f.step(np.full(motor_unit_count, max_input), 1.0) 90 | 91 | ctf_after = f.current_peak_forces 92 | assert np.equal(ctf_before, ctf_after).all() 93 | -------------------------------------------------------------------------------- /examples/envs/assets/arm.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/test_potvin_fuglevand_2017_motor_neuron_pool.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from pymuscle import PotvinFuglevand2017MotorNeuronPool as Pool 4 | 5 | 6 | def test_init(): 7 | # Missing arguments 8 | with pytest.raises(TypeError): 9 | p = Pool() 10 | 11 | motor_unit_count = 120 12 | p = Pool(motor_unit_count) 13 | assert p.motor_unit_count == motor_unit_count 14 | 15 | # Check calculated max excitation 16 | assert p.max_excitation == 67.0 17 | 18 | 19 | def test_step(): 20 | motor_unit_count = 120 21 | p = Pool(motor_unit_count) 22 | 23 | # Missing arguments 24 | with pytest.raises(TypeError): 25 | p.step() 26 | 27 | # Bad type 28 | with pytest.raises(TypeError): 29 | p.step(33.0, 1) 30 | 31 | # Wrong shape 32 | with pytest.raises(AssertionError): 33 | p.step(np.ones(3), 1) 34 | 35 | # No excitation 36 | output = p.step(np.zeros(motor_unit_count), 1.0) 37 | output_sum = sum(output) 38 | assert output_sum == pytest.approx(0.0) 39 | 40 | # Moderate 41 | p = Pool(motor_unit_count) 42 | moderate_input = 40.0 43 | moderate_output = 3503.58881 44 | output = p.step(np.full(motor_unit_count, moderate_input), 1.0) 45 | output_sum = np.sum(output) 46 | assert output_sum == pytest.approx(moderate_output) 47 | 48 | # Max 49 | p = Pool(motor_unit_count) 50 | max_input = 67.0 51 | max_output = 3915.06787 52 | output = p.step(np.full(motor_unit_count, max_input), 1.0) 53 | output_sum = np.sum(output) 54 | assert output_sum == pytest.approx(max_output) 55 | 56 | # Above 57 | p = Pool(motor_unit_count) 58 | output = p.step(np.full(motor_unit_count, max_input + 40), 1.0) 59 | output_sum = np.sum(output) 60 | assert output_sum == pytest.approx(max_output) 61 | 62 | 63 | def test_fatigue_values(): 64 | motor_unit_count = 120 65 | 66 | p = Pool(motor_unit_count) 67 | max_input = 67.0 68 | first_output = p.step(np.full(motor_unit_count, max_input), 1.0) 69 | first_output_sum = np.sum(first_output) 70 | 71 | # Advance the simulation 10 seconds 72 | for _ in range(10): 73 | adapted_output = p.step(np.full(motor_unit_count, max_input), 1.0) 74 | adapted_output_sum = np.sum(adapted_output) 75 | 76 | # Should have changed 77 | assert adapted_output_sum != pytest.approx(first_output_sum) 78 | 79 | # To this value 80 | expected_adapted_output = 3749.91061 81 | assert adapted_output_sum == pytest.approx(expected_adapted_output) 82 | 83 | 84 | def test_fatigue_disabled(): 85 | motor_unit_count = 120 86 | 87 | p = Pool(motor_unit_count, apply_fatigue=False) 88 | max_input = 67.0 89 | first_output = p.step(np.full(motor_unit_count, max_input), 1.0) 90 | first_output_sum = np.sum(first_output) 91 | 92 | # Advance the simulation 10 seconds 93 | for _ in range(10): 94 | next_output = p.step(np.full(motor_unit_count, max_input), 1.0) 95 | next_output_sum = np.sum(next_output) 96 | 97 | # Should NOT have changed 98 | assert next_output_sum == pytest.approx(first_output_sum) 99 | -------------------------------------------------------------------------------- /tests/test_standard_muscle.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from pymuscle import StandardMuscle as Muscle 4 | 5 | 6 | def test_init(): 7 | max_force = 32.0 8 | m = Muscle(max_force) 9 | 10 | # Check calculated number of motor units 11 | assert m.motor_unit_count == 120 12 | 13 | # Check calculated max_output 14 | max_output = 2609.0309 15 | assert m.max_arb_output == pytest.approx(max_output) 16 | 17 | max_force = 90.0 18 | m = Muscle(max_force) 19 | 20 | # Check calculated number of motor units 21 | assert m.motor_unit_count == 340 22 | max_output = 7338.29062 23 | assert m.max_arb_output == pytest.approx(max_output) 24 | 25 | 26 | def test_step(): 27 | max_force = 32.0 28 | m = Muscle(max_force) 29 | 30 | with pytest.raises(TypeError): 31 | m.step() 32 | 33 | with pytest.raises(AssertionError): 34 | m.step(np.ones(3), 1) 35 | 36 | # No excitation 37 | output = m.step(np.zeros(m.motor_unit_count), 1.0) 38 | assert output == pytest.approx(0.0) 39 | 40 | # Moderate 41 | m = Muscle(max_force) 42 | moderate_input = 0.5 43 | moderate_output = 0.39096348 44 | output = m.step(np.full(m.motor_unit_count, moderate_input), 1.0) 45 | assert output == pytest.approx(moderate_output) 46 | 47 | # Moderate - single value 48 | m = Muscle(max_force) 49 | output = m.step(moderate_input, 1.0) 50 | assert output == pytest.approx(moderate_output) 51 | 52 | # Max 53 | m = Muscle(max_force) 54 | max_input = 1.0 55 | max_output = 0.84935028 56 | output = m.step(np.full(m.motor_unit_count, max_input), 1.0) 57 | assert output == pytest.approx(max_output) 58 | 59 | # Above 60 | m = Muscle(max_force) 61 | output = m.step(np.full(m.motor_unit_count, max_input + 40), 1.0) 62 | assert output == pytest.approx(max_output) 63 | 64 | 65 | def test_fatigue(): 66 | # With fatigue off the return should always be the same 67 | max_force = 32.0 68 | m = Muscle(max_force, apply_peripheral_fatigue=False) 69 | 70 | # As measured by fatigue 71 | fatigue_before = m.get_peripheral_fatigue() 72 | assert fatigue_before == 0.0 73 | 74 | # As measured by output 75 | moderate_input = 0.5 76 | moderate_output = 0.39096348 77 | for i in range(100): 78 | output = m.step(moderate_input, 1.0) 79 | assert output == pytest.approx(moderate_output) 80 | 81 | # And again by fatigue after 82 | fatigue_after = m.get_peripheral_fatigue() 83 | assert fatigue_after == 0.0 84 | 85 | # Fatigue ON 86 | 87 | # You should see no change with zero activation 88 | m = Muscle(max_force, apply_peripheral_fatigue=True) 89 | 90 | # As measured by fatigue 91 | fatigue_before = m.get_peripheral_fatigue() 92 | assert fatigue_before == 0.0 93 | 94 | for i in range(100): 95 | output = m.step(0.0, 1.0) 96 | assert output == pytest.approx(0.0) 97 | 98 | # As measured by fatgue after 99 | fatigue_after = m.get_peripheral_fatigue() 100 | assert fatigue_after == 0.0 101 | 102 | # You should see increasing fatigue and decreasing output 103 | # with non-zero inputs. 104 | 105 | # Measured by output 106 | fatigued_output = 0.29151827 107 | for i in range(100): 108 | output = m.step(moderate_input, 1.0) 109 | assert output == pytest.approx(fatigued_output) 110 | 111 | # As measured by fatgue after 112 | expected_fatigue = 0.18028181 113 | fatigue_after = m.get_peripheral_fatigue() 114 | assert pytest.approx(fatigue_after, expected_fatigue) 115 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Note: To use the 'upload' functionality of this file, you must: 5 | # $ pip install twine 6 | 7 | import io 8 | import os 9 | import sys 10 | from shutil import rmtree 11 | 12 | from setuptools import find_packages, setup, Command 13 | 14 | # Package meta-data. 15 | NAME = 'pymuscle' 16 | DESCRIPTION = 'A motor unit based model of skeletal muscle and fatigue' 17 | URL = 'https://github.com/iandanforth/pymuscle' 18 | EMAIL = 'iandanforth@gmail.com' 19 | AUTHOR = 'Ian Danforth' 20 | REQUIRES_PYTHON = '>=3.6.0' 21 | VERSION = None 22 | 23 | # What packages are required for this module to be executed? 24 | # This should be kept in sync with *runtime* packages, but not 25 | # *development* packages. 26 | REQUIRED = [ 27 | 'numpy', 28 | 'plotly', 29 | 'colorlover' 30 | ] 31 | 32 | # The rest you shouldn't have to touch too much :) 33 | # ------------------------------------------------ 34 | # Except, perhaps the License and Trove Classifiers! 35 | # If you do change the License, remember to change the Trove Classifier for that! 36 | 37 | here = os.path.abspath(os.path.dirname(__file__)) 38 | 39 | # Import the README and use it as the long-description. 40 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 41 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 42 | long_description = '\n' + f.read() 43 | 44 | # Load the package's __version__.py module as a dictionary. 45 | about = {} 46 | if not VERSION: 47 | with open(os.path.join(here, NAME, '__version__.py')) as f: 48 | exec(f.read(), about) 49 | else: 50 | about['__version__'] = VERSION 51 | 52 | 53 | class UploadCommand(Command): 54 | """Support setup.py upload.""" 55 | 56 | description = 'Build and publish the package.' 57 | user_options = [] 58 | 59 | @staticmethod 60 | def status(s): 61 | """Prints things in bold.""" 62 | print('\033[1m{0}\033[0m'.format(s)) 63 | 64 | def initialize_options(self): 65 | pass 66 | 67 | def finalize_options(self): 68 | pass 69 | 70 | def run(self): 71 | try: 72 | self.status('Removing previous builds…') 73 | rmtree(os.path.join(here, 'dist')) 74 | except OSError: 75 | pass 76 | 77 | self.status('Building Source and Wheel (universal) distribution…') 78 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 79 | 80 | self.status('Uploading the package to PyPi via Twine…') 81 | os.system('twine upload dist/*') 82 | 83 | # self.status('Pushing git tags…') 84 | # os.system('git tag v{0}'.format(about['__version__'])) 85 | # os.system('git push --tags') 86 | 87 | sys.exit() 88 | 89 | 90 | # Where the magic happens: 91 | setup( 92 | name=NAME, 93 | version=about['__version__'], 94 | description=DESCRIPTION, 95 | long_description=long_description, 96 | long_description_content_type='text/markdown', 97 | author=AUTHOR, 98 | author_email=EMAIL, 99 | python_requires=REQUIRES_PYTHON, 100 | url=URL, 101 | packages=find_packages(exclude=('tests',)), 102 | # If your package is a single module, use this instead of 'packages': 103 | py_modules=[NAME], 104 | 105 | # entry_points={ 106 | # 'console_scripts': ['mycli=mymodule:cli'], 107 | # }, 108 | install_requires=REQUIRED, 109 | include_package_data=True, 110 | license='MIT + No Military Use', 111 | classifiers=[ 112 | # Trove classifiers 113 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 114 | 'Programming Language :: Python', 115 | 'Programming Language :: Python :: 3', 116 | 'Programming Language :: Python :: 3.6', 117 | 'Programming Language :: Python :: Implementation :: CPython', 118 | 'Programming Language :: Python :: Implementation :: PyPy' 119 | ], 120 | # $ setup.py publish support. 121 | cmdclass={ 122 | 'upload': UploadCommand, 123 | }, 124 | ) -------------------------------------------------------------------------------- /examples/envs/assets/hopper.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 74 | -------------------------------------------------------------------------------- /physio-readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started for Physiologists 2 | 3 | and other non-ml researchers. 4 | 5 | If you're interested in replicating or extending the results of of Potvin and 6 | Fuglevand, 2017 please start with reviewing [their official repository.](https://github.com/JimPotvin/Potvin_Fuglevand_Muscle_Fatigue_Model) 7 | 8 | The matlab code in that repository was re-implemented in Python for this library. (Forked at [15462f](https://github.com/JimPotvin/Potvin_Fuglevand_Muscle_Fatigue_Model/commit/15462f85106ed9ebde3d78ab6fe665c88bf8b32e)) Results 9 | from either set of code should be identical to within floating-point accuracy. Any errors 10 | in this code are entirely mine and not those of the paper's authors. 11 | 12 | ### Minimal example 13 | 14 | The Muscle class provides the primary API for the library. A Muscle can be 15 | heavily customized but here we use mainly default values. A PotvinFuglevandMuscle 16 | instantiated with 120 motor units has the distribution of strengths, recruitment 17 | thresholds, and fatigue properties as used in the experiments of Potvin and 18 | Fuglevand, 2017. That model is, in turn, based on experimental measurements of 19 | the first dorsal interossei. 20 | 21 | ```python 22 | from pymuscle import PotvinFuglevandMuscle as Muscle 23 | from pymuscle.vis import PotvinChart 24 | 25 | # Create a Muscle 26 | motor_unit_count = 120 27 | muscle = Muscle(motor_unit_count) 28 | 29 | # Set up the simulation parameters 30 | sim_duration = 200 # seconds 31 | frames_per_second = 50 32 | step_size = 1 / frames_per_second 33 | total_steps = int(sim_duration / step_size) 34 | 35 | # Use a constant level of excitation to more easily observe fatigue 36 | excitation = 40.0 37 | 38 | total_outputs = [] 39 | outputs_by_unit = [] 40 | print("Starting simulation ...") 41 | for i in range(total_steps): 42 | # Calling step() updates the simulation and returns the total output 43 | # produced by the muscle during this step for the given excitation level. 44 | total_output = muscle.step(excitation, step_size) 45 | total_outputs.append(total_output) 46 | # You can also introspect the muscle to see the forces being produced 47 | # by each motor unit. 48 | output_by_unit = muscle.current_forces 49 | outputs_by_unit.append(output_by_unit) 50 | if (i % (frames_per_second * 10)) == 0: 51 | print("Sim time - {} seconds ...".format(int(i / frames_per_second))) 52 | 53 | # Visualize the behavior of the motor units over time 54 | print("Creating chart ...") 55 | chart = PotvinChart( 56 | outputs_by_unit, 57 | step_size 58 | ) 59 | # Things to note in the chart: 60 | # - Some motor units (purple) are never recruited at this level of excitation 61 | # - Some motor units become completely fatigued in this short time 62 | # - Some motor units stabilize and decrease their rate of fatigue 63 | # - Forces from the weakest motor units are almost constant the entire time 64 | chart.display() 65 | 66 | ``` 67 | 68 | This will open a browser window with the produced chart. It should look like this: 69 | 70 |

71 | 72 | # Limitations 73 | 74 | ## Scope 75 | 76 | PyMuscle is concerned with inputs to motor unit neurons, the outputs of those 77 | motor units, and the changes to that system over time. It does not model the 78 | dynamics of the muscle body itself or the impact of dynamic motion on this 79 | motor unit input/output relationship. 80 | 81 | Separately, functions implementing force-length and force-velocity relationships 82 | of Hill-type muscles are provided in [hill_type.py](https://github.com/iandanforth/pymuscle/blob/master/pymuscle/hill_type.py) 83 | 84 | ## Recovery 85 | 86 | Potvin and Fuglevand 2017 explicitly models fatigue but *not* recovery. We 87 | eagerly await the updated model from Potvin which will included a model of 88 | recovery. 89 | 90 | Until then the `StandardMuscle` class, which builds on the Potvin and Fuglevand 91 | base classes, implements peripheral (muscle fiber) recovery as this is a 92 | relatively simple process but disables central (motor unit fatigue). If you use 93 | instances of `StandardMuscle` or its children please be aware you are stepping off 94 | peer-reviewed ground. 95 | 96 | ## Proprioception 97 | 98 | This library does not directly provide any feedback signals for control. The 99 | example projects show how to integrate PyMuscle with a physics simulation to 100 | get simulated output forces and stretch and strain values derived from the 101 | state of the simulated muscle body. (In the example this is a damped spring 102 | but a Hill-type, or more complex model could also be used.) 103 | 104 | Fatigue could be used as a feedback signal but this will need to be calculated 105 | from the states of the motor units. 106 | -------------------------------------------------------------------------------- /examples/envs/mujoco_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from gym import error, spaces 4 | from gym.utils import seeding 5 | import numpy as np 6 | from os import path 7 | import gym 8 | import six 9 | 10 | try: 11 | import mujoco_py 12 | except ImportError as e: 13 | raise error.DependencyNotInstalled("{}. (HINT: you need to install mujoco_py, and also perform the setup instructions here: https://github.com/openai/mujoco-py/.)".format(e)) 14 | 15 | DEFAULT_SIZE = 500 16 | 17 | 18 | class MujocoEnv(gym.Env): 19 | """Superclass for all MuJoCo environments. 20 | """ 21 | 22 | def __init__(self, model_path, frame_skip): 23 | if model_path.startswith("/"): 24 | fullpath = model_path 25 | else: 26 | fullpath = os.path.join(os.path.dirname(__file__), "assets", model_path) 27 | if not path.exists(fullpath): 28 | raise IOError("File %s does not exist" % fullpath) 29 | self.frame_skip = frame_skip 30 | self.model = mujoco_py.load_model_from_path(fullpath) 31 | self.sim = mujoco_py.MjSim(self.model) 32 | self.data = self.sim.data 33 | self.viewer = None 34 | self._viewers = {} 35 | 36 | self.metadata = { 37 | 'render.modes': ['human', 'rgb_array'], 38 | 'video.frames_per_second': int(np.round(1.0 / self.dt)) 39 | } 40 | 41 | self.init_qpos = self.sim.data.qpos.ravel().copy() 42 | self.init_qvel = self.sim.data.qvel.ravel().copy() 43 | observation, _reward, done, _info = self.step(np.zeros(self.model.nu)) 44 | assert not done 45 | self.obs_dim = observation.size 46 | 47 | bounds = self.model.actuator_ctrlrange.copy() 48 | low = bounds[:, 0] 49 | high = bounds[:, 1] 50 | self.action_space = spaces.Box(low=low, high=high) 51 | 52 | high = np.inf*np.ones(self.obs_dim) 53 | low = -high 54 | self.observation_space = spaces.Box(low, high) 55 | 56 | self.seed() 57 | 58 | def seed(self, seed=None): 59 | self.np_random, seed = seeding.np_random(seed) 60 | return [seed] 61 | 62 | # methods to override: 63 | # ---------------------------- 64 | 65 | def reset_model(self): 66 | """ 67 | Reset the robot degrees of freedom (qpos and qvel). 68 | Implement this in each subclass. 69 | """ 70 | raise NotImplementedError 71 | 72 | def viewer_setup(self): 73 | """ 74 | This method is called when the viewer is initialized and after every reset 75 | Optionally implement this method, if you need to tinker with camera position 76 | and so forth. 77 | """ 78 | pass 79 | 80 | # ----------------------------- 81 | 82 | def reset(self): 83 | self.sim.reset() 84 | ob = self.reset_model() 85 | old_viewer = self.viewer 86 | for v in self._viewers.values(): 87 | self.viewer = v 88 | self.viewer_setup() 89 | self.viewer = old_viewer 90 | return ob 91 | 92 | def set_state(self, qpos, qvel): 93 | assert qpos.shape == (self.model.nq,) and qvel.shape == (self.model.nv,) 94 | old_state = self.sim.get_state() 95 | new_state = mujoco_py.MjSimState(old_state.time, qpos, qvel, 96 | old_state.act, old_state.udd_state) 97 | self.sim.set_state(new_state) 98 | self.sim.forward() 99 | 100 | @property 101 | def dt(self): 102 | return self.model.opt.timestep * self.frame_skip 103 | 104 | def do_simulation(self, ctrl, n_frames): 105 | self.sim.data.ctrl[:] = ctrl 106 | for _ in range(n_frames): 107 | self.sim.step() 108 | 109 | def render(self, mode='human', width=DEFAULT_SIZE, height=DEFAULT_SIZE): 110 | if mode == 'rgb_array': 111 | self._get_viewer(mode).render(width, height) 112 | # window size used for old mujoco-py: 113 | data = self._get_viewer(mode).read_pixels(width, height, depth=False) 114 | # original image is upside-down, so flip it 115 | return data[::-1, :, :] 116 | elif mode == 'human': 117 | self._get_viewer(mode).render() 118 | 119 | def close(self): 120 | if self.viewer is not None: 121 | # self.viewer.finish() 122 | self.viewer = None 123 | self._viewers = {} 124 | 125 | def _get_viewer(self, mode): 126 | self.viewer = self._viewers.get(mode) 127 | if self.viewer is None: 128 | if mode == 'human': 129 | self.viewer = mujoco_py.MjViewer(self.sim) 130 | elif mode == 'rgb_array': 131 | self.viewer = mujoco_py.MjRenderContextOffscreen(self.sim, 0) 132 | self.viewer_setup() 133 | self._viewers[mode] = self.viewer 134 | return self.viewer 135 | 136 | def get_body_com(self, body_name): 137 | return self.data.get_body_xpos(body_name) 138 | 139 | def state_vector(self): 140 | return np.concatenate([ 141 | self.sim.data.qpos.flat, 142 | self.sim.data.qvel.flat 143 | ]) 144 | -------------------------------------------------------------------------------- /pymuscle/pymuscle_fibers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numpy import ndarray 3 | 4 | from .potvin_fuglevand_2017_muscle_fibers import PotvinFuglevand2017MuscleFibers 5 | 6 | 7 | class PyMuscleFibers(PotvinFuglevand2017MuscleFibers): 8 | """ 9 | Encapsulates the muscle fibers portions of the motor unit model. Currently 10 | a thin wrapper to implement fiber recovery. 11 | 12 | This is the standard muscle fiber model for PyMuscle. Which underlying 13 | model it extends is expected to change over time. This class exists to 14 | support the needs of users in the machine learning community. Users who 15 | belong to the physiology, biomechanical, or medical communities should 16 | use one of the base classes (such as PotvinFuglevand2017MuscleFibers) as 17 | those are strict implementations of published work. 18 | 19 | :param motor_unit_count: Number of motor units in the muscle 20 | :param force_conversion_factor: The ratio of newtons to arbitrary force 21 | units. All peak twitch forces are calculated internally to lie in a 22 | range of 0 to 100 arbitrary force units. The maximum force these 23 | fibers can theoretically produce is the sum of those peak twitch forces. 24 | To relate the arbitrary force units to SI units you need to provide 25 | a conversion factor. Increasing this value is essentially saying that 26 | a given motor unit produces more force than the default value would 27 | suggest. 28 | :param max_twitch_amplitude: Max twitch force within the pool 29 | :param max_contraction_time: 30 | [milliseconds] Maximum contraction time for a motor unit 31 | :param contraction_time_range: 32 | The scale between the fastest contraction time and the slowest 33 | :fatigue_factor_first_unit: 34 | The nominal fatigability of the first motor unit in percent / second 35 | :fatigability_range: 36 | The scale between the fatigability of the first motor unit and the last 37 | :contraction_time_change_ratio: 38 | For each percent of force lost during fatigue, what percentage should 39 | contraction increase? 40 | 41 | .. todo:: 42 | The argument naming isn't consistent. Sometimes we use 'max' and other 43 | times we use 'last unit'. Can these be made consistent? 44 | 45 | Usage:: 46 | 47 | from pymuscle import PyMuscleFibers as Fibers 48 | 49 | motor_unit_count = 60 50 | fibers = Fibers(motor_unit_count) 51 | motor_neuron_firing_rates = np.rand(motor_unit_count) * 10.0 52 | step_size = 0.01 53 | force = fibers.step(motor_neuron_firing_rates, step_size) 54 | """ 55 | def __init__( 56 | self, 57 | *args, 58 | force_conversion_factor: float=0.028, 59 | **kwargs 60 | ): 61 | super().__init__(*args, **kwargs) 62 | 63 | # Ratio of newtons (N) to internal arbitrary force units 64 | self.force_conversion_factor = force_conversion_factor 65 | 66 | # Define recovery rates 67 | # Averaged from data in Liu et al. 2002, Table 2 68 | max_recovery_rate = self._max_fatigue_rate / 2.53 69 | # Recovery should ~= fatigue for small units, <= for medium units and 70 | # << for largest units 71 | # Re-uses the same method as calculating fatigabilities. 72 | recovery_range = max_recovery_rate / self._nominal_fatigabilities[0] 73 | self._recovery_rates = self._calc_nominal_fatigabilities( 74 | self.motor_unit_count, 75 | recovery_range, 76 | max_recovery_rate, 77 | self._peak_twitch_forces 78 | ) 79 | 80 | def _update_fatigue( 81 | self, 82 | normalized_forces: ndarray, 83 | step_size: float 84 | ) -> None: 85 | """ 86 | Updates current twitch forces and contraction times. This overrides 87 | the parent method to add in recovery calculations. 88 | 89 | :param normalized_forces: 90 | Array of scaled forces. Used to weight how much fatigue will be 91 | generated in this step. 92 | :param step_size: How far time has advanced in this step. 93 | """ 94 | fatigues = (self._nominal_fatigabilities * normalized_forces) * step_size 95 | self._current_peak_forces -= fatigues 96 | 97 | # Apply recovery for units producing no force 98 | self._apply_recovery(normalized_forces, step_size) 99 | 100 | # Zero out negative values 101 | self._current_peak_forces[self._current_peak_forces < 0] = 0.0 102 | 103 | # Clip max values 104 | over = self._current_peak_forces > self._peak_twitch_forces 105 | self._current_peak_forces[over] = self._peak_twitch_forces[over] 106 | 107 | # Apply fatigue to contraction times 108 | self._update_contraction_times() 109 | 110 | def _apply_recovery( 111 | self, 112 | normalized_forces: ndarray, 113 | step_size: float 114 | ) -> None: 115 | """ 116 | Apply recovery to motor units not producing force in this step. 117 | 118 | TODO - Finalize the strategy used below 119 | """ 120 | # Find the indices of valid, recovering units. 121 | recovering = normalized_forces <= 0 122 | 123 | # Strategy 1 - Linear recovery at fatigue rates 124 | # recovery = self._nominal_fatigabilities[recovering] * step_size 125 | 126 | # Strategy 2 - Uses inverse of fatigue rates in an asymptotic approach 127 | # peak = self._peak_twitch_forces[recovering] 128 | # current = self._current_peak_forces[recovering] 129 | # recovery_ratio = (peak - current) / peak 130 | # recovery = (self._nominal_fatigabilities[recovering] * recovery_ratio) * step_size 131 | 132 | # Strategy 3 - Uses linear approach with calculated recovery rates 133 | # recovery = self._recovery_rates[recovering] * step_size 134 | 135 | # Strategy 4 - Combine 2 and 3 136 | peak = self._peak_twitch_forces[recovering] 137 | current = self._current_peak_forces[recovering] 138 | recovery_ratio = (peak - current) / peak 139 | recovery = (self._recovery_rates[recovering] * recovery_ratio) * step_size 140 | 141 | self._current_peak_forces[recovering] += recovery 142 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('..')) 18 | 19 | import pymuscle 20 | 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = 'PyMuscle' 25 | copyright = '2018, Ian Danforth' 26 | author = 'Ian Danforth' 27 | 28 | # The short X.Y version 29 | version = '' 30 | # The full version, including alpha/beta/rc tags 31 | release = '0.0.1' 32 | 33 | 34 | # -- General configuration --------------------------------------------------- 35 | 36 | # If your documentation needs a minimal Sphinx version, state it here. 37 | # 38 | # needs_sphinx = '1.0' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | 'sphinx.ext.autosummary', 45 | 'sphinx.ext.autodoc', 46 | 'sphinx_autodoc_typehints', 47 | 'sphinx.ext.intersphinx', 48 | 'sphinx.ext.todo', 49 | 'sphinx.ext.coverage', 50 | 'sphinx.ext.mathjax', 51 | 'sphinx.ext.viewcode', 52 | 'sphinx.ext.githubpages', 53 | ] 54 | 55 | # Autodoc 56 | autodoc_default_flags = ['members'] 57 | 58 | # Autosummary 59 | autosummary_generate = True 60 | 61 | # Add any paths that contain templates here, relative to this directory. 62 | templates_path = ['_templates'] 63 | 64 | # The suffix(es) of source filenames. 65 | # You can specify multiple suffix as a list of string: 66 | # 67 | # source_suffix = ['.rst', '.md'] 68 | source_suffix = '.rst' 69 | 70 | # The master toctree document. 71 | master_doc = 'index' 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | # 76 | # This is also used if you do content translation via gettext catalogs. 77 | # Usually you set "language" from the command line for these cases. 78 | language = None 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | # This pattern also affects html_static_path and html_extra_path . 83 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | 89 | # -- Options for HTML output ------------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | # 94 | # html_theme = 'alabaster' 95 | html_theme = "sphinx_rtd_theme" 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | # 101 | # html_theme_options = {} 102 | 103 | # Add any paths that contain custom static files (such as style sheets) here, 104 | # relative to this directory. They are copied after the builtin static files, 105 | # so a file named "default.css" will overwrite the builtin "default.css". 106 | html_static_path = ['_static'] 107 | 108 | # Custom sidebar templates, must be a dictionary that maps document names 109 | # to template names. 110 | # 111 | # The default sidebars (for documents that don't match any pattern) are 112 | # defined by theme itself. Builtin themes are using these templates by 113 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 114 | # 'searchbox.html']``. 115 | # 116 | # html_sidebars = {} 117 | 118 | 119 | # -- Options for HTMLHelp output --------------------------------------------- 120 | 121 | # Output file base name for HTML help builder. 122 | htmlhelp_basename = 'PyMuscledoc' 123 | 124 | 125 | # -- Options for LaTeX output ------------------------------------------------ 126 | 127 | latex_elements = { 128 | # The paper size ('letterpaper' or 'a4paper'). 129 | # 130 | # 'papersize': 'letterpaper', 131 | 132 | # The font size ('10pt', '11pt' or '12pt'). 133 | # 134 | # 'pointsize': '10pt', 135 | 136 | # Additional stuff for the LaTeX preamble. 137 | # 138 | # 'preamble': '', 139 | 140 | # Latex figure (float) alignment 141 | # 142 | # 'figure_align': 'htbp', 143 | } 144 | 145 | # Grouping the document tree into LaTeX files. List of tuples 146 | # (source start file, target name, title, 147 | # author, documentclass [howto, manual, or own class]). 148 | latex_documents = [ 149 | (master_doc, 'PyMuscle.tex', 'PyMuscle Documentation', 150 | 'Ian Danforth', 'manual'), 151 | ] 152 | 153 | 154 | # -- Options for manual page output ------------------------------------------ 155 | 156 | # One entry per manual page. List of tuples 157 | # (source start file, name, description, authors, manual section). 158 | man_pages = [ 159 | (master_doc, 'pymuscle', 'PyMuscle Documentation', 160 | [author], 1) 161 | ] 162 | 163 | 164 | # -- Options for Texinfo output ---------------------------------------------- 165 | 166 | # Grouping the document tree into Texinfo files. List of tuples 167 | # (source start file, target name, title, author, 168 | # dir menu entry, description, category) 169 | texinfo_documents = [ 170 | (master_doc, 'PyMuscle', 'PyMuscle Documentation', 171 | author, 'PyMuscle', 'One line description of project.', 172 | 'Miscellaneous'), 173 | ] 174 | 175 | 176 | # -- Extension configuration ------------------------------------------------- 177 | 178 | # -- Options for intersphinx extension --------------------------------------- 179 | 180 | # Example configuration for intersphinx: refer to the Python standard library. 181 | intersphinx_mapping = {'https://docs.python.org/': None} 182 | 183 | # -- Options for todo extension ---------------------------------------------- 184 | 185 | # If true, `todo` and `todoList` produce output, else they produce nothing. 186 | todo_include_todos = True -------------------------------------------------------------------------------- /examples/envs/pymunk_arm.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import gym 3 | import pygame 4 | import pymunk 5 | import pymunk.pygame_util 6 | import numpy as np 7 | from pygame.locals import (QUIT, KEYDOWN, K_ESCAPE) 8 | from pymuscle import StandardMuscle as Muscle 9 | 10 | 11 | class PymunkArmEnv(gym.Env): 12 | """ 13 | Single joint arm with opposing muscles physically simulated by Pymunk in 14 | a Pygame wrapper. 15 | """ 16 | 17 | def __init__(self, apply_fatigue=False): 18 | # Set up our 2D physics simulation 19 | self._init_sim() 20 | 21 | # Add a simulated arm consisting of: 22 | # - bones (rigid bodies) 23 | # - muscle bodies (damped spring constraints) 24 | self.brach, self.tricep = self._add_arm() 25 | 26 | # Instantiate the PyMuscles 27 | self.brach_muscle = Muscle( 28 | apply_peripheral_fatigue=apply_fatigue 29 | ) 30 | self.tricep_muscle = Muscle( 31 | apply_peripheral_fatigue=False # Tricep never gets tired in this env 32 | ) 33 | 34 | self.frames = 0 35 | 36 | def _init_sim(self): 37 | pygame.init() 38 | screen_width = screen_height = 600 39 | self.screen = pygame.display.set_mode((screen_width, screen_height)) 40 | pygame.display.set_caption("Curl Sim") 41 | self.draw_options = pymunk.pygame_util.DrawOptions(self.screen) 42 | self.draw_options.flags = 1 # Disable constraint drawing 43 | self.space = pymunk.Space() 44 | self.space.gravity = (0.0, -980.0) 45 | 46 | def _add_arm(self): 47 | config = { 48 | "arm_center": (self.screen.get_width() / 2, 49 | self.screen.get_height() / 2), 50 | "lower_arm_length": 170, 51 | "lower_arm_starting_angle": 15, 52 | "lower_arm_mass": 10, 53 | "brach_rest_length": 5, 54 | "brach_stiffness": 450, 55 | "brach_damping": 200, 56 | "tricep_rest_length": 30, 57 | "tricep_stiffness": 50, 58 | "tricep_damping": 400 59 | } 60 | 61 | # Upper Arm 62 | upper_arm_length = 200 63 | upper_arm_body = pymunk.Body(body_type=pymunk.Body.STATIC) 64 | upper_arm_body.position = config["arm_center"] 65 | upper_arm_body.angle = np.deg2rad(-45) 66 | upper_arm_line = pymunk.Segment(upper_arm_body, (0, 0), (-upper_arm_length, 0), 5) 67 | upper_arm_line.sensor = True # Disable collision 68 | 69 | self.space.add(upper_arm_body) 70 | self.space.add(upper_arm_line) 71 | 72 | # Lower Arm 73 | lower_arm_body = pymunk.Body(0, 0) # Pymunk will calculate moment based on mass of attached shape 74 | lower_arm_body.position = config["arm_center"] 75 | lower_arm_body.angle = np.deg2rad(config["lower_arm_starting_angle"]) 76 | elbow_extension_length = 20 77 | lower_arm_start = (-elbow_extension_length, 0) 78 | lower_arm_line = pymunk.Segment( 79 | lower_arm_body, 80 | lower_arm_start, 81 | (config["lower_arm_length"], 0), 82 | 5 83 | ) 84 | lower_arm_line.mass = config["lower_arm_mass"] 85 | lower_arm_line.friction = 1.0 86 | 87 | self.space.add(lower_arm_body) 88 | self.space.add(lower_arm_line) 89 | 90 | # Hand 91 | hand_width = hand_height = 15 92 | start_x = config["lower_arm_length"] 93 | start_y = 14 94 | self.hand_shape = pymunk.Circle( 95 | lower_arm_body, 96 | 20, 97 | (start_x, start_y) 98 | ) 99 | self.space.add(self.hand_shape) 100 | 101 | # Pivot (Elbow) 102 | elbow_body = pymunk.Body(body_type=pymunk.Body.STATIC) 103 | elbow_body.position = config["arm_center"] 104 | elbow_joint = pymunk.PivotJoint(elbow_body, lower_arm_body, config["arm_center"]) 105 | self.space.add(elbow_joint) 106 | 107 | # Spring (Brachialis Muscle) 108 | brach_spring = pymunk.constraint.DampedSpring( 109 | upper_arm_body, 110 | lower_arm_body, 111 | (-(upper_arm_length * (1 / 2)), 0), # Connect half way up the upper arm 112 | (config["lower_arm_length"] / 5, 0), # Connect near the bottom of the lower arm 113 | config["brach_rest_length"], 114 | config["brach_stiffness"], 115 | config["brach_damping"] 116 | ) 117 | self.space.add(brach_spring) 118 | 119 | # Spring (Tricep Muscle) 120 | tricep_spring = pymunk.constraint.DampedSpring( 121 | upper_arm_body, 122 | lower_arm_body, 123 | (-(upper_arm_length * (3 / 4)), 0), 124 | lower_arm_start, 125 | config["tricep_rest_length"], 126 | config["tricep_stiffness"], 127 | config["tricep_damping"] 128 | ) 129 | self.space.add(tricep_spring) 130 | 131 | # Elbow stop (prevent under/over extension) 132 | elbow_stop_point = pymunk.Circle( 133 | upper_arm_body, 134 | radius=5, 135 | offset=(-elbow_extension_length, -3) 136 | ) 137 | elbow_stop_point.friction = 1.0 138 | self.space.add(elbow_stop_point) 139 | 140 | return brach_spring, tricep_spring 141 | 142 | @staticmethod 143 | def _handle_keys(): 144 | for event in pygame.event.get(): 145 | if event.type == QUIT: 146 | sys.exit(0) 147 | elif event.type == KEYDOWN and event.key == K_ESCAPE: 148 | sys.exit(0) 149 | 150 | def step(self, input_array, step_size, debug=True): 151 | # Check for user input 152 | self._handle_keys() 153 | 154 | # Scale input to match the expected range of the muscle sim 155 | input_array = np.array(input_array) 156 | 157 | if debug: 158 | print(input_array) 159 | 160 | # Advance the simulation 161 | self.space.step(step_size) 162 | self.frames += 1 163 | 164 | # Advance muscle sim and sync with physics sim 165 | brach_output = self.brach_muscle.step(input_array[0], step_size) 166 | tricep_output = self.tricep_muscle.step(input_array[1], step_size) 167 | 168 | gain = 500 169 | self.brach.stiffness = brach_output * gain 170 | self.tricep.stiffness = tricep_output * gain 171 | 172 | if debug: 173 | print("Brach Total Output: ", brach_output) 174 | print("Tricep Total Output: ", tricep_output) 175 | print("Brach Stiffness: ", self.brach.stiffness) 176 | print("Tricep Stiffness: ", self.tricep.stiffness) 177 | 178 | hand_location = self.hand_shape.body.local_to_world((170, 0)) 179 | return hand_location 180 | 181 | def render(self, debug=True): 182 | if debug and (self.draw_options.flags is not 3): 183 | self.draw_options.flags = 3 # Enable constraint drawing 184 | 185 | self.screen.fill((255, 255, 255)) 186 | self.space.debug_draw(self.draw_options) 187 | pygame.display.flip() 188 | 189 | def reset(self): 190 | if self.space: 191 | del self.space 192 | self._init_sim() 193 | -------------------------------------------------------------------------------- /docs/src/readme-template.md: -------------------------------------------------------------------------------- 1 | ### *** Warning: PyMuscle is early stage and under active development. *** 2 | 3 | # PyMuscle 4 | [![Build Status](https://travis-ci.org/iandanforth/pymuscle.svg?branch=master)](https://travis-ci.org/iandanforth/pymuscle) 5 | 6 | PyMuscle provides a motor unit based model of skeletal muscle. It simulates the 7 | relationship between excitatory input and motor-unit output as well as fatigue 8 | over time. 9 | 10 | It is compatible with [OpenAI Gym](https://gym.openai.com) environments and is 11 | intended to be useful for researchers in the machine learning community. 12 | 13 | PyMuscle can be used to enhance the realism of motor control for simulated 14 | agents. To get you started we provide a [toy example project](https://github.com/iandanforth/pymuscle/tree/master/examples) 15 | which uses PyMuscle in a simulation of arm curl and extension. 16 | 17 | Out of the box we provide a motor neuron pool model and a muscle fiber model 18 | based on "A motor unit-based model of muscle fatigue" 19 | ([Potvin and Fuglevand, 2017](http://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1005581)). 20 | If you use this library as part of your research please cite that paper. 21 | 22 | We hope to extend this model and support alternative models in the future. 23 | 24 | ## More about PyMuscle 25 | 26 | Motor control in biological creatures is complex. PyMuscle allows you to capture 27 | some of that complexity while remaining [performant](#performance). It provides 28 | greater detail than sending torque values to simulated motors-as-joints but 29 | less detail (and computational cost) than a full biochemical model. 30 | 31 | PyMuscle is not tied to a particular physics library and can be used with a 32 | variety of muscle body simulations. PyMuscle focuses on the relationship between 33 | control signals (excitatory inputs to motor neurons) and per-motor-unit output. 34 | 35 | Motor unit output is dimensionless but can be interpreted as force. It can also 36 | be used as a proxy for the contractile state of muscle bodies in the physics 37 | sim of your choice. 38 | 39 | # Background 40 | 41 | ## Motor Units 42 |

43 | 44 | 45 | A motor unit is the combination of a motor neuron and the muscle fibers to which 46 | the neuron makes connections. Skeletal muscles are made up of many muscle fibers. 47 | For a given motor unit a single motor neuron will have an axon that branches 48 | and innervates a subset of the fibers in a muscle. Muscle fibers usually 49 | belong to only one motor unit. 50 | 51 | Muscles may have anywhere from a few dozen to thousands of motor units. The 52 | human arm, for example, has 30 some muscles and is innervated by [approximately 35,000 axons](https://onlinelibrary.wiley.com/doi/abs/10.1002/ana.25018) 53 | from motor neurons. 54 | 55 | The brain controls muscles by sending signals to motor units and receiving 56 | signals from mechanoreceptors embedded in muscles and the skin. In animals all 57 | the motor units an animal will ever have are present from birth and learning to 58 | produce smooth coordinated motion through control of those units is a significant 59 | part of the developmental process. 60 | 61 | ## Control 62 | 63 | Motor units are recruited in an orderly fashion to produce varying levels of 64 | muscle force. 65 | 66 | The cell bodies of motor neurons for a given muscle cluster together in the 67 | spinal cord in what are known as motor neuron pools, columns, or nuclei. 68 | Generally motor neurons in a pool can be thought of as all getting the same 69 | activation inputs. This input is the combination of dozens if not hundreds of 70 | separate inputs from interneurons and upper motor neurons carrying signals from 71 | the brain and mechanoreceptors in the body. 72 | 73 | In a voluntary contraction of a muscle, say in curling your arm, the input 74 | to the motor neuron pool for the bicep muscle will ramp up, recruiting more 75 | and more motor units, starting from the weakest motor units to stronger ones. 76 | 77 | Over time motor neurons and muscle fibers can't produce the same level of force 78 | for the same level of activation input. This is called fatigue. The brain must 79 | compensate for the fatigue if it wants to maintain a given force or perform 80 | the same action again and again in the same way. 81 | 82 | # Installation 83 | 84 | ## Requirements 85 | 86 | Python 3.6+ 87 | 88 | ## Install 89 | 90 | ``` 91 | pip install pymuscle 92 | ``` 93 | 94 | # Getting Started 95 | 96 | ### Minimal example 97 | 98 | The Muscle class provides the primary API for the library. A Muscle can be 99 | heavily customized but here we use mainly default values. A PotvinMuscle 100 | instantiated with 120 motor units has the distribution of strengths, recruitment 101 | thresholds, and fatigue properties as used in the experiments of Potvin and 102 | Fuglevand, 2017. 103 | 104 | ```python 105 | {{'../../examples/minimal-example.py'}} 106 | ``` 107 | 108 | This will open a browser window with the produced chart. It should look like this: 109 | 110 |

111 | 112 | ### Familiar with OpenAI's Gym? 113 | 114 | Make sure you have the following installed 115 | 116 | ``` 117 | pip install gym pygame pymunk 118 | ``` 119 | 120 | then try out the [example project](https://github.com/iandanforth/pymuscle/tree/master/examples) 121 | 122 | # Versioning Plan 123 | 124 | PyMuscle is in a pre-alpha state. Expect regular breaking changes. 125 | 126 | We expect to stabilize the API for 1.0 and introduce breaking changes only 127 | during major releases. 128 | 129 | This library tries to provide empirically plausible behavior. As new research is 130 | released or uncovered we will update the underlying model. Non-bug-fix changes 131 | that would alter the output of the library will be integrated in major releases. 132 | 133 | If you know of results you believe should be integrated please let us know. See 134 | the [Contributing](#contributing) section. 135 | 136 | # Contributing 137 | 138 | We encourage you to contribute! Specifically we'd love to hear about and feature 139 | projects using PyMuscle. 140 | 141 | For all issues please search the [existing issues](https://github.com/iandanforth/pymuscle/issues) before submitting. 142 | 143 | - [Bug Reports](https://github.com/iandanforth/pymuscle/issues/new?template=bug_report.md) 144 | - [Enhancement requests](https://github.com/iandanforth/pymuscle/issues/new?template=feature_request.md) 145 | - [Suggest research](https://github.com/iandanforth/pymuscle/issues/new?template=research-submission.md) that can better inform the model 146 | 147 | _Before_ opening a pull request please: 148 | 149 | - See if there is an open ticket for this issue 150 | - If the ticket is tagged 'Help Needed' comment to note that you intend to work on this issue 151 | - If the ticket is *not* tagged, comment that you would like to work on the issue 152 | - We will then discuss the priority, timing and expectations around the issue. 153 | - If there is no open ticket, create one 154 | - We prefer to discuss the implications of changes before you write code! 155 | 156 | 157 | ## Development 158 | 159 | If you want to help develop the PyMuscle library itself the following may help. 160 | 161 | Clone this repository 162 | 163 | ``` 164 | git clone git@github.com:iandanforth/pymuscle.git 165 | cd pymuscle 166 | ``` 167 | 168 | Install [pipenv](https://docs.pipenv.org/). (The modern combination of pip and 169 | virtual environments.) 170 | 171 | ``` 172 | pip install pipenv 173 | ``` 174 | 175 | If this throws a permissions error you will need to to run this with 'sudo' 176 | 177 | ``` 178 | sudo pip install pipenv 179 | ``` 180 | 181 | Install dependencies and start a clean python environment 182 | 183 | ``` 184 | pipenv install 185 | pipenv shell 186 | ``` 187 | 188 | To exit this python environment 189 | 190 | ``` 191 | exit 192 | ``` 193 | 194 | or close your terminal and start a new one. 195 | 196 | # Performance 197 | 198 | PyMuscle aims to be fast. We use Numpy to get fast vector computation. PyMuscle 199 | uses only a single process today but may be extended to multi-process in 200 | the future and to GPUs through the integration of [PyTorch](https://pytorch.org/). 201 | 202 | # Limitations 203 | 204 | ## Scope 205 | 206 | PyMuscle is concerned with inputs to motor unit neurons, the outputs of those 207 | motor units, and the changes to that system over time. It does not model the 208 | dynamics of the muscle body itself or the impact of dynamic motion on this 209 | motor unit input/output relationship. 210 | 211 | ## Recovery 212 | 213 | Potvin and Fuglevand 2017 explicitly models fatigue but *not* recovery. We 214 | eagerly await the updated model from Potvin which will included a model of 215 | recovery. 216 | 217 | ## Proprioception 218 | 219 | This library does not directly provide any feedback signals for control. The 220 | example project shows how to integrate PyMuscle with a physics simulation to 221 | get simulated output forces and stretch and strain values derived from the 222 | state of the simulated muscle body. (In the example this is a damped spring 223 | but a Hill-type, or more complex model could also be used.) 224 | 225 | Fatigue could be used as a feedback signal but this will need to be calculated 226 | from the states of the motor units. 227 | -------------------------------------------------------------------------------- /pymuscle/muscle.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains base Muscle class and its immediate descendants. 3 | """ 4 | 5 | import numpy as np 6 | from typing import Union 7 | 8 | from .potvin_fuglevand_2017_muscle_fibers import PotvinFuglevand2017MuscleFibers 9 | from .potvin_fuglevand_2017_motor_neuron_pool import PotvinFuglevand2017MotorNeuronPool 10 | from .pymuscle_fibers import PyMuscleFibers 11 | from .model import Model 12 | 13 | 14 | class Muscle(object): 15 | """ 16 | A user-created :class:`Muscle ` object. 17 | 18 | Used to simulate the input-output relationship between motor neuron 19 | excitation and muscle fibers contractile state over time. 20 | 21 | :param motor_neuron_pool_model: 22 | The motor neuron pool implementation to use with this muscle. 23 | :param muscle_fibers_model: 24 | The muscle fibers model implementation to use with this muscle. 25 | :param motor_unit_count: How many motor units comprise this muscle. 26 | 27 | Usage:: 28 | 29 | from pymuscle import (Muscle, 30 | PotvinFuglevand2017MotorNeuronPool as Pool, 31 | PotvinFuglevand2017MuscleFibers as Fibers) 32 | 33 | motor_unit_count = 60 34 | muscle = Muscle( 35 | Pool(motor_unit_count), 36 | Fibers(motor_unit_count), 37 | ) 38 | excitation = 32.0 39 | force = muscle.step(excitation, 1 / 50.0) 40 | """ 41 | 42 | def __init__( 43 | self, 44 | motor_neuron_pool_model: Model, 45 | muscle_fibers_model: Model, 46 | ): 47 | assert motor_neuron_pool_model.motor_unit_count == \ 48 | muscle_fibers_model.motor_unit_count 49 | 50 | self._pool = motor_neuron_pool_model 51 | self._fibers = muscle_fibers_model 52 | 53 | @property 54 | def motor_unit_count(self): 55 | return self._pool.motor_unit_count 56 | 57 | @property 58 | def max_excitation(self): 59 | return self._pool.max_excitation 60 | 61 | @property 62 | def current_forces(self): 63 | return self._fibers.current_forces 64 | 65 | def step( 66 | self, 67 | motor_pool_input: Union[int, float, np.ndarray], 68 | step_size: float 69 | ) -> float: 70 | """ 71 | Advances the muscle model one step. 72 | 73 | :param motor_pool_input: 74 | Either a single value or an array of values representing the 75 | excitatory input to the motor neuron pool for this muscle 76 | :param step_size: 77 | How far to advance the simulation in time for this step. 78 | """ 79 | 80 | # Expand a single input to the muscle to a full array 81 | if isinstance(motor_pool_input, float) or \ 82 | isinstance(motor_pool_input, int): 83 | motor_pool_input = np.full( 84 | self._pool.motor_unit_count, 85 | motor_pool_input 86 | ) 87 | 88 | # Ensure we're really passing an ndarray to _pool.step() 89 | input_as_array = np.array(motor_pool_input) 90 | 91 | motor_pool_output = self._pool.step(input_as_array, step_size) 92 | return self._fibers.step(motor_pool_output, step_size) 93 | 94 | 95 | class PotvinFuglevandMuscle(Muscle): 96 | """ 97 | A thin wrapper around :class:`Muscle ` which pre-selects the 98 | Potvin fiber and motor neuron models. 99 | """ 100 | 101 | def __init__( 102 | self, 103 | motor_unit_count: int, 104 | apply_central_fatigue: bool = True, 105 | apply_peripheral_fatigue: bool = True 106 | ): 107 | pool = PotvinFuglevand2017MotorNeuronPool( 108 | motor_unit_count, 109 | apply_fatigue=apply_central_fatigue 110 | ) 111 | fibers = PotvinFuglevand2017MuscleFibers( 112 | motor_unit_count, 113 | apply_fatigue=apply_peripheral_fatigue 114 | ) 115 | 116 | super().__init__( 117 | motor_neuron_pool_model=pool, 118 | muscle_fibers_model=fibers 119 | ) 120 | 121 | 122 | class StandardMuscle(Muscle): 123 | """ 124 | A wrapper around :class:`Muscle ` which pre-selects the 125 | Potvin motor neuron model and the PyMuscle specific fiber model. 126 | 127 | In addition this class implements an API (through the primary step() 128 | method) where inputs to and outputs from the muscle are in the range 129 | 0.0 to 1.0. 130 | 131 | The API for this class is oriented toward use along side physics 132 | simulations. 133 | 134 | This muscle does *not* include central (motor neuron) fatigue as the 135 | equations for recovery are not yet available. 136 | 137 | This muscle does include both *peripheral* fatigue and recovery. 138 | 139 | :param max_force: Maximum voluntary isometric force this muscle can produce 140 | when fully rested and at maximum excitation. (Newtons) 141 | This along with the force_conversion_factor will determine the number 142 | of simulated motor units. 143 | :param force_conversion_factor: This library uses biological defaults to 144 | convert the desired max_force into a number of motor units which 145 | make up a muscle. This can result in a large number of motor units 146 | which may be slow. To improve performance (but diverge from biology) 147 | you can change this force conversion factor. 148 | 149 | Note: It is likely the default value here will change with major 150 | versions as better biological data is found. 151 | """ 152 | def __init__( 153 | self, 154 | max_force: float = 32.0, 155 | force_conversion_factor: float = 0.0123, 156 | apply_central_fatigue: bool = False, 157 | apply_peripheral_fatigue: bool = True 158 | ): 159 | 160 | # Maximum voluntary isometric force this muscle will be able to produce 161 | self.max_force = max_force 162 | 163 | # Ratio of newtons (N) to internal arbitrary force units 164 | self.force_conversion_factor = force_conversion_factor 165 | 166 | motor_unit_count = self.force_to_motor_unit_count( 167 | self.max_force, 168 | self.force_conversion_factor, 169 | ) 170 | 171 | pool = PotvinFuglevand2017MotorNeuronPool( 172 | motor_unit_count, 173 | apply_fatigue=apply_central_fatigue 174 | ) 175 | fibers = PyMuscleFibers( 176 | motor_unit_count, 177 | force_conversion_factor=force_conversion_factor, 178 | apply_fatigue=apply_peripheral_fatigue 179 | ) 180 | 181 | super().__init__( 182 | motor_neuron_pool_model=pool, 183 | muscle_fibers_model=fibers 184 | ) 185 | 186 | # Max output in arbitrary units 187 | self.max_arb_output = sum(self._fibers._peak_twitch_forces) 188 | 189 | @staticmethod 190 | def force_to_motor_unit_count( 191 | max_force: float, 192 | conversion_factor: float 193 | ) -> int: 194 | # This takes the relationship between force production 195 | # and number of motor units from Fuglevand 93 and solves 196 | # for motor units given desired force. 197 | 198 | # The reference muscle is the first dorsal interossei 199 | # https://en.wikipedia.org/wiki/Dorsal_interossei_of_the_hand 200 | 201 | # The number of motor units for the FDI was estimated by 202 | # Feinstein et al. (1955) at 119. 203 | # https://www.ncbi.nlm.nih.gov/pubmed/14349537 204 | # 205 | # Caveats: 206 | # - This estimate was from 1 dissection of an adult male 207 | # - This esimate counted 'large nerve fibers' and then assumed 208 | # 40% of them would be afferent. 209 | 210 | # The Fuglevand experiments use 120 motor units. The peak twitch 211 | # forces are calculated in the range of 0-100 arbitrary force units 212 | # with each motor unit being assigned a peak twitch force according 213 | # to the exponential function outlined in Fuglevand 93. 214 | # 215 | # The total possible force units this muscle could produce, after 216 | # assignment of peak twitch forces, is the sum of those peak twitch 217 | # forces or ~2609.03 arbitrary units. 218 | 219 | # The maximum voluntary force (MVC) for the FDI was taken from 220 | # Jahanmir-Zezhad et al. 221 | # https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5450824/ 222 | # MVC = 32 Newtons (N) 223 | 224 | # Thus if a modeled FDI with 120 motor units produces an MVC of 225 | # 2,216 units and a real FDI produces an MVC of 32N we can 226 | # relate them as follows 227 | # 228 | # conversion_factor = 32N / 2609.03 units = 0.01226509469 N/unit 229 | 230 | # See: https://www.desmos.com/calculator/b9xzsaqs1g 231 | # m = max_force 232 | # c = conversion_factor 233 | # u = motor unit count 234 | # u = 1 / ln( ((1-m/c) / (e^4.6 - m/c)))^(1/4.6) ) 235 | r = (max_force / conversion_factor) 236 | n2 = 1 - r 237 | d2 = np.exp(4.6) - r 238 | inner = np.power((n2 / d2), (1 / 4.6)) 239 | d = np.log(inner) # This is natural log by default (ln) 240 | muf = 1 / d 241 | muc = int(np.ceil(muf)) 242 | 243 | return muc 244 | 245 | def get_central_fatigue(self): 246 | raise NotImplementedError 247 | 248 | def get_peripheral_fatigue(self) -> float: 249 | """ 250 | Returns fatigue level in the range 0.0 to 1.0 where: 251 | 252 | 0.0 - Completely rested 253 | 1.0 - Completely fatigued 254 | """ 255 | fatigue = 1 - sum(self._fibers.current_peak_forces) / self.max_arb_output 256 | return fatigue 257 | 258 | def step( 259 | self, 260 | motor_pool_input: Union[int, float, np.ndarray], 261 | step_size: float 262 | ) -> float: 263 | """ 264 | Advances the muscle model one step. 265 | 266 | :param motor_pool_input: 267 | Either a single value or an array of values representing the 268 | excitatory input to the motor neuron pool for this muscle. 269 | Range is 0.0 - 1.0. 270 | :param step_size: 271 | How far to advance the simulation in time for this step. 272 | """ 273 | 274 | # Rescale the input to the underlying range for the motor pool 275 | motor_pool_input *= self.max_excitation 276 | arb_output = super().step(motor_pool_input, step_size) 277 | # Rescale the output such that it is in the range 0.0 - 1.0 278 | scaled_output = arb_output / self.max_arb_output 279 | return scaled_output 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyMuscle 2 | [![Build Status](https://travis-ci.org/iandanforth/pymuscle.svg?branch=master)](https://travis-ci.org/iandanforth/pymuscle) 3 | 4 | PyMuscle provides a motor unit based model of skeletal muscle. It simulates the 5 | relationship between excitatory input and motor-unit output as well as fatigue 6 | over time. 7 | 8 | It is compatible with [OpenAI Gym](https://gym.openai.com) environments and is 9 | intended to be useful for researchers in the machine learning community. 10 | 11 | PyMuscle can be used to enhance the realism of motor control for simulated 12 | agents. To get you started we provide a [toy example project](https://github.com/iandanforth/pymuscle/tree/master/examples) 13 | which uses PyMuscle in a simulation of arm curl and extension. 14 | 15 | Out of the box we provide a motor neuron pool model and a muscle fiber model 16 | based on "A motor unit-based model of muscle fatigue" 17 | ([Potvin and Fuglevand, 2017](http://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1005581)). 18 | If you use this library as part of your research please cite that paper. 19 | 20 | We hope to extend this model and support alternative models in the future. 21 | 22 | ## More about PyMuscle 23 | 24 | Motor control in biological creatures is complex. PyMuscle allows you to capture 25 | some of that complexity while remaining [performant](#performance). It provides 26 | greater detail than sending torque values to simulated motors-as-joints but 27 | less detail (and computational cost) than a full biochemical model. 28 | 29 | PyMuscle is not tied to a particular physics library and can be used with a 30 | variety of muscle body simulations. PyMuscle focuses on the relationship between 31 | control signals (excitatory inputs to motor neurons) and per-motor-unit output. 32 | 33 | Motor unit output is dimensionless but can be interpreted as force. It can also 34 | be used as a proxy for the contractile state of muscle bodies in the physics 35 | sim of your choice. 36 | 37 | # Background 38 | 39 | ## Motor Units 40 |

41 | 42 | 43 | A motor unit is the combination of a motor neuron and the muscle fibers to which 44 | the neuron makes connections. Skeletal muscles are made up of many muscle fibers. 45 | For a given motor unit a single motor neuron will have an axon that branches 46 | and innervates a subset of the fibers in a muscle. Muscle fibers usually 47 | belong to only one motor unit. 48 | 49 | Muscles may have anywhere from a few dozen to thousands of motor units. The 50 | human arm, for example, has 30 some muscles and is innervated by [approximately 35,000 axons](https://onlinelibrary.wiley.com/doi/abs/10.1002/ana.25018) 51 | from motor neurons. 52 | 53 | The brain controls muscles by sending signals to motor units and receiving 54 | signals from mechanoreceptors embedded in muscles and the skin. In animals all 55 | the motor units an animal will ever have are present from birth and learning to 56 | produce smooth coordinated motion through control of those units is a significant 57 | part of the developmental process. 58 | 59 | ## Control 60 | 61 | Motor units are recruited in an orderly fashion to produce varying levels of 62 | muscle force. 63 | 64 | The cell bodies of motor neurons for a given muscle cluster together in the 65 | spinal cord in what are known as motor neuron pools, columns, or nuclei. 66 | Generally motor neurons in a pool can be thought of as all getting the same 67 | activation inputs. This input is the combination of dozens if not hundreds of 68 | separate inputs from interneurons and upper motor neurons carrying signals from 69 | the brain and mechanoreceptors in the body. 70 | 71 | In a voluntary contraction of a muscle, say in curling your arm, the input 72 | to the motor neuron pool for the bicep muscle will ramp up, recruiting more 73 | and more motor units, starting from the weakest motor units to stronger ones. 74 | 75 | Over time motor neurons and muscle fibers can't produce the same level of force 76 | for the same level of activation input. This is called fatigue. The brain must 77 | compensate for the fatigue if it wants to maintain a given force or perform 78 | the same action again and again in the same way. 79 | 80 | # Installation 81 | 82 | ## Requirements 83 | 84 | Python 3.6+ 85 | 86 | ## Install 87 | 88 | ``` 89 | pip install pymuscle 90 | ``` 91 | 92 | # Getting Started 93 | 94 | #### Not a Machine Learning researcher? Please see [Getting Started for Physiologists](physio-readme.md) 95 | 96 | ### Minimal example 97 | 98 | The Muscle class provides the primary API for the library. A Muscle can be 99 | heavily customized but here we use mainly default values. A PotvinMuscle 100 | instantiated with 120 motor units has the distribution of strengths, recruitment 101 | thresholds, and fatigue properties as used in the experiments of Potvin and 102 | Fuglevand, 2017. 103 | 104 | ```python 105 | from pymuscle import StandardMuscle as Muscle 106 | from pymuscle.vis import PotvinChart 107 | 108 | muscle = Muscle() 109 | 110 | # Set up the simulation parameters 111 | sim_duration = 200 # seconds 112 | frames_per_second = 50 113 | step_size = 1 / frames_per_second 114 | total_steps = int(sim_duration / step_size) 115 | 116 | # Use a constant level of excitation to more easily observe fatigue 117 | excitation = 0.6 # Range is 0.0 to 1.0 118 | 119 | total_outputs = [] 120 | outputs_by_unit = [] 121 | print("Starting simulation ...") 122 | for i in range(total_steps): 123 | # Calling step() updates the simulation and returns the total output 124 | # produced by the muscle during this step for the given excitation level. 125 | total_output = muscle.step(excitation, step_size) 126 | total_outputs.append(total_output) 127 | # You can also introspect the muscle to see the forces being produced 128 | # by each motor unit. 129 | output_by_unit = muscle.current_forces 130 | outputs_by_unit.append(output_by_unit) 131 | if (i % (frames_per_second * 10)) == 0: 132 | print("Sim time - {} seconds ...".format(int(i / frames_per_second))) 133 | 134 | # Visualize the behavior of the motor units over time 135 | print("Creating chart ...") 136 | chart = PotvinChart( 137 | outputs_by_unit, 138 | step_size 139 | ) 140 | # Things to note in the chart: 141 | # - Some motor units (purple) are never recruited at this level of excitation 142 | # - Some motor units become completely fatigued in this short time 143 | # - Some motor units stabilize and decrease their rate of fatigue 144 | # - Forces from the weakest motor units are almost constant the entire time 145 | chart.display() 146 | 147 | ``` 148 | 149 | This will open a browser window with the produced chart. It should look like this: 150 | 151 |

152 | 153 | ### Familiar with OpenAI's Gym? 154 | 155 | Make sure you have the following installed 156 | 157 | ``` 158 | pip install gym pygame pymunk 159 | ``` 160 | 161 | then try out the [example project](https://github.com/iandanforth/pymuscle/tree/master/examples) 162 | 163 | # Versioning Plan 164 | 165 | PyMuscle is in an alpha state. Expect regular breaking changes. 166 | 167 | We expect to stabilize the API for 1.0 and introduce breaking changes only 168 | during major releases. 169 | 170 | This library tries to provide empirically plausible behavior. As new research is 171 | released or uncovered we will update the underlying model. Non-bug-fix changes 172 | that would alter the output of the library will be integrated in major releases. 173 | 174 | If you know of results you believe should be integrated please let us know. See 175 | the [Contributing](#contributing) section. 176 | 177 | # Contributing 178 | 179 | We encourage you to contribute! Specifically we'd love to hear about and feature 180 | projects using PyMuscle. 181 | 182 | For all issues please search the [existing issues](https://github.com/iandanforth/pymuscle/issues) before submitting. 183 | 184 | - [Bug Reports](https://github.com/iandanforth/pymuscle/issues/new?template=bug_report.md) 185 | - [Enhancement requests](https://github.com/iandanforth/pymuscle/issues/new?template=feature_request.md) 186 | - [Suggest research](https://github.com/iandanforth/pymuscle/issues/new?template=research-submission.md) that can better inform the model 187 | 188 | _Before_ opening a pull request please: 189 | 190 | - See if there is an open ticket for this issue 191 | - If the ticket is tagged 'Help Needed' comment to note that you intend to work on this issue 192 | - If the ticket is *not* tagged, comment that you would like to work on the issue 193 | - We will then discuss the priority, timing and expectations around the issue. 194 | - If there is no open ticket, create one 195 | - We prefer to discuss the implications of changes before you write code! 196 | 197 | 198 | ## Development 199 | 200 | If you want to help develop the PyMuscle library itself the following may help. 201 | 202 | Clone this repository 203 | 204 | ``` 205 | git clone git@github.com:iandanforth/pymuscle.git 206 | cd pymuscle 207 | pip install -r requirements-dev.txt 208 | python setup.py develop 209 | pytest 210 | ``` 211 | 212 | # Performance 213 | 214 | PyMuscle aims to be fast. We use Numpy to get fast vector computation. If you 215 | find that some part of the library is not fast enough for your use case please 216 | [open a ticket](https://github.com/iandanforth/pymuscle/issues) and let us know. 217 | 218 | # Limitations 219 | 220 | ## Scope 221 | 222 | PyMuscle is concerned with inputs to motor unit neurons, the outputs of those 223 | motor units, and the changes to that system over time. It does not model the 224 | dynamics of the muscle body itself or the impact of dynamic motion on this 225 | motor unit input/output relationship. 226 | 227 | ## Recovery 228 | 229 | Potvin and Fuglevand 2017 explicitly models fatigue but *not* recovery. We 230 | eagerly await the updated model from Potvin which will included a model of 231 | recovery. 232 | 233 | Until then the `StandardMuscle` class, which builds on the Potvin and Fuglevand 234 | base classes, implements peripheral (muscle fiber) recovery as this is a 235 | relatively simple process but disables central (motor unit fatigue). 236 | 237 | ## Proprioception 238 | 239 | Instances of `StandardMuscle` implement a `get_peripheral_fatigue()` method 240 | to allow users to track fatigue state of each muscle. Other than that this 241 | library does not directly provide any feedback signals for control. The 242 | example projects show how to integrate PyMuscle with a physics simulation to 243 | get simulated output forces and stretch and strain values derived from the 244 | state of the simulated muscle body. (In the example this is a damped spring 245 | but a Hill-type, or more complex model could also be used.) 246 | -------------------------------------------------------------------------------- /pymuscle/potvin_fuglevand_2017_motor_neuron_pool.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numpy import ndarray 3 | from typing import Dict, Any 4 | 5 | from .model import Model 6 | 7 | 8 | class PotvinFuglevand2017MotorNeuronPool(Model): 9 | """ 10 | Encapsulates the motor neuron portion of the motor unit model. 11 | 12 | The name of each parameter as it appears in Potvin, 2017 is in parentheses. 13 | If a parameter does not appear in the paper but does appear in the 14 | accompanying Matlab code, the variable name from the Matlab code is used in 15 | the parentheses. 16 | 17 | :param motor_unit_count: Number of motor units in the muscle (n) 18 | :param max_recruitment_threshold: 19 | Max excitation required by a motor unit within the pool before 20 | firing (RR) 21 | :param firing_gain: 22 | The slope of firing rate by excitation above threshold (g) 23 | :param min_firing_rate: 24 | The minimum firing rate for a motor neuron above threshold (minR) 25 | :param max_firing_rate_first_unit: 26 | Max firing rate for the first motor unit (maxR(1)) 27 | :param max_firing_rate_last_unit: 28 | Max firing rate for the last motor unit (maxR(last)) 29 | :param derecruitment_delta: 30 | Absolute minimum firing rate = min_firing_rate - derecruitment_delta 31 | (d) 32 | :param adaptation_magnitude: 33 | Magnitude of adaptation for different levels of excitation.(phi) 34 | :param adaptation_time_constant: 35 | Time constant for motor neuron adaptation (tau). Default based on 36 | Revill & Fuglevand (2011) 37 | :param max_duration: 38 | Longest duration of motor unit activity that will be recorded. The 39 | default value should be >> than the time it takes to fatigue all 40 | fibers. Helps prevent unbounded values. 41 | :apply_fatigue: Whether to calculate and apply central fatigue. 42 | 43 | Usage:: 44 | 45 | from pymuscle import PotvinFuglevand2017MotorNeuronPool as Pool 46 | 47 | motor_unit_count = 60 48 | pool = Pool(motor_unit_count) 49 | excitation = np.full(motor_unit_count, 10.0) 50 | step_size = 1 / 50.0 51 | firing_rates = pool.step(excitation, step_size) 52 | """ 53 | def __init__( 54 | self, 55 | motor_unit_count: int, 56 | max_recruitment_threshold: int = 50, 57 | firing_gain: float = 1.0, 58 | min_firing_rate: int = 8, 59 | max_firing_rate_first_unit: int = 35, 60 | max_firing_rate_last_unit: int = 25, 61 | derecruitment_delta: int = 2, 62 | adaptation_magnitude: float = 0.67, 63 | adaptation_time_constant: float = 22.0, 64 | max_duration: float = 20000.0, 65 | apply_fatigue: bool = True 66 | ): 67 | self._recruitment_thresholds = self._calc_recruitment_thresholds( 68 | motor_unit_count, 69 | max_recruitment_threshold 70 | ) 71 | 72 | self._peak_firing_rates = self._calc_peak_firing_rates( 73 | max_firing_rate_first_unit, 74 | max_firing_rate_last_unit, 75 | max_recruitment_threshold, 76 | self._recruitment_thresholds 77 | ) 78 | 79 | self._recruitment_durations = np.zeros(motor_unit_count) 80 | 81 | # Assign additional non-public attributes 82 | self._max_recruitment_threshold = max_recruitment_threshold 83 | self._firing_gain = firing_gain 84 | self._min_firing_rate = min_firing_rate 85 | self._derecruitment_delta = derecruitment_delta 86 | self._adaptation_magnitude = adaptation_magnitude 87 | self._adaptation_time_constant = adaptation_time_constant 88 | self._max_duration = max_duration 89 | self._apply_fatigue = apply_fatigue 90 | 91 | # Assign public attributes 92 | self.motor_unit_count = motor_unit_count 93 | 94 | # Calculate the excitation required to bring the pool to 95 | # maximum firing. 96 | m_e = self._max_recruitment_threshold \ 97 | + (max_firing_rate_last_unit - self._min_firing_rate) \ 98 | / self._firing_gain 99 | self.max_excitation = m_e 100 | 101 | # Pre-calculate firing rates for all motor neurons across a range of 102 | # possible excitation levels. 103 | self._firing_rates_by_excitation: Dict[float, Any] = {} 104 | 105 | def _calc_adapted_firing_rates( 106 | self, 107 | excitations: ndarray, 108 | step_size: float 109 | ) -> ndarray: 110 | """ 111 | Calculate the firing rate for the given excitation including motor 112 | neuron fatigue (adaptation). 113 | 114 | :param excitations: 115 | Array of excitation levels to use as input to motor neurons. 116 | :param step_size: How far to advance time in this step. 117 | 118 | """ 119 | firing_rates = self._calc_firing_rates(excitations) 120 | adaptations = self._calc_adaptations(firing_rates) 121 | adapted_firing_rates = firing_rates - adaptations 122 | 123 | # Apply fatigue as a last step 124 | self._update_recruitment_durations(firing_rates, step_size) 125 | 126 | return adapted_firing_rates 127 | 128 | def _calc_firing_rates(self, excitations: ndarray) -> ndarray: 129 | """ 130 | Calculates firing rates on a per motor neuron basis for the given 131 | array of excitations. 132 | 133 | :param excitations: 134 | Array of excitation levels to use as input to motor neurons. 135 | """ 136 | if self._firing_rates_by_excitation: 137 | excitation = excitations[0] # TODO - Support variations 138 | firing_rates = self._firing_rates_by_excitation[excitation] 139 | else: 140 | firing_rates = self._inner_calc_firing_rates( 141 | excitations, 142 | self._recruitment_thresholds, 143 | self._firing_gain, 144 | self._min_firing_rate, 145 | self._peak_firing_rates 146 | ) 147 | 148 | return firing_rates 149 | 150 | @staticmethod 151 | def _inner_calc_firing_rates( 152 | excitations: ndarray, 153 | thresholds: ndarray, 154 | gain: float, 155 | min_firing_rate: int, 156 | peak_firing_rates: ndarray 157 | ) -> ndarray: 158 | """ 159 | Pure function to do actual calculation of firing rates. 160 | 161 | :param excitations: 162 | Array of excitation levels to use as input to motor neurons. 163 | :param thresholds: 164 | Array of minimum firing rates required for motor neuron activity 165 | :param gain: How firing rates scale with excitations 166 | :param peak_firing_rates: Maximum allowed firing rates per neuron 167 | """ 168 | 169 | firing_rates = excitations - thresholds 170 | firing_rates += min_firing_rate 171 | below_thresh_indices = firing_rates < min_firing_rate 172 | firing_rates[below_thresh_indices] = 0 173 | firing_rates *= gain 174 | 175 | # Check for max values 176 | above_peak_indices = firing_rates > peak_firing_rates 177 | firing_rates[above_peak_indices] = peak_firing_rates[above_peak_indices] 178 | 179 | return firing_rates 180 | 181 | def _update_recruitment_durations( 182 | self, 183 | firing_rates: ndarray, 184 | step_size: float 185 | ) -> None: 186 | """ 187 | Increment the on duration for each on motor unit by step_size 188 | 189 | :param firing_rates: Array of activities for each motor neuron. 190 | :param step_size: How far to advance time in this step. 191 | """ 192 | # If we don't update durations, no fatigue will occur. 193 | if not self._apply_fatigue: 194 | return 195 | 196 | on = firing_rates > 0 197 | self._recruitment_durations[on] += step_size 198 | # TODO: Enable as a recovery mechanism 199 | # off = firing_rates = 0 200 | # self._recruitment_durations[off] -= 0 201 | # Prevent overflows 202 | over = self._recruitment_durations > self._max_duration 203 | self._recruitment_durations[over] = self._max_duration 204 | # Can't be less than zero 205 | under = self._recruitment_durations < 0 206 | self._recruitment_durations[under] = 0 207 | 208 | def _calc_adaptations(self, firing_rates: ndarray) -> ndarray: 209 | """ 210 | Calculate the adaptation rates for each neuron based on current 211 | activity levels. Applies central fatigue. 212 | 213 | :param firing_rates: Array of activities for each motor neuron. 214 | """ 215 | adapt_curve = self._calc_adaptations_curve(firing_rates) 216 | # From Eq. (12) 217 | exponent = -1 * (self._recruitment_durations / self._adaptation_time_constant) 218 | adapt_scale = 1 - np.exp(exponent) 219 | adaptations = adapt_curve * adapt_scale 220 | # Zero out negative values 221 | adaptations[adaptations < 0] = 0.0 222 | return adaptations 223 | 224 | def _calc_adaptations_curve(self, firing_rates: ndarray) -> ndarray: 225 | """ 226 | Calculates q(i) from Eq. (13). This is the baseline adaptation curve 227 | for each neuron based on activity. 228 | 229 | :param firing_rates: Array of activities for each motor neuron. 230 | """ 231 | ratios = (self._recruitment_thresholds - 1) / (self._max_recruitment_threshold - 1) 232 | adaptations = self._adaptation_magnitude * (firing_rates - self._min_firing_rate + self._derecruitment_delta) * ratios 233 | return adaptations 234 | 235 | @staticmethod 236 | def _calc_peak_firing_rates( 237 | max_firing_rate_first_unit: int, 238 | max_firing_rate_last_unit: int, 239 | max_recruitment_threshold: int, 240 | recruitment_thresholds: ndarray, 241 | ) -> ndarray: 242 | """ 243 | Calculate peak firing rates for each motor neuron 244 | 245 | :param max_firing_rate_first_unit: 246 | Maximum allowed activity of the 'first' neuron in the pool. 247 | :param max_firing_rate_last_unit: 248 | Maximum allowed activity of the 'last' neuron in the pool. 249 | :param max_recruitment_threshold: 250 | Excitation level above which the 'last' neuron in the pool will 251 | become active. 252 | :param recruitment_thresholds: 253 | Array of all excitation levels above which each neuron will become 254 | active. 255 | """ 256 | firing_rate_range = max_firing_rate_first_unit - max_firing_rate_last_unit 257 | rates = max_firing_rate_first_unit \ 258 | - (firing_rate_range 259 | * ((recruitment_thresholds - recruitment_thresholds[0]) 260 | / (max_recruitment_threshold - recruitment_thresholds[0]))) 261 | return rates 262 | 263 | @staticmethod 264 | def _calc_recruitment_thresholds( 265 | motor_unit_count: int, 266 | max_recruitment_threshold: int 267 | ) -> ndarray: 268 | """ 269 | Pure function to calculate recruitment thresholds for each motor 270 | neuron. 271 | 272 | :param motor_unit_count: The number of motor units in the pool. 273 | :param max_recruitment_threshold: 274 | Excitation level above which the 'last' neuron in the pool will 275 | become active. 276 | """ 277 | motor_unit_indices = np.arange(1, motor_unit_count + 1) 278 | 279 | r_log = np.log(max_recruitment_threshold) 280 | r_exponent = (r_log * (motor_unit_indices - 1)) / (motor_unit_count - 1) 281 | return np.exp(r_exponent) 282 | 283 | def step(self, motor_pool_input: ndarray, step_size: float) -> ndarray: 284 | """ 285 | Advance the motor neuron pool simulation one step. 286 | 287 | Returns firing rates on a per motor neuron basis for the given 288 | array of excitations. Takes into account fatigue over time. 289 | 290 | :param excitations: 291 | Array of excitation levels to use as input to motor neurons. 292 | :param step_size: How far to advance time in this step. 293 | """ 294 | assert (len(motor_pool_input) == self.motor_unit_count) 295 | return self._calc_adapted_firing_rates(motor_pool_input, step_size) 296 | -------------------------------------------------------------------------------- /pymuscle/potvin_fuglevand_2017_muscle_fibers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import math # noqa 3 | from numpy import ndarray 4 | from copy import copy 5 | 6 | from .model import Model 7 | 8 | 9 | class PotvinFuglevand2017MuscleFibers(Model): 10 | """ 11 | Encapsulates the muscle fibers portions of the motor unit model. 12 | 13 | The name of each parameter as it appears in Potvin, 2017 is in parentheses. 14 | If a parameter does not appear in the paper but does appear in the Matlab 15 | code, the variable name from the Matlab code is in parentheses. 16 | 17 | :param motor_unit_count: Number of motor units in the muscle (n) 18 | :param max_twitch_amplitude: Max twitch force within the pool (RP) 19 | :param max_contraction_time: 20 | [milliseconds] Maximum contraction time for a motor unit (tL) 21 | :param contraction_time_range: 22 | The scale between the fastest contraction time and the slowest (rt) 23 | :max_fatigue_rate: [percent per second] The rate at which the largest 24 | motor unit will fatigue at maximum excitation. 25 | :fatigability_range: 26 | The scale between the fatigability of the first motor unit and the last 27 | :contraction_time_change_ratio: 28 | For each percent of force lost during fatigue, what percentage should 29 | contraction increase? Based on Shields et al (1997) 30 | :apply_fatigue: Whether to calculate and apply peripheral fatigue. 31 | 32 | .. todo:: 33 | The argument naming isn't consistent. Sometimes we use 'max' and other 34 | times we use 'last unit'. Can these be made consistent? 35 | 36 | Usage:: 37 | 38 | from pymuscle import PotvinFuglevand2017MuscleFibers as Fibers 39 | 40 | motor_unit_count = 60 41 | fibers = Fibers(motor_unit_count) 42 | motor_neuron_firing_rates = np.rand(motor_unit_count) * 10.0 43 | step_size = 0.01 44 | force = fibers.step(motor_neuron_firing_rates, step_size) 45 | """ 46 | def __init__( 47 | self, 48 | motor_unit_count: int, 49 | max_twitch_amplitude: int = 100, 50 | max_contraction_time: int = 90, 51 | contraction_time_range: int = 3, 52 | max_fatigue_rate: float = 0.0225, 53 | fatigability_range: int = 180, 54 | contraction_time_change_ratio: float = 0.379, 55 | apply_fatigue: bool = True 56 | ): 57 | self._peak_twitch_forces = self._calc_peak_twitch_forces( 58 | motor_unit_count, 59 | max_twitch_amplitude 60 | ) 61 | 62 | # These will change with fatigue. 63 | self._current_peak_forces = copy(self._peak_twitch_forces) 64 | 65 | self._contraction_times = self._calc_contraction_times( 66 | max_twitch_amplitude, 67 | max_contraction_time, 68 | contraction_time_range, 69 | self._peak_twitch_forces 70 | ) 71 | 72 | # These will change with fatigue 73 | self._current_contraction_times = copy(self._contraction_times) 74 | 75 | # The maximum rates at which motor units will fatigue 76 | self._nominal_fatigabilities = self._calc_nominal_fatigabilities( 77 | motor_unit_count, 78 | fatigability_range, 79 | max_fatigue_rate, 80 | self._peak_twitch_forces 81 | ) 82 | 83 | # Assign other non-public attributes 84 | self._contraction_time_change_ratio = contraction_time_change_ratio 85 | self._apply_fatigue = apply_fatigue 86 | self._max_fatigue_rate = max_fatigue_rate 87 | 88 | # Assign public attributes 89 | self.motor_unit_count = motor_unit_count 90 | self.current_forces = np.zeros(motor_unit_count) 91 | 92 | @property 93 | def current_peak_forces(self): 94 | return self._current_peak_forces 95 | 96 | def _update_fatigue( 97 | self, 98 | normalized_forces: ndarray, 99 | step_size: float 100 | ) -> None: 101 | """ 102 | Updates current twitch forces and contraction times. 103 | 104 | :param normalized_forces: 105 | Array of scaled forces. Used to weight how much fatigue will be 106 | generated in this step. 107 | :param step_size: How far time has advanced in this step. 108 | """ 109 | # Instantaneous fatigue rate 110 | fatigues = (self._nominal_fatigabilities * normalized_forces) * step_size 111 | self._current_peak_forces -= fatigues 112 | 113 | # Zero out negative values 114 | self._current_peak_forces[self._current_peak_forces < 0] = 0.0 115 | self._update_contraction_times() 116 | 117 | def _update_contraction_times(self) -> None: 118 | """ 119 | Update our current contraction times as a function of our current 120 | force capacity relative to our peak force capacity. 121 | From Eq. (11) 122 | """ 123 | force_loss_pcts = 1 - (self._current_peak_forces / self._peak_twitch_forces) 124 | inc_pcts = 1 + self._contraction_time_change_ratio * force_loss_pcts 125 | self._current_contraction_times = self._contraction_times * inc_pcts 126 | 127 | @staticmethod 128 | def _calc_contraction_times( 129 | max_twitch_amplitude: int, 130 | max_contraction_time: int, 131 | contraction_time_range: int, 132 | peak_twitch_forces: ndarray 133 | ) -> ndarray: 134 | """ 135 | Calculate the contraction times for each motor unit 136 | Results in a smooth range from max_contraction_time at the first 137 | motor unit down to max_contraction_time / contraction_time range 138 | for the last motor unit 139 | 140 | :param max_twitch_amplitude: 141 | Largest force a motor unit in this muscle can produce. 142 | :param max_contraction_time: 143 | Slowest contraction time for a motor unit in this muscle. 144 | :param contraction_time_range: 145 | The ratio between the slowest and the fastest contraction times 146 | in this muscle. 147 | :param peak_twitch_forces: 148 | An array of all the largest forces that each motor unit can 149 | produce. 150 | """ 151 | 152 | # Fuglevand 93 version - very slightly different values 153 | # twitch_force_range = peak_twitch_forces[-1] / peak_twitch_forces[0] 154 | # scale = math.log(twitch_force_range, contraction_time_range) 155 | 156 | # Potvin 2017 version 157 | scale = np.log(max_twitch_amplitude) / np.log(contraction_time_range) 158 | 159 | mantissa = 1 / peak_twitch_forces 160 | exponent = 1 / scale 161 | return max_contraction_time * np.power(mantissa, exponent) 162 | 163 | @staticmethod 164 | def _calc_peak_twitch_forces( 165 | motor_unit_count: int, 166 | max_twitch_amplitude: int 167 | ) -> ndarray: 168 | """ 169 | Pure function to calculate the peak twitch force for each motor unit. 170 | 171 | :param motor_unit_count: The number of motor units in the pool. 172 | :param max_twitch_amplitude: 173 | Largest force a motor unit in this muscle can produce. (Arbitrary 174 | units.) 175 | """ 176 | motor_unit_indices = np.arange(1, motor_unit_count + 1) 177 | t_log = np.log(max_twitch_amplitude) 178 | t_exponent = (t_log * (motor_unit_indices - 1)) / (motor_unit_count - 1) 179 | return np.exp(t_exponent) 180 | 181 | @staticmethod 182 | def _calc_nominal_fatigabilities( 183 | motor_unit_count: int, 184 | fatigability_range: int, 185 | max_fatigue_rate: float, 186 | peak_twitch_forces: ndarray 187 | ) -> ndarray: 188 | """ 189 | Pure function to calculate nominal fatigue factors for each motor unit. 190 | 191 | Taken more from the matlab code than the paper. 192 | 193 | :param motor_unit_count: The number of motor units in this muscle. 194 | :param fatigability_range: 195 | The ratio between the maximum fatigue rate of the strongest motor 196 | unit (which fatigues the fastest) and the fatigue rate of the 197 | weakest motor unit. 198 | :param max_fatigue_rate: 199 | Largest percentage drop per unit time of twitch strength for the 200 | strongest motor unit. 201 | :param peak_twitch_forces: 202 | An array of all the largest forces that each motor unit can 203 | produce. 204 | """ 205 | motor_unit_indices = np.arange(1, motor_unit_count + 1) 206 | f_log = np.log(fatigability_range) 207 | motor_unit_fatigue_curve = np.exp((f_log / (motor_unit_count - 1)) * (motor_unit_indices - 1)) 208 | fatigue_rates = motor_unit_fatigue_curve * (max_fatigue_rate / fatigability_range) * peak_twitch_forces 209 | return fatigue_rates 210 | 211 | def _normalize_firing_rates(self, firing_rates: ndarray) -> ndarray: 212 | """ 213 | Calculate the effective impact of a given set of firing rates on 214 | muscle fibers which have diverse contraction times and may be fatigued. 215 | 216 | :param firing_rates: Should be the result of pool._calc_adapted_firing_rates() 217 | """ 218 | # Divide by 1000 here as firing rates are per second where contraction 219 | # times are in milliseconds. 220 | return self._current_contraction_times * (firing_rates / 1000) 221 | 222 | @staticmethod 223 | def _calc_normalized_forces(normalized_firing_rates: ndarray) -> ndarray: 224 | """ 225 | Calculate motor unit force, relative to its peak force. Force grows 226 | in a linear fashion up to 0.4 normalized firing rate and then in a 227 | sigmoid curve afterward. 228 | 229 | :param normalized_firing_rates: 230 | An array of firing rates scaled by the current contraction times 231 | for each motor unit. 232 | """ 233 | normalized_forces = copy(normalized_firing_rates) 234 | linear_threshold = 0.4 # Values are non-linear above this value 235 | below_thresh_indices = normalized_forces <= linear_threshold 236 | above_thresh_indices = normalized_forces > linear_threshold 237 | # The next two lines are strange and magical 238 | # In the paper they are simplified to *= 0.3 239 | # This is the equivalent of the Matlab code 240 | normalized_forces[below_thresh_indices] /= 0.4 241 | normalized_forces[below_thresh_indices] *= 1 - np.exp(-2 * (0.4 ** 3)) 242 | exponent = -2 * np.power( 243 | normalized_forces[above_thresh_indices], 244 | 3 245 | ) 246 | normalized_forces[above_thresh_indices] = 1 - np.exp(exponent) 247 | return normalized_forces 248 | 249 | def _calc_current_forces(self, normalized_forces: ndarray) -> ndarray: 250 | """ 251 | Scales the normalized forces for each motor unit by their current 252 | remaining twitch force capacity. 253 | 254 | This method also updates the public Muscle.current_forces array. 255 | 256 | :param normalized_forces: An array of forces scaled between 0 and 1 257 | """ 258 | self.current_forces = normalized_forces * self._current_peak_forces 259 | return self.current_forces 260 | 261 | def _calc_total_fiber_force( 262 | self, 263 | firing_rates: ndarray, 264 | step_size: float 265 | ) -> float: 266 | """ 267 | Calculates the total instantaneous force produced by all fibers for 268 | the given instantaneous firing rates. 269 | :param firing_rates: 270 | An array of firing rates calculated by a compatible Pool class. 271 | :param step_size: How far time has advanced in this step. 272 | """ 273 | normalized_firing_rates = self._normalize_firing_rates(firing_rates) 274 | normalized_forces = self._calc_normalized_forces(normalized_firing_rates) 275 | current_forces = self._calc_current_forces(normalized_forces) 276 | total_force = np.sum(current_forces) 277 | 278 | # Apply fatigue as last step 279 | if self._apply_fatigue: 280 | self._update_fatigue(normalized_forces, step_size) 281 | 282 | return total_force 283 | 284 | def step( 285 | self, 286 | motor_pool_output: ndarray, 287 | step_size: float 288 | ) -> float: 289 | """ 290 | Advance the muscle fibers simulation one step. 291 | 292 | Returns the total instantaneous force produced by all fibers for 293 | the given input from the motor neuron pool. 294 | 295 | :param motor_pool_output: 296 | An array of firing rates calculated by a compatible Pool class. 297 | :param step_size: How far time has advanced in this step. 298 | """ 299 | assert (len(motor_pool_output) == self.motor_unit_count) 300 | return self._calc_total_fiber_force(motor_pool_output, step_size) 301 | --------------------------------------------------------------------------------