Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 18 additions & 1 deletion src/accelerometer/accProcess.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import accelerometer.summarisation
import pandas as pd
import atexit
import sys
import warnings


Expand Down Expand Up @@ -142,6 +143,13 @@ def main(): # noqa: C901
metavar='mins', default=60, type=int,
help="""minimum non-wear duration in minutes
(default : %(default)s mins))""")
parser.add_argument('--minWearPerDay',
metavar="e.g. '20h', '1200m'", default=None, type=str,
help="""minimum wear time per day for a day to be included
in summary statistics. Days with less wear time will be
excluded. Supports formats: '20h' (hours), '1200m' (minutes),
'0.5d' (days), or '20' (hours by default).
(default : %(default)s (all days included))""")
parser.add_argument('--calibrationSphereCriteria',
metavar='mg', default=0.3, type=float,
help="""calibration sphere threshold (default
Expand Down Expand Up @@ -233,6 +241,14 @@ def main(): # noqa: C901

processingStartTime = datetime.datetime.now()

# Parse minWearPerDay time string to hours
if args.minWearPerDay is not None:
try:
args.minWearPerDay = accelerometer.utils.parseTimeString(args.minWearPerDay)
except ValueError as e:
print(f"Error parsing --minWearPerDay: {e}")
sys.exit(-1)

if args.calOffset != [0, 0, 0] or args.calSlope != [1, 1, 1] or args.calTemp != [0, 0, 0]:
args.skipCalibration = True
warnings.warn('Skipping calibration as coefficients supplied')
Expand Down Expand Up @@ -342,7 +358,8 @@ def deleteIntermediateFiles():
activityModel=args.activityModel,
intensityDistribution=args.intensityDistribution,
psd=args.psd, fourierFrequency=args.fourierFrequency,
fourierWithAcc=args.fourierWithAcc, m10l5=args.m10l5)
fourierWithAcc=args.fourierWithAcc, m10l5=args.m10l5,
minWearPerDay=args.minWearPerDay)

# Generate time series file
accelerometer.utils.writeTimeSeries(epochData, labels, args.tsFile)
Expand Down
62 changes: 54 additions & 8 deletions src/accelerometer/summarisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def getActivitySummary( # noqa: C901
removeSpuriousSleep=True, removeSpuriousSleepTol=60,
activityModel="walmsley",
intensityDistribution=False, imputation=True,
psd=False, fourierFrequency=False, fourierWithAcc=False, m10l5=False
psd=False, fourierFrequency=False, fourierWithAcc=False, m10l5=False,
minWearPerDay=None
):
"""
Calculate overall activity summary from <epochFile> data. Get overall
Expand Down Expand Up @@ -50,6 +51,9 @@ def getActivitySummary( # noqa: C901
:param bool intensityDistribution: Add intensity outputs to dict <summary>
:param bool imputation: Impute missing data using data from other days around the same time
:param bool verbose: Print verbose output
:param float minWearPerDay: Minimum wear time (in hours) required for a day to be
included in summary statistics. Days with less wear time will be excluded.
If None, all days are included.

:return: A tuple containing a pandas dataframe of activity epoch data,
activity prediction labels (empty if <activityClassification>==False), and
Expand Down Expand Up @@ -126,7 +130,7 @@ def getActivitySummary( # noqa: C901
circadian.calculateM10L5(imputeMissing(data[['acc'] + labels]), epochPeriod, summary)

# Main movement summaries
writeMovementSummaries(data, labels, summary)
writeMovementSummaries(data, labels, summary, minWearPerDay)

# Return physical activity summary
return data, labels
Expand Down Expand Up @@ -325,13 +329,15 @@ def calculateECDF(x, summary):
summary[f'{x.name}-ecdf-{level}mg'] = val


def writeMovementSummaries(data, labels, summary): # noqa: C901
def writeMovementSummaries(data, labels, summary, minWearPerDay=None): # noqa: C901
"""Write overall summary stats for each activity type to summary dict

:param pandas.DataFrame e: Pandas dataframe of epoch data
:param list(str) labels: Activity state labels
:param dict summary: Output dictionary containing all summary metrics
:param bool imputation: Impute missing data using data from other days around the same time
:param float minWearPerDay: Minimum wear time (in hours) required for a day to be
included in summary statistics. Days with less wear time will be excluded.
If None, all days are included.

:return: Write dict <summary> keys for each activity type 'overall-<avg/sd>',
'week<day/end>-avg', '<day..>-avg', 'hourOfDay-<hr..>-avg',
Expand All @@ -353,27 +359,67 @@ def writeMovementSummaries(data, labels, summary): # noqa: C901
* epochInHours
).reset_index(drop=True)

# Filter days based on minimum wear time threshold
validDays = pd.Series([True] * len(dailyStats))
if minWearPerDay is not None:
validDays = dailyStats['wearTime'] >= minWearPerDay
numExcludedDays = (~validDays).sum()
numIncludedDays = validDays.sum()

summary['wearTime-numDaysExcluded'] = int(numExcludedDays)
summary['wearTime-numDaysIncluded'] = int(numIncludedDays)
summary['wearTime-minWearPerDayThreshold(hrs)'] = minWearPerDay

if numExcludedDays > 0:
utils.toScreen(f"Excluding {numExcludedDays} day(s) with wear time < {minWearPerDay}h")
utils.toScreen(f"Including {numIncludedDays} day(s) with wear time >= {minWearPerDay}h")

# Write per-day statistics (including excluded days for transparency)
for i, row in dailyStats.iterrows():
included = validDays.iloc[i]
for col in cols:
summary[f'day{i}-recorded-{col}(hrs)'] = row.loc[col]
summary[f'day{i}-includedInSummary'] = int(included)

# Mark epochs from excluded days in the data
data['excludeDay'] = False
if minWearPerDay is not None and numExcludedDays > 0:
excludedDayIndices = dailyStats.index[~validDays].tolist()
for dayIdx in excludedDayIndices:
dayDate = dailyStats.index[dailyStats.index == dayIdx][0]
# Get the actual date from data corresponding to this day index
allDates = data.index.date
uniqueDates = pd.Series(allDates).unique()
if dayIdx < len(uniqueDates):
targetDate = uniqueDates[dayIdx]
data.loc[data.index.date == targetDate, 'excludeDay'] = True

# In the following, we resample, pad and impute the data so that we have a
# multiple of 24h for the stats calculations
tStart, tEnd = data.index[0], data.index[-1]
cols = ['acc', 'wearTime'] + labels
if 'MET' in data.columns:
cols.append('MET')
data = imputeMissing(data[cols].astype('float'))

# Filter out excluded days before imputation and summary calculation
dataForSummary = data.copy()
if minWearPerDay is not None and numExcludedDays > 0:
# Set excluded days to missing for imputation purposes
dataForSummary.loc[dataForSummary['excludeDay'], cols] = np.nan
dataForSummary.loc[dataForSummary['excludeDay'], 'missing'] = True

dataForSummary = imputeMissing(dataForSummary[cols].astype('float'))

# Overall stats (no padding, i.e. only within recording period)
overallStats = data[tStart:tEnd].apply(['mean', 'std'])
# Only calculate on included days
overallStats = dataForSummary[tStart:tEnd].apply(['mean', 'std'])
for col in overallStats:
summary[f'{col}-overall-avg'] = overallStats[col].loc['mean']
summary[f'{col}-overall-sd'] = overallStats[col].loc['std']

dayOfWeekStats = (
data
.groupby([data.index.weekday, data.index.hour])
dataForSummary
.groupby([dataForSummary.index.weekday, dataForSummary.index.hour])
.mean()
)
dayOfWeekStats.index = dayOfWeekStats.index.set_levels(
Expand Down
46 changes: 46 additions & 0 deletions src/accelerometer/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,52 @@ def date_strftime(t):
return t.strftime(f'%Y-%m-%d %H:%M:%S.%f%z [{tz}]')


def parseTimeString(timeStr):
"""
Parse a time string and return the value in hours.

Supports formats like:
- '20h' or '20H' -> 20 hours
- '1200m' or '1200M' -> 20 hours (1200 minutes)
- '0.5d' or '0.5D' -> 12 hours (0.5 days)
- '20' -> 20 hours (default unit is hours)

:param str timeStr: Time string to parse
:return: Time value in hours
:rtype: float

.. code-block:: python

import accUtils
hours = accUtils.parseTimeString('20h') # returns 20.0
hours = accUtils.parseTimeString('1200m') # returns 20.0
"""
if timeStr is None:
return None

timeStr = str(timeStr).strip()

# Extract number and unit
match = re.match(r'^([0-9.]+)([hdmHDM]?)$', timeStr)
if not match:
raise ValueError(f"Invalid time format: '{timeStr}'. "
"Expected format: number followed by optional unit (h/m/d), "
"e.g., '20h', '1200m', '0.5d', or '20'")

value = float(match.group(1))
unit = match.group(2).lower() if match.group(2) else 'h' # default to hours

# Convert to hours
if unit == 'h':
return value
elif unit == 'm':
return value / 60.0
elif unit == 'd':
return value * 24.0
else:
raise ValueError(f"Unknown time unit: '{unit}'. Use 'h' (hours), 'm' (minutes), or 'd' (days)")


def writeTimeSeries(e, labels, tsFile):
"""
Write activity timeseries file
Expand Down
Loading