From 1bb042bc787d43653d8fdd3c63b1226b8c03f7eb Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Thu, 25 Mar 2021 11:39:48 +1000 Subject: [PATCH] Add some methods to parse ISO8601 duration strings (eg '2021-03-23T00:00:00Z/2021-03-24T12:00:00Z/PT12H') to a list of datetimes --- .../auto_generated/qgstemporalutils.sip.in | 33 +++++ src/core/qgstemporalutils.cpp | 83 +++++++++++++ src/core/qgstemporalutils.h | 65 ++++++++++ tests/src/python/test_qgstemporalutils.py | 116 ++++++++++++++++++ 4 files changed, 297 insertions(+) diff --git a/python/core/auto_generated/qgstemporalutils.sip.in b/python/core/auto_generated/qgstemporalutils.sip.in index 8eb0f787378..de0a3eb690e 100644 --- a/python/core/auto_generated/qgstemporalutils.sip.in +++ b/python/core/auto_generated/qgstemporalutils.sip.in @@ -10,6 +10,8 @@ + + class QgsTemporalUtils { %Docstring(signature="appended") @@ -100,6 +102,37 @@ the number of days in the current month. .. versionadded:: 3.18 %End + + static QList< QDateTime > calculateDateTimesUsingDuration( const QDateTime &start, const QDateTime &end, const QString &duration, bool &ok /Out/, bool &maxValuesExceeded /Out/, int maxValues = -1 ); +%Docstring +Calculates a complete list of datetimes between ``start`` and ``end``, using the specified ISO8601 ``duration`` string (eg "PT12H"). + +:param start: start date time +:param end: end date time +:param duration: ISO8601 duration string +:param maxValues: maximum number of values to return, or -1 to return all values + +:return: - calculated list of date times + - ok: will be set to ``True`` if ``duration`` was successfully parsed and date times could be calculated + - maxValuesExceeded: will be set to ``True`` if the maximum number of values to return was exceeded + +.. versionadded:: 3.20 +%End + + static QList< QDateTime > calculateDateTimesFromISO8601( const QString &string, bool &ok /Out/, bool &maxValuesExceeded /Out/, int maxValues = -1 ); +%Docstring +Calculates a complete list of datetimes from a ISO8601 ``string`` containing a duration (eg "2021-03-23T00:00:00Z/2021-03-24T12:00:00Z/PT12H"). + +:param string: ISO8601 compatible string +:param maxValues: maximum number of values to return, or -1 to return all values + +:return: - calculated list of date times + - ok: will be set to ``True`` if ``string`` was successfully parsed and date times could be calculated + - maxValuesExceeded: will be set to ``True`` if the maximum number of values to return was exceeded + +.. versionadded:: 3.20 +%End + }; diff --git a/src/core/qgstemporalutils.cpp b/src/core/qgstemporalutils.cpp index 5609bad537d..be7c255e9b9 100644 --- a/src/core/qgstemporalutils.cpp +++ b/src/core/qgstemporalutils.cpp @@ -209,3 +209,86 @@ QDateTime QgsTemporalUtils::calculateFrameTime( const QDateTime &start, const lo } } +QList QgsTemporalUtils::calculateDateTimesUsingDuration( const QDateTime &start, const QDateTime &end, const QString &duration, bool &ok, bool &maxValuesExceeded, int maxValues ) +{ + ok = false; + const QgsTimeDuration timeDuration( QgsTimeDuration::fromString( duration, ok ) ); + if ( !ok ) + return {}; + + if ( timeDuration.years == 0 && timeDuration.months == 0 && timeDuration.weeks == 0 && timeDuration.days == 0 + && timeDuration.hours == 0 && timeDuration.minutes == 0 && timeDuration.seconds == 0 ) + { + ok = false; + return {}; + } + + QList res; + QDateTime current = start; + maxValuesExceeded = false; + while ( current <= end ) + { + res << current; + + if ( maxValues >= 0 && res.size() > maxValues ) + { + maxValuesExceeded = true; + break; + } + + if ( timeDuration.years ) + current = current.addYears( timeDuration.years ); + if ( timeDuration.months ) + current = current.addMonths( timeDuration.months ); + if ( timeDuration.weeks || timeDuration.days ) + current = current.addDays( timeDuration.weeks * 7 + timeDuration.days ); + if ( timeDuration.hours || timeDuration.minutes || timeDuration.seconds ) + current = current.addSecs( timeDuration.hours * 60LL * 60 + timeDuration.minutes * 60 + timeDuration.seconds ); + } + return res; +} + +QList QgsTemporalUtils::calculateDateTimesFromISO8601( const QString &string, bool &ok, bool &maxValuesExceeded, int maxValues ) +{ + ok = false; + maxValuesExceeded = false; + const QStringList parts = string.split( '/' ); + if ( parts.length() != 3 ) + { + return {}; + } + + const QDateTime start = QDateTime::fromString( parts.at( 0 ), Qt::ISODate ); + if ( !start.isValid() ) + return {}; + const QDateTime end = QDateTime::fromString( parts.at( 1 ), Qt::ISODate ); + if ( !end.isValid() ) + return {}; + + return calculateDateTimesUsingDuration( start, end, parts.at( 2 ), ok, maxValuesExceeded, maxValues ); +} + +// +// QgsTimeDuration +// + +QgsTimeDuration QgsTimeDuration::fromString( const QString &string, bool &ok ) +{ + ok = false; + thread_local QRegularExpression sRx( QStringLiteral( R"(P(?:([\d]+)Y)?(?:([\d]+)M)?(?:([\d]+)W)?(?:([\d]+)D)?(?:T(?:([\d]+)H)?(?:([\d]+)M)?(?:([\d\.]+)S)?)?$)" ) ); + + const QRegularExpressionMatch match = sRx.match( string ); + QgsTimeDuration duration; + if ( match.hasMatch() ) + { + ok = true; + duration.years = match.capturedView( 1 ).toInt(); + duration.months = match.capturedView( 2 ).toInt(); + duration.weeks = match.capturedView( 3 ).toInt(); + duration.days = match.capturedView( 4 ).toInt(); + duration.hours = match.capturedView( 5 ).toInt(); + duration.minutes = match.capturedView( 6 ).toInt(); + duration.seconds = match.capturedView( 7 ).toDouble(); + } + return duration; +} diff --git a/src/core/qgstemporalutils.h b/src/core/qgstemporalutils.h index 65b4fb04812..d0b66ca1fdc 100644 --- a/src/core/qgstemporalutils.h +++ b/src/core/qgstemporalutils.h @@ -25,6 +25,46 @@ class QgsMapSettings; class QgsFeedback; class QgsMapDecoration; +#ifndef SIP_RUN + +/** + * \ingroup core + * \class QgsTimeDuration + * \brief Contains utility methods for working with temporal layers and projects. + * + * Designed for storage of ISO8601 duration values. + * + * \note Not available in Python bindings + * \since QGIS 3.20 + */ +class CORE_EXPORT QgsTimeDuration +{ + public: + + //! Years + int years = 0; + //! Months + int months = 0; + //! Weeks + int weeks = 0; + //! Days + int days = 0; + //! Hours + int hours = 0; + //! Minutes + int minutes = 0; + //! Seconds + double seconds = 0; + + /** + * Creates a QgsTimeDuration from a \a string value. + */ + static QgsTimeDuration fromString( const QString &string, bool &ok ); + +}; +#endif + + /** * \ingroup core * \class QgsTemporalUtils @@ -125,6 +165,31 @@ class CORE_EXPORT QgsTemporalUtils * \since QGIS 3.18 */ static QDateTime calculateFrameTime( const QDateTime &start, const long long frame, const QgsInterval interval ); + + /** + * Calculates a complete list of datetimes between \a start and \a end, using the specified ISO8601 \a duration string (eg "PT12H"). + * \param start start date time + * \param end end date time + * \param duration ISO8601 duration string + * \param ok will be set to TRUE if \a duration was successfully parsed and date times could be calculated + * \param maxValuesExceeded will be set to TRUE if the maximum number of values to return was exceeded + * \param maxValues maximum number of values to return, or -1 to return all values + * \returns calculated list of date times + * \since QGIS 3.20 + */ + static QList< QDateTime > calculateDateTimesUsingDuration( const QDateTime &start, const QDateTime &end, const QString &duration, bool &ok SIP_OUT, bool &maxValuesExceeded SIP_OUT, int maxValues = -1 ); + + /** + * Calculates a complete list of datetimes from a ISO8601 \a string containing a duration (eg "2021-03-23T00:00:00Z/2021-03-24T12:00:00Z/PT12H"). + * \param string ISO8601 compatible string + * \param ok will be set to TRUE if \a string was successfully parsed and date times could be calculated + * \param maxValuesExceeded will be set to TRUE if the maximum number of values to return was exceeded + * \param maxValues maximum number of values to return, or -1 to return all values + * \returns calculated list of date times + * \since QGIS 3.20 + */ + static QList< QDateTime > calculateDateTimesFromISO8601( const QString &string, bool &ok SIP_OUT, bool &maxValuesExceeded SIP_OUT, int maxValues = -1 ); + }; diff --git a/tests/src/python/test_qgstemporalutils.py b/tests/src/python/test_qgstemporalutils.py index 2558d7eec0d..d2758040b51 100644 --- a/tests/src/python/test_qgstemporalutils.py +++ b/tests/src/python/test_qgstemporalutils.py @@ -144,6 +144,122 @@ class TestQgsTemporalUtils(unittest.TestCase): QgsInterval(0.2, unit)) self.assertEqual(f, expected3[unit]) + def testCalculateDateTimesUsingDuration(self): + # invalid duration string + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 3, 24), QTime(12, 0, 0)), 'xT12H') + self.assertFalse(ok) + # null duration string + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 3, 24), QTime(12, 0, 0)), '') + self.assertFalse(ok) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 3, 24), QTime(12, 0, 0)), 'P') + self.assertFalse(ok) + + # valid durations + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 3, 24), QTime(12, 0, 0)), 'PT12H') + self.assertEqual(vals, [QDateTime(2021, 3, 23, 0, 0), + QDateTime(2021, 3, 23, 12, 0), + QDateTime(2021, 3, 24, 0, 0), + QDateTime(2021, 3, 24, 12, 0)]) + self.assertTrue(ok) + self.assertFalse(exceeded) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 3, 24), QTime(12, 0, 0)), 'PT12H', maxValues=2) + self.assertEqual(vals, [QDateTime(2021, 3, 23, 0, 0), + QDateTime(2021, 3, 23, 12, 0), + QDateTime(2021, 3, 24, 0, 0)]) + self.assertTrue(ok) + self.assertTrue(exceeded) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 3, 24), QTime(12, 0, 0)), 'PT10H2M5S') + self.assertEqual(vals, [QDateTime(2021, 3, 23, 0, 0), QDateTime(2021, 3, 23, 10, 2, 5), + QDateTime(2021, 3, 23, 20, 4, 10), QDateTime(2021, 3, 24, 6, 6, 15)]) + self.assertTrue(ok) + self.assertFalse(exceeded) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2010, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 5, 24), QTime(12, 0, 0)), 'P2Y') + self.assertEqual(vals, + [QDateTime(2010, 3, 23, 0, 0), QDateTime(2012, 3, 23, 0, 0), QDateTime(2014, 3, 23, 0, 0), + QDateTime(2016, 3, 23, 0, 0), QDateTime(2018, 3, 23, 0, 0), QDateTime(2020, 3, 23, 0, 0)]) + self.assertTrue(ok) + self.assertFalse(exceeded) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2020, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 5, 24), QTime(12, 0, 0)), 'P2M') + self.assertEqual(vals, + [QDateTime(2020, 3, 23, 0, 0), QDateTime(2020, 5, 23, 0, 0), QDateTime(2020, 7, 23, 0, 0), + QDateTime(2020, 9, 23, 0, 0), QDateTime(2020, 11, 23, 0, 0), QDateTime(2021, 1, 23, 0, 0), + QDateTime(2021, 3, 23, 0, 0), QDateTime(2021, 5, 23, 0, 0)]) + self.assertTrue(ok) + self.assertFalse(exceeded) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 5, 24), QTime(12, 0, 0)), 'P2W') + self.assertEqual(vals, [QDateTime(2021, 3, 23, 0, 0), QDateTime(2021, 4, 6, 0, 0), QDateTime(2021, 4, 20, 0, 0), + QDateTime(2021, 5, 4, 0, 0), QDateTime(2021, 5, 18, 0, 0)]) + self.assertTrue(ok) + self.assertFalse(exceeded) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2021, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 4, 7), QTime(12, 0, 0)), 'P2D') + self.assertEqual(vals, + [QDateTime(2021, 3, 23, 0, 0), QDateTime(2021, 3, 25, 0, 0), QDateTime(2021, 3, 27, 0, 0), + QDateTime(2021, 3, 29, 0, 0), QDateTime(2021, 3, 31, 0, 0), QDateTime(2021, 4, 2, 0, 0), + QDateTime(2021, 4, 4, 0, 0), QDateTime(2021, 4, 6, 0, 0)]) + self.assertTrue(ok) + self.assertFalse(exceeded) + + # complex mix + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesUsingDuration( + QDateTime(QDate(2010, 3, 23), QTime(0, 0, 0)), + QDateTime(QDate(2021, 5, 24), QTime(12, 0, 0)), 'P2Y1M3W4DT5H10M22S') + self.assertEqual(vals, [QDateTime(2010, 3, 23, 0, 0), QDateTime(2012, 5, 18, 5, 10, 22), + QDateTime(2014, 7, 13, 10, 20, 44), QDateTime(2016, 9, 7, 15, 31, 6), + QDateTime(2018, 11, 1, 20, 41, 28), QDateTime(2020, 12, 27, 1, 51, 50)]) + self.assertTrue(ok) + self.assertFalse(exceeded) + + def testCalculateDateTimesFromISO8601(self): + # invalid duration string + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesFromISO8601('x') + self.assertFalse(ok) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesFromISO8601( + 'a-03-23T00:00:00Z/2021-03-24T12:00:00Z/PT12H') + self.assertFalse(ok) + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesFromISO8601( + '2021-03-23T00:00:00Z/b-03-24T12:00:00Z/PT12H') + self.assertFalse(ok) + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesFromISO8601( + '2021-03-23T00:00:00Z/2021-03-24T12:00:00Z/xc') + self.assertFalse(ok) + + vals, ok, exceeded = QgsTemporalUtils.calculateDateTimesFromISO8601( + '2021-03-23T00:00:00Z/2021-03-24T12:00:00Z/PT12H') + self.assertEqual(vals, [QDateTime(2021, 3, 23, 0, 0, 0, 0, Qt.TimeSpec(1)), + QDateTime(2021, 3, 23, 12, 0, 0, 0, Qt.TimeSpec(1)), + QDateTime(2021, 3, 24, 0, 0, 0, 0, Qt.TimeSpec(1)), + QDateTime(2021, 3, 24, 12, 0, 0, 0, Qt.TimeSpec(1))]) + self.assertTrue(ok) + self.assertFalse(exceeded) + if __name__ == '__main__': unittest.main()