Skip to content

Commit

Permalink
Fixes issue #585 ( bug in io.timestamp2datetime ) (#586)
Browse files Browse the repository at this point in the history
* Fix bug in io.timestamp2datetime (Issue #585)

Some dates are assigned wrongly when Day, Month and Year are set individually, as the datetime object internally/silently adjusts month if day is not valid

* Update timestamp2datetime.m

Fix: Should not preallocate datetimeArray as it is not known beforehand whether the timestamp has a timezone or not (datetime.empty contrains to datetime w/o timezone)

* Create testTimestampToDatetime.m

Add unit test for io.timestamp2datetime function
  • Loading branch information
ehennestad authored Sep 10, 2024
1 parent 529e086 commit 904ff14
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 21 deletions.
40 changes: 19 additions & 21 deletions +io/timestamp2datetime.m
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
function Datetimes = timestamp2datetime(timestamps)
%TIMESTAMP2DATETIME converts string timestamps to MATLAB datetime object
function datetimeArray = timestamp2datetime(timestamps)
%TIMESTAMP2DATETIME converts string timestamps to MATLAB datetime object(s)

timestamps = timestamp2cellstr(timestamps);

for iTimestamp = 1:length(timestamps)
timestampString = timestamps{iTimestamp};
try
Expand All @@ -17,15 +18,15 @@
rethrow(ME);
end
end
Datetimes(iTimestamp) = Datetime;
datetimeArray(iTimestamp) = Datetime; %#ok<AGROW>
end
end

function Datetime = detectDatetime(timestamp)
errorId = 'NWB:InvalidTimestamp';
errorTemplate = sprintf('Timestamp `%s` is not a valid ISO8601 subset for NWB:\n %%s', timestamp);
Datetime = datetime(0, 0, 0, 0, 0, 0, 0);
%% YMoD

%% Parse year, month and date (YMoD)
hmsStart = find(timestamp == 'T', 1);
if isempty(hmsStart)
ymdStamp = timestamp;
Expand All @@ -35,22 +36,18 @@
errorMessage = sprintf(errorTemplate, 'YMD should be in the form YYYY-mm-dd or YYYYmmdd');
if contains(ymdStamp, '-')
assert(length(ymdStamp) == 10, errorId, errorMessage);
YmdToken = struct(...
'Year', ymdStamp(1:4) ...
, 'Month', ymdStamp(6:7) ...
, 'Day', ymdStamp(9:10) ...
);
yearNum = str2double( ymdStamp(1:4) );
monthNum = str2double( ymdStamp(6:7) );
dayNum = str2double( ymdStamp(9:10) );
else
assert(length(ymdStamp) == 8, errorId, errorMessage);
YmdToken = struct(...
'Year', ymdStamp(1:4) ...
, 'Month', ymdStamp(5:6) ...
, 'Day', ymdStamp(7:8) ...
);
yearNum = str2double( ymdStamp(1:4) );
monthNum = str2double( ymdStamp(5:6) );
dayNum = str2double( ymdStamp(7:8) );
end
Datetime.Day = str2double(YmdToken.Day);
Datetime.Month = str2double(YmdToken.Month);
Datetime.Year = str2double(YmdToken.Year);

Datetime = datetime(yearNum, monthNum, dayNum, 0, 0, 0, 0);

assert(~isnat(Datetime), errorId, sprintf(errorTemplate, 'non-numeric YMD values detected'));

%% HMiS TZ
Expand Down Expand Up @@ -116,8 +113,9 @@
elseif ischar(timestamps)
cells = {timestamps};
else
error(['timestamps must be a ' ...
, 'string, character array, or cell array of strings/character arrays.']);
errorId = "NWB:timestamp2datetime:MustBeCharCellArrayOrString";
errorMsg = ['timestamps must be a string, character array, ', ...
'or cell array of strings/character arrays.'];
error(errorId, errorMsg);
end
end

107 changes: 107 additions & 0 deletions +tests/+unit/+io/testTimestampToDatetime.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
classdef testTimestampToDatetime < matlab.unittest.TestCase
% Specific tests for function io.timestamp2datetime

properties
TimestampList = ["2023-05-05", "2024-06-06"]
Expected = datetime(["2023-05-05", "2024-06-06"], "InputFormat", "uuuu-MM-dd")
end

properties
NWBDefaultStringFormat = "uuuu-MM-dd'T'HH:mm:ss.SSSSSSZZZZZ";
end

methods (Test)
function testNonDatestamp(testCase)
testCase.verifyError(@() io.timestamp2datetime("Hello World"), ...
"NWB:InvalidTimestamp")
end

function testNonStringInput(testCase)
timestamp = [2022, 3, 4];
testCase.verifyError(@() io.timestamp2datetime(timestamp), ...
"NWB:timestamp2datetime:MustBeCharCellArrayOrString" )
end

function testCharInput(testCase)
timestamps = char(testCase.TimestampList(1)); % Convert to char
actual = io.timestamp2datetime(timestamps);
testCase.verifyEqual(actual, testCase.Expected(1));
end

function testStringInput(testCase)
actual = io.timestamp2datetime(testCase.TimestampList);
testCase.verifyEqual(actual, testCase.Expected);
end

function testCellArrayInput(testCase)
timestamps = cellstr(testCase.TimestampList); % Convert to cell

actual = io.timestamp2datetime(timestamps);
testCase.verifyEqual(actual, testCase.Expected);
end

function testValidTimestampWithTimezone(testCase)
timestamp = "2023-07-23T15:30:00Z";
expected = datetime(timestamp, "InputFormat", "uuuu-MM-dd'T'HH:mm:ssZ", 'TimeZone', 'UTC');
actual = io.timestamp2datetime(timestamp);
testCase.verifyEqual(actual, expected);
end

function testTimestampWithInvalidTimeZone(testCase)
timestamps = "20230723T15000000ZAmerica/Oslo";
testCase.verifyError(@() io.timestamp2datetime(timestamps), 'MATLAB:datetime:UnknownTimeZone');
end

function testPartialTimestampWithDash(testCase)
partialTimestamp = "2023-07";
testCase.verifyError(@() io.timestamp2datetime(partialTimestamp), 'NWB:InvalidTimestamp');
end

function testPartialTimestampWithoutDash(testCase)
timestamps = "20230723";
actual = io.timestamp2datetime(timestamps);
expected = datetime("2023-07-23", "InputFormat", "uuuu-MM-dd");
testCase.verifyEqual(actual, expected);
end

function testTimestampWithTimeNoTimeZone(testCase)
timestamp = "20230723T15:00:00";
expected = datetime("2023-07-23T15:00:00", "InputFormat", "uuuu-MM-dd'T'HH:mm:ss");
actual = io.timestamp2datetime(timestamp);
testCase.verifyEqual(actual, expected);
end

function testTimestampWithMilliseconds(testCase)
timestamp = "20230723T150000000";
expected = datetime("2023-07-23T15:00:00", "InputFormat", "uuuu-MM-dd'T'HH:mm:ss");
actual = io.timestamp2datetime(timestamp);
testCase.verifyEqual(actual, expected);
end

function testCurrentYear(testCase)
currentYear = year(datetime("now", 'TimeZone', 'local')); % Specify the year
startDate = datetime(currentYear, 1, 1, 'TimeZone', 'local'); % Start date: January 1st
endDate = datetime(currentYear, 12, 31, 'TimeZone', 'local'); % End date: December 31st

% Generate all dates for the year
allDates = startDate:calmonths(1):endDate;

% Alternative: Only test last day of each month:
% allDates = (startDate:calmonths(1):endDate) + calmonths(1) - caldays(1);
allDates.Format = testCase.NWBDefaultStringFormat;

numFailed = 0;

for i = 1:numel(allDates)
actual = io.timestamp2datetime( string(allDates(i)) );
expected = allDates(i);

if ~isequal(actual, expected)
numFailed = numFailed + 1;
end
end

testCase.verifyEqual(numFailed, 0)
end
end
end

0 comments on commit 904ff14

Please sign in to comment.