diff --git a/rowers/fitness.py b/rowers/fitness.py new file mode 100644 index 00000000..bcced876 --- /dev/null +++ b/rowers/fitness.py @@ -0,0 +1,112 @@ +import polars as pl +import datetime +import arrow +import numpy as np + +from rowers.dataprep import rscore_approx + +from rowers.models import Rower, Workout + +def weightfromrecord(row,metricchoice): + vv = row[metricchoice][0] + if vv > 0: + return vv + if metricchoice == 'rscore': # pragma: no cover + return rscore_approx(row) + + return 0 # pragma: no cover + + +def getfatigues(df, fatigues, fitnesses, dates, impulses, startdate, kfatigue, kfitness): + fatigue = 0 + fitness = 0 + impulses = [] + + for f in fatigues: + impulses.append(0) + + lambda_a = 2/(kfatigue+1) + lambda_c = 2/(kfitness+1) + + enddate = datetime.date.today() + nrdays = (enddate - startdate).days + + for i in range(nrdays+1): + date = startdate + datetime.timedelta(days=i) + datekey = date.strftime('%Y-%m-%d') + weight = 0 + + try: + df2 = df.filter(pl.col('date') == date) + if type(df2) == pl.Series: + weight += weightfromrecord(df2, 'rscore') + else: + for row in df2.iter_slices(n_rows=1): + weight += weightfromrecord(row, 'rscore') + except KeyError: + pass + + impulses.append(weight) + + fatigue = (1 - lambda_a) * fatigue + lambda_a * weight + fitness = (1 - lambda_c) * fitness + lambda_c * weight + + fatigues.append(fatigue) + fitnesses.append(fitness) + dates.append(arrow.get(date).datetime) + + return fatigues, fitnesses, dates, impulses + +def calculate_fitness(rower): + workouts = Workout.objects.filter(user=rower, date__gte=datetime.date.today() - datetime.timedelta(days=90), + duplicate=False).order_by('date') + + kfitness = rower.kfit + kfatigue = rower.kfatigue + + + records = [] + + for w in workouts: + dd = { + 'date': w.date, + 'trimp': w.trimp, + 'rscore': w.rscore, + 'hrtss':w.hrtss, + 'duration': w.duration, + 'id': w.id, + 'rpe': w.rpe, + } + records.append(dd) + + df = pl.from_records(records) + if df.is_empty(): + return 0, 0 + + dates = [arrow.get(w.date).datetime for w in workouts] + impulses = [np.nan for w in workouts] + fitnesses = [np.nan for w in workouts] + fatigues = [np.nan for w in workouts] + + fatigues, fitnesses, dates, impulses = getfatigues( + df, + fatigues, + fitnesses, + dates, + impulses, + datetime.date.today()-datetime.timedelta(days=90), + kfatigue, kfitness + ) + + df = pl.DataFrame({ + 'date': dates, + 'fatigue': fatigues, + 'fitness': fitnesses, + 'impulse': impulses, + }) + + endfitness = fitnesses[-2] + endfatigue = fatigues[-2] + + return endfitness, endfatigue + diff --git a/rowers/forms.py b/rowers/forms.py index f54c97b0..45a545bb 100644 --- a/rowers/forms.py +++ b/rowers/forms.py @@ -316,8 +316,17 @@ class MetricsForm(forms.Form): class CNsummaryForm(forms.Form): file = forms.FileField(required=True, validators=[must_be_csv]) +class CurrentWeekJsonForm(forms.Form): + plan = forms.CharField(required=True, label="Plan Code", initial="zwolsche") + secret = forms.CharField(required=True, label="Plan Secret", initial="noot") + delete_existing = forms.BooleanField(required=False, initial=False, label="Remove any existing sessions") + class NextWeekJsonForm(forms.Form): - file = forms.FileField(required=True) + plan = forms.CharField(required=True, label="Plan Code", initial="zwolsche") + secret = forms.CharField(required=True, label="Plan Secret", initial="noot") + fitness = forms.IntegerField(required=False, label='Fitness', initial=0) + fatigue = forms.IntegerField(required=False, label='Fatigue', initial=0) + delete_existing = forms.BooleanField(required=False, initial=False, label="Remove any existing sessions") # The little window to type '4x2000m/500m' to update the workout summary diff --git a/rowers/plannedsessions.py b/rowers/plannedsessions.py index 28da7b70..5b0513f9 100644 --- a/rowers/plannedsessions.py +++ b/rowers/plannedsessions.py @@ -1067,6 +1067,14 @@ def get_workouts_session(r, ps): return ws +def strcapitalize(s): + if s is None: + return None + if isinstance(s, str): + return s[0].upper() + s[1:] + + return s + def correct_intensity(workout): # reads the steps and if the intensity is an integer, converts it to a string steps = workout['steps'] @@ -1074,15 +1082,53 @@ def correct_intensity(workout): if 'intensity' in step: if isinstance(step['intensity'], int): step['intensity'] = intensitymap[step['intensity']] + step['durationType'] = strcapitalize(step['durationType']) + step['targetType'] = strcapitalize(step['targetType']) + step['intensity'] = strcapitalize(step['intensity']) return workout - -def create_next_week_from_json(plansteps, rower, planbyrscore=False, plan=None,): - trainingdays = plansteps['cycles'] - # start date is the first day of the following week +def remove_next_week_sessions(rower): + # remove all sessions in the next week today = timezone.now() startdate = today - timezone.timedelta(days=today.weekday())+timezone.timedelta(days=7) + enddate = startdate + timezone.timedelta(days=6) + + sps = PlannedSession.objects.filter( + rower__in=[rower], + startdate__gte=startdate, + enddate__lte=enddate, + is_template=False, + ) + + for ps in sps: + ps.delete() + + return 1 + +def remove_this_week_sessions(rower): + # remove all sessions in the current week + today = timezone.now() + startdate = today - timezone.timedelta(days=today.weekday()) + enddate = startdate + timezone.timedelta(days=6) + + sps = PlannedSession.objects.filter( + rower__in=[rower], + startdate__gte=startdate, + enddate__lte=enddate, + is_template=False, + ) + + for ps in sps: + ps.delete() + + return 1 + +def create_next_week_from_json(plansteps, rower, planbyrscore=False, plan=None, + startdate=timezone.now()-timezone.timedelta(days=timezone.now().weekday())+timezone.timedelta(days=7)): + trainingdays = plansteps['cycles'] + # start date is the first day of the following week + ndays = 0 for day in trainingdays: try: diff --git a/rowers/tests/testdata/testdata.tcx.gz b/rowers/tests/testdata/testdata.tcx.gz index 5c76f9ad..0c0f856d 100644 Binary files a/rowers/tests/testdata/testdata.tcx.gz and b/rowers/tests/testdata/testdata.tcx.gz differ diff --git a/rowers/urls.py b/rowers/urls.py index 05d38608..65db0431 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -1111,5 +1111,7 @@ urlpatterns = [ name="braintree_webhook_view"), re_path(r'^nextweekplan/$', views.nextweekplan_view, name='nextweekplan_view'), + re_path(r'^currentweekplan/$', views.currentweekplan_view, + name='currentweekplan_view'), ] diff --git a/rowers/utils.py b/rowers/utils.py index 5b8a0500..f6aa06bd 100644 --- a/rowers/utils.py +++ b/rowers/utils.py @@ -749,12 +749,16 @@ def steps_write_fit(steps): headers = {'Authorization': authorizationstring} # convert to json, value of keys called wkt_step_name to string + newsteps = [] for step in steps['steps']: step['wkt_step_name'] = str(step['wkt_step_name']) # convert numerical values in the dict to integers for key in step.keys(): if isinstance(step[key], (int, float)): step[key] = int(step[key]) + newsteps.append(step) + + steps['steps'] = newsteps response = requests.post(url=url, headers=headers, json=steps) @@ -1377,26 +1381,25 @@ def step_to_string(step, short=False): if isinstance(intensity, int): intensity = intensitymap[intensity] - - if short and intensity.lower() in ['warmup', 'cooldown', 'rest']: - s = '{intensity} {duration} {unit} {target} {repeat}'.format( - intensity=intensity, - duration=duration, - target=target, - repeat=repeat, - unit=unit - ) - elif intensity.lower() in ['warmup', 'cooldown', 'rest']: - s = '{intensity} {duration} {unit} {target} {repeat} {notes}'.format( - intensity=intensity, - duration=duration, - target=target, - repeat=repeat, - unit=unit, - notes=notes, - ) - - if stype == 'RepeatStep': + if stype != 'RepeatStep': + if short and intensity.lower() in ['warmup', 'cooldown', 'rest']: + s = '{intensity} {duration} {unit} {target} {repeat}'.format( + intensity=intensity, + duration=duration, + target=target, + repeat=repeat, + unit=unit + ) + elif intensity.lower() in ['warmup', 'cooldown', 'rest']: + s = '{intensity} {duration} {unit} {target} {repeat} {notes}'.format( + intensity=intensity, + duration=duration, + target=target, + repeat=repeat, + unit=unit, + notes=notes, + ) + else: s = 'Repeat {duration}'.format(duration=duration) return s, stype, nr, repeatID, repeatValue diff --git a/rowers/views/apiviews.py b/rowers/views/apiviews.py index 681e3148..fc0ee311 100644 --- a/rowers/views/apiviews.py +++ b/rowers/views/apiviews.py @@ -1000,7 +1000,10 @@ def strokedatajson_v2(request, id): except: return HttpResponse("No JSON object could be decoded", status=400) - df = df.sort("time") + try: + df = df.sort("time") + except ColumnNotFoundError: # pragma: no cover + return HttpResponse("No time column", status=400) status, comment, data = api_get_dataframe(row.startdatetime, df) if status != 200: # pragma: no cover diff --git a/rowers/views/planviews.py b/rowers/views/planviews.py index 09048997..9e4bf885 100644 --- a/rowers/views/planviews.py +++ b/rowers/views/planviews.py @@ -4211,30 +4211,110 @@ def planmacrocyclebymonth(request, id=0, userid=0): # pragma: no cover return HttpResponseRedirect(url) +@user_passes_test(can_plan, login_url="/rowers/paidplans", + message="This functionality requires a Pro plan", + redirect_field_name=None) +def currentweekplan_view(request): + r = getrower(request.user) + + form = CurrentWeekJsonForm() + + if request.method == "POST": + form = CurrentWeekJsonForm(request.POST) + if form.is_valid(): + secret = form.cleaned_data['secret'] + plan = form.cleaned_data['plan'] + delete_existing = form.cleaned_data['delete_existing'] + + post_data = { + 'secret': secret, + 'plan': plan, + } + + url = "http://localhost:8898/current-week-plan/" + response = requests.post(url, data=post_data) + + data = response.json() + if response.status_code in [200,201]: + if delete_existing: + remove_this_week_sessions(r) + + create_next_week_from_json( + data, r, + startdate=timezone.now()-timezone.timedelta(days=timezone.now().weekday())) + + messages.info(request,"Your planned sessions were created") + + url = reverse("plannedsessions_view") + return HttpResponseRedirect(url) + + active = 'nav-plan' + + breadcrumbs = [ + { + 'url': reverse('plannedsessions_view'), + 'name': 'Sessions' + }, + { + 'url': reverse('currentweekplan_view'), + 'name': "Next week" + }, + ] + + return render(request, + 'loadnextweek.html', + {'rower': r, + 'form': form, + 'active': active, + 'breadcrumbs': breadcrumbs}) + + @user_passes_test(can_plan, login_url="/rowers/paidplans", message="This functionality requires a Pro plan", redirect_field_name=None) def nextweekplan_view(request): r = getrower(request.user) - form = NextWeekJsonForm() + fitness, fatigue = calculate_fitness(r) + + + form = NextWeekJsonForm( + initial={ + 'fitness': round(fitness), + 'fatigue': round(fatigue), + } + ) if request.method == "POST": - form = NextWeekJsonForm(request.POST, request.FILES) + form = NextWeekJsonForm(request.POST) if form.is_valid(): - f = form.cleaned_data['file'] - if f is not None: - filename, path_and_filename = handle_uploaded_file(f) - try: - with open(path_and_filename,'r') as ff: - data = json.load(ff) - except: - messages.error(request,"Hmm, invalid file") + fitness = form.cleaned_data['fitness'] + fatigue = form.cleaned_data['fatigue'] + secret = form.cleaned_data['secret'] + plan = form.cleaned_data['plan'] + delete_existing = form.cleaned_data['delete_existing'] + post_data = { + 'fitness':fitness, + 'fatigue':fatigue, + 'plan': plan, + 'secret': secret, + } + + # post form.cleaned_data to localhost:8898/next-week-plan + url = "http://localhost:8898/next-week-plan/" + response = requests.post(url, data=post_data) + + + if response.status_code in [200,201]: + data = response.json() + if delete_existing: + remove_next_week_sessions(r) create_next_week_from_json(data, r) messages.info(request,"Your planned sessions were created") - # remove path_and_filename - os.remove(path_and_filename) + else: + messages.error(request,"Unsuccessful") + active = 'nav-plan' diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 0b9e0aa9..7667dae2 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -13,6 +13,7 @@ from rowers.utils import ( my_dict_from_instance, wavg, NoTokenError, request_is_ajax, dologging ) +from rowers.fitness import calculate_fitness from rowers.celery import result as celery_result from rowers.interactiveplots import * from scipy.interpolate import griddata @@ -120,7 +121,7 @@ from rowers.forms import ( TrainingPlanBillingForm, InstantPlanSelectForm, TrainingZonesForm, InstrokeForm, InStrokeMultipleCompareForm, ForceCurveMultipleCompareForm, PlanByRscoreForm, - AssignChoices, NextWeekJsonForm, + AssignChoices, NextWeekJsonForm, CurrentWeekJsonForm, ) from django.urls import reverse, reverse_lazy diff --git a/rowers/views/userviews.py b/rowers/views/userviews.py index 90cb4480..9f72a107 100644 --- a/rowers/views/userviews.py +++ b/rowers/views/userviews.py @@ -394,6 +394,7 @@ def rower_exportsettings_view(request, userid=0): 'strava_auto_delete': 'stravatoken', 'trainingpeaks_auto_export': 'tptoken', 'rp3_auto_import': 'rp3token', + 'rp3_use_split_power': 'rp3token', 'nk_auto_import': 'nktoken', 'intervals_auto_export': 'intervals_token', 'intervals_resample_to_1s': 'intervals_token',