~cypheon/trakka

e770d7abaa006e06e3fbe885e5a8bd3355faa852 — Johann Rudloff 2 years ago 797b739
Implement graphs visualizing HR zones
M activities/activity.py => activities/activity.py +14 -3
@@ 125,8 125,18 @@ def opt_max(a, b):
        return a
    return max(a, b)

def analyse_hr_zones(hr: np.array, athlete_max_hr: float) -> np.array:
    bins = (np.array([0.0, 0.5, 0.65, 0.8, 0.9, 10]) * athlete_max_hr).round()
def opt_min(a, b):
    if a is None:
        return b
    if b is None:
        return a
    return min(a, b)

def analyse_hr_zones(hr: np.array, athlete_max_hr: float, athlete_resting_hr:
                     float = 0) -> np.array:
    hr_range = athlete_max_hr - athlete_resting_hr
    bins = (np.array([0.0, 0.3, 0.4, 0.6, 0.85, 10]) * hr_range + athlete_resting_hr).round()
    bins[0] = 0.0
    hist = np.histogram(np.convolve(hr[:, 1], np.array([0.5, 0.5]), 'valid'), weights=np.diff(hr[:, 0]), bins=bins)
    hr_zones = {
        'seconds': list(hist[0].round()),


@@ 202,7 212,8 @@ def analyse(track: Track, athlete_data: Mapping[str, float], raw: bool = False):
        min_hr = np.min(hr[:, 1])

        potential_max_hr = opt_max(max_hr, athlete_data.get('max_hr'))
        hr_zones = analyse_hr_zones(hr, potential_max_hr)
        resting_hr = opt_min(min_hr, athlete_data.get('resting_hr'))
        hr_zones = analyse_hr_zones(hr, potential_max_hr, resting_hr)
    elif hr.shape[0] == 1:
        avg_hr = hr[0][1]
        max_hr = hr[0][1]

A activities/graph.py => activities/graph.py +31 -0
@@ 0,0 1,31 @@
import io

import matplotlib
matplotlib.use("Agg")
from matplotlib import pyplot as plt

from .models import Activity

def hr_zones(act: Activity):
    p = plt.figure()
    bins = act.analysis['hr_zones']['bins']
    seconds = act.analysis['hr_zones']['seconds']

    bin_names = [
        'resting',
        'light',
        'moderate',
        'vigorous',
        'max effort',
    ]

    labels = [f'{name} (>{x:.0f} bpm)' for name, x in zip(bin_names, bins)]
    labels[0] = 'resting'

    pie = plt.pie(seconds, wedgeprops={'width': 0.57}, autopct='%1.f%%', labels=labels, colors=['#eee', '#fcc', '#f99', '#f00', '#600'])

    bio = io.BytesIO()
    p.savefig(bio, format='svg')
    bio.seek(0)
    #len(bio.read())
    return bio

M activities/jinja2/details.html => activities/jinja2/details.html +5 -0
@@ 23,5 23,10 @@
    {{ stat('Max. Heartrate', act.analysis.get('max_hr') | optional_int) }}
    {{ stat('Energy', act.analysis.get('energy') | optional_int, 'kcal') }}
  </div>

  {% if 'hr_zones' in act.analysis %}
    <h2>Heart Rate Zone Analysis</h2>
    <img src="/graphs/hrzones/{{ act.id }}">
  {% endif %}
</div>
{% endblock %}

M activities/services.py => activities/services.py +2 -0
@@ 94,12 94,14 @@ def process_activity(act: Activity):
        logger.error('unknown filetype %s', header[:32])
    logger.debug('raw track: %s', track)

    resting_hr = get_user_healthdata(act.user.id, 'RESTING_HR', track.start_date())
    max_hr = get_user_healthdata(act.user.id, 'MAX_HR', track.start_date())
    weight = get_user_healthdata(act.user.id, 'WEIGHT', track.start_date())
    logger.debug('max HR for analysis: %f', max_hr)
    logger.debug('athlete weight for analysis: %f', weight)

    athlete_data = {
        "resting_hr": resting_hr or 60,
        "max_hr": max_hr,
        "weight": weight,
    }

M activities/urls.py => activities/urls.py +1 -0
@@ 12,6 12,7 @@ urlpatterns = [
    path('upload', views.upload, name='upload'),
    path('activities', views.overview, name='overview'),
    path('activities/<int:activity_id>', views.summary, name='summary'),
    path('graphs/hrzones/<int:activity_id>', views.graph_hr_zones, name='graph-hrzones'),
    path('api/v1/activities', rest.activity_upload, name='rest_upload'),
    path('api/v1/import', rest.transfer_import, name='rest_import'),
]

M activities/views.py => activities/views.py +6 -0
@@ 10,6 10,7 @@ from django import forms

from .models import Activity
from .services import UPLOAD_MAX_SIZE, create_activity, queue_analysis
from . import graph

import datetime
import hashlib


@@ 53,6 54,11 @@ def summary(request, activity_id):
    return render(request, 'details.html', context)

@login_required
def graph_hr_zones(request, activity_id):
    act = request.user.activity_set.get(id=activity_id)
    return HttpResponse(graph.hr_zones(act), content_type='image/svg+xml')

@login_required
def upload(request):
    if request.method == 'POST':
        form = UploadFileForm(request.POST, request.FILES)