Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat:suport heart_rate in export gpx file #529

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 103 additions & 32 deletions run_page/keep_sync.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import argparse
import base64
import json
import math
import os
import time
import zlib
from collections import namedtuple
from datetime import datetime, timedelta

import eviltransform
import gpxpy
import polyline
import requests
from config import GPX_FOLDER, JSON_FILE, SQL_FILE, run_map, start_point
from Crypto.Cipher import AES
from generator import Generator
from utils import adjust_time
from utils import parse_df_points_to_gpx, Metadata
from pandas import DataFrame
Copy link
Owner

Choose a reason for hiding this comment

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

why we need pandas here...
I think it is a bit heavy (we only use this in csv generate...


# need to test
LOGIN_API = "https://api.gotokeep.com/v1.1/users/login"
RUN_DATA_API = "https://api.gotokeep.com/pd/v3/stats/detail?dateUnit=all&type=running&lastDate={last_date}"
RUN_LOG_API = "https://api.gotokeep.com/pd/v3/runninglog/{run_id}"

HR_FRAME_THRESHOLD_IN_DECISECOND = 100 # Maximum time difference to consider a data point as the nearest, the unit is decisecond(分秒)

TIMESTAMP_THRESHOLD_IN_DECISECOND = 3_600_000 # Threshold for target timestamp adjustment, the unit of timestamp is decisecond(分秒), so the 3_600_000 stands for 100 hours sports time. 100h = 100 * 60 * 60 * 10

# If your points need trans from gcj02 to wgs84 coordinate which use by Mapbox
TRANS_GCJ02_TO_WGS84 = True
Expand Down Expand Up @@ -88,6 +91,17 @@ def parse_raw_data_to_nametuple(
keep_id = run_data["id"].split("_")[1]

start_time = run_data["startTime"]
avg_heart_rate = None
decoded_hr_data = []
if run_data["heartRate"]:
avg_heart_rate = run_data["heartRate"].get("averageHeartRate", None)
heart_rate_data = run_data["heartRate"].get("heartRates", None)
if heart_rate_data is not None:
decoded_hr_data = decode_runmap_data(heart_rate_data)
# fix #66
if avg_heart_rate and avg_heart_rate < 0:
avg_heart_rate = None

if run_data["geoPoints"]:
run_points_data = decode_runmap_data(run_data["geoPoints"], True)
run_points_data_gpx = run_points_data
Expand All @@ -99,20 +113,22 @@ def parse_raw_data_to_nametuple(
for i, p in enumerate(run_points_data_gpx):
p["latitude"] = run_points_data[i][0]
p["longitude"] = run_points_data[i][1]
else:
run_points_data = [[p["latitude"], p["longitude"]] for p in run_points_data]

for p in run_points_data_gpx:
p_hr = find_nearest_hr(decoded_hr_data, int(p["timestamp"]), start_time)
if p_hr:
p["hr"] = p_hr
if with_download_gpx:
if str(keep_id) not in old_gpx_ids:
gpx_data = parse_points_to_gpx(run_points_data_gpx, start_time)
if (
str(keep_id) not in old_gpx_ids
and run_data["dataType"] == "outdoorRunning"
):
gpx_data = parse_points_to_gpx(
run_data["id"], run_points_data_gpx, start_time
)
download_keep_gpx(gpx_data, str(keep_id))
else:
print(f"ID {keep_id} no gps data")
heart_rate = None
if run_data["heartRate"]:
heart_rate = run_data["heartRate"].get("averageHeartRate", None)
# fix #66
if heart_rate and heart_rate < 0:
heart_rate = None
polyline_str = polyline.encode(run_points_data) if run_points_data else ""
start_latlng = start_point(*run_points_data[0]) if run_points_data else None
start_date = datetime.utcfromtimestamp(start_time / 1000)
Expand All @@ -133,7 +149,7 @@ def parse_raw_data_to_nametuple(
"start_date_local": datetime.strftime(start_date_local, "%Y-%m-%d %H:%M:%S"),
"end_local": datetime.strftime(end_local, "%Y-%m-%d %H:%M:%S"),
"length": run_data["distance"],
"average_heartrate": int(heart_rate) if heart_rate else None,
"average_heartrate": int(avg_heart_rate) if avg_heart_rate else None,
"map": run_map(polyline_str),
"start_latlng": start_latlng,
"distance": run_data["distance"],
Expand Down Expand Up @@ -171,34 +187,89 @@ def get_all_keep_tracks(email, password, old_tracks_ids, with_download_gpx=False
return tracks


def parse_points_to_gpx(run_points_data, start_time):
# future to support heart rate
def parse_points_to_gpx(run_id, run_points_data, start_time):
"""
Convert run points data to GPX format.

Args:
run_id (str): The ID of the run.
run_points_data (list of dict): A list of run data points.
start_time (int): The start time for adjusting timestamps. Note that the unit of the start_time is millsecond

Returns:
gpx_data (str): GPX data in string format.
"""
points_dict_list = []
# early timestamp fields in keep's data stands for delta time, but in newly data timestamp field stands for exactly time,
# so it does'nt need to plus extra start_time
if run_points_data[0]["timestamp"] > TIMESTAMP_THRESHOLD_IN_DECISECOND:
start_time = 0

for point in run_points_data:
points_dict = {
"latitude": point["latitude"],
"longitude": point["longitude"],
"time": datetime.utcfromtimestamp(
(point["timestamp"] * 100 + start_time) / 1000
(point["timestamp"] * 100 + start_time)
/ 1000 # note that the timestamp of a point is decisecond(分秒)
),
"elevation": point.get("verticalAccuracy"),
"hr": point.get("hr"),
}
if "verticalAccuracy" in point:
points_dict["elevation"] = point["verticalAccuracy"]
points_dict_list.append(points_dict)
gpx = gpxpy.gpx.GPX()
gpx.nsmap["gpxtpx"] = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
gpx_track = gpxpy.gpx.GPXTrack()
gpx_track.name = "gpx from keep"
gpx.tracks.append(gpx_track)

# Create first segment in our GPX track:
gpx_segment = gpxpy.gpx.GPXTrackSegment()
gpx_track.segments.append(gpx_segment)
for p in points_dict_list:
point = gpxpy.gpx.GPXTrackPoint(**p)
gpx_segment.points.append(point)

return gpx.to_xml()

meta = Metadata(
name="Run from Keep",
type="Run",
desc=f"Run from Keep",
link=RUN_LOG_API.format(run_id=run_id),
)
gpx_data = parse_df_points_to_gpx(meta, DataFrame(points_dict_list))
return gpx_data


#
def find_nearest_hr(
hr_data_list, target_time, start_time, threshold=HR_FRAME_THRESHOLD_IN_DECISECOND
):
"""
Find the nearest heart rate data point to the target time.
if cannot found suitable HR data within the specified time frame (within 10 seconds by default), there will be no hr data return
Args:
heart_rate_data (list of dict): A list of heart rate data points, where each point is a dictionary
containing at least "timestamp" and "beatsPerMinute" keys.
target_time (float): The target timestamp for which to find the nearest heart rate data point. Please Note that the unit of target_time is decisecond(分秒),
means 1/10 of a second ,this is very unsual!! so when we convert a target_time to second we need to divide by 10, and when we convert a target time to millsecond
we need to times 100.
start_time (float): The reference start time. the unit of start_time is normal millsecond timestamp
threshold (float, optional): The maximum allowed time difference to consider a data point as the nearest.
Default is HR_THRESHOLD, the unit is decisecond(分秒)

Returns:
int or None: The heart rate value of the nearest data point, or None if no suitable data point is found.
zhaohongxuan marked this conversation as resolved.
Show resolved Hide resolved
"""
closest_element = None
# init difference value
min_difference = float("inf")
if target_time > TIMESTAMP_THRESHOLD_IN_DECISECOND:
target_time = (
target_time * 100 - start_time
) / 100 # note that the unit of target_time is decisecond(分秒) and the unit of start_time is normal millsecond

for item in hr_data_list:
timestamp = item["timestamp"]
difference = abs(timestamp - target_time)

if difference <= threshold and difference < min_difference:
closest_element = item
min_difference = difference

if closest_element:
hr = closest_element.get("beatsPerMinute")
if hr and hr > 0:
return hr

return None


def download_keep_gpx(gpx_data, keep_id):
Expand Down
24 changes: 6 additions & 18 deletions run_page/nike_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
)
from generator import Generator
from utils import adjust_time, make_activities_file
from pandas import DataFrame
from utils import parse_df_points_to_gpx, Metadata

# logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("nike_sync")
Expand Down Expand Up @@ -248,24 +250,10 @@ def update_points(points, update_data, update_name):
if heart_rate_data:
update_points(points_dict_list, heart_rate_data, "heart_rate")

for p in points_dict_list:
# delete useless attr
del p["start_time"]
if p.get("heart_rate") is None:
point = gpxpy.gpx.GPXTrackPoint(**p)
else:
heart_rate_num = p.pop("heart_rate")
point = gpxpy.gpx.GPXTrackPoint(**p)
gpx_extension_hr = ElementTree.fromstring(
f"""<gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
<gpxtpx:hr>{heart_rate_num}</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
"""
)
point.extensions.append(gpx_extension_hr)
gpx_segment.points.append(point)

return gpx.to_xml()
meta = Metadata(name=title, type="Run", desc="Run from NRC")
df_points = DataFrame(points_dict_list)
gpx_doc = parse_df_points_to_gpx(meta, df_points, col_hr="heart_rate")
return gpx_doc


def parse_activity_data(activity):
Expand Down
59 changes: 59 additions & 0 deletions run_page/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@

import pytz

import gpxpy
import pandas as pd
import xml.etree.ElementTree as ET
import numpy as np

try:
from rich import print
except:
pass
from generator import Generator
from stravalib.client import Client
from stravalib.exc import RateLimitExceeded
from collections import namedtuple

Metadata = namedtuple("Metadata", ("name", "type", "desc", "link"))


def adjust_time(time, tz_name):
Expand Down Expand Up @@ -115,3 +123,54 @@ def upload_file_to_strava(client, file_name, data_type, force_to_run=True):
print(
f"Uploading {data_type} file: {file_name} to strava, upload_id: {r.upload_id}."
)


def parse_df_points_to_gpx(
metadata,
df_points,
col_lat="latitude",
col_long="longitude",
col_time="time",
col_ele="elevation",
col_hr="hr",
zhaohongxuan marked this conversation as resolved.
Show resolved Hide resolved
):
"""
Convert a pandas dataframe to gpx
Parameters:
metadata(Metadata): metadata of the gpx track
df_points (pd.DataFrame): pandas dataframe containing at minimum lat,long,time info of points
"""
gpx = gpxpy.gpx.GPX()
gpx.nsmap["gpxtpx"] = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
gpx_track = gpxpy.gpx.GPXTrack()
gpx.tracks.append(gpx_track)

gpx_segment = gpxpy.gpx.GPXTrackSegment()
gpx_track.segments.append(gpx_segment)

# support multi tracks in the future
gpx.tracks[0].name = metadata.name
gpx.tracks[0].type = metadata.type
gpx.tracks[0].description = metadata.desc
gpx.tracks[0].link = metadata.link

for idx in df_points.index:
track_point = gpxpy.gpx.GPXTrackPoint(
latitude=df_points.loc[idx, col_lat],
longitude=df_points.loc[idx, col_long],
time=pd.Timestamp(df_points.loc[idx, col_time]),
elevation=df_points.loc[idx, col_ele] if col_ele else None,
)

# gpx extensions
if df_points.loc[idx, col_hr] and not np.isnan(df_points.loc[idx, col_hr]):
hr = int(df_points.loc[idx, col_hr])
gpx_extension_hr = ET.fromstring(
f"""<gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
<gpxtpx:hr>{hr}</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
"""
)
track_point.extensions.append(gpx_extension_hr)
gpx_segment.points.append(track_point)
return gpx.to_xml()