diff --git a/README.md b/README.md index 1a560d0..51629fd 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ and modules which are documented and supported. 4.12 [Quaternions](./README.md#412-quaternions) Scale, move and rotate 3D objects with minimal mathematics. 4.13 [A Pyboard power meter](./README.md#413-a-pyboard-power-meter) One of my own projects. 4.14 [NTP time](./README.md#414-ntp-time) More portable than official driver with other benefits. + 4.15 [Date](./README.md#415-date) Small and simple classes for handling dates. 5. [Module Index](./README.md#5-module-index) Supported code. Device drivers, GUI's, utilities. 5.1 [uasyncio](./README.md#51-uasyncio) Tutorial and drivers for asynchronous coding. 5.2 [Memory Device Drivers](./README.md#52-memory-device-drivers) Drivers for nonvolatile memory devices. @@ -341,6 +342,20 @@ and point the MicroPython device at the local server with: ntptime.host="192.168.0.10" # Server address. ntptime.time() ``` +## 4.15 Date + +The official [datetime module](https://github.com/micropython/micropython-lib/tree/master/python-stdlib/datetime) +is fully featured but substantial. This `Date` class has no concept of time, +but is very compact. Dates are stored as a small int. Contrary to normal MP +practice, properties are used. This allows basic arithmetic syntax while +ensuring automatic rollover. The speed penalty of properties is unlikely to be +a factor in date operations. + +The `Date` class provides basic arithmetic and comparison methods. The +`DateCal` subclass adds pretty printing and methods to assist in creating +calendars. + +The classes are documented [here](./date/DATE.md) ##### [Index](./README.md#0-index) diff --git a/date/DATE.md b/date/DATE.md new file mode 100644 index 0000000..f09595e --- /dev/null +++ b/date/DATE.md @@ -0,0 +1,114 @@ +# Simple Date classes + +The official [datetime module](https://github.com/micropython/micropython-lib/tree/master/python-stdlib/datetime) +is fully featured but substantial. This `Date` class has no concept of time, +but is very compact. Dates are stored as a small int. Contrary to normal MP +practice, properties are used. This allows basic arithmetic syntax while +ensuring automatic rollover. The speed penalty of properties is unlikely to be +a factor in date operations. + +The `Date` class provides basic arithmetic and comparison methods. The +`DateCal` subclass adds pretty printing and methods to assist in creating +calendars. + +[Return to main readme](../README.md) + +# Date class + +The `Date` class embodies a single date value which may be modified, copied +and compared with other `Date` instances. + +## Constructor + +This takes a single optional arg: + * `lt=None` By default the date is initialised from system time. To set the + date from another time source, a valid + [localtime/gmtime](http://docs.micropython.org/en/latest/library/time.html#time.localtime) + tuple may be passed. + +## Method + + * `now` Arg `lt=None`. Sets the instance to the current date, from system time + or `lt` as described above. + +## Writeable properties + + * `year` e.g. 2023. + * `month` 1 == January. May be set to any number, years will roll over if + necessary. e.g. `d.month += 15` or `d.month -= 1`. + * `mday` Adjust day in current month. Allowed range `1..month_length`. + * `day` Days since epoch. Note that the epoch varies with platform - the value + may be treated as an opaque small integer. Use to adjust a date with rollover + (`d.day += 7`) or to assign one date to another (`date2.day = date1.day`). May + also be used to represnt a date as a small int for saving to a file. + +## Read-only property + + * `wday` Day of week. 0==Monday 6==Sunday. + +## Date comparisons + +Python "magic methods" enable date comparisons using standard operators `<`, +`<=`, `>`, `>=`, `==`, `!=`. + +# DateCal class + +This adds pretty formatting and functionality to return additional information +about the current date. The added methods and properties do not change the +date value. Primarily intended for calendars. + +## Constructor + +This takes a single optional arg: + * `lt=None` See `Date` constructor. + +## Methods + + * `time_offset` arg `hr=6`. This returns 0 or 1, being the offset in hours of + UK local time to UTC. By default the change occurs when the date changes at + 00:00 UTC on the last Sunday in March and October. If an hour value is passed, + the change will occur at the correct 01:00 UTC. This method will need to be + adapted for other geographic locations. + * `wday_n` arg `mday=1`. Return the weekday for a given day of the month. + * `mday_list` arg `wday`. Given a weekday, for the current month return an + ordered list of month days matching that weekday. + +## Read-only properties + + * `month_length` Length of month in days. + * `day_str` Day of week as a string, e.g. "Wednesday". + * `month_str` Month as a string, e.g. "August". + +## Class variables + + * `days` A 7-tuple `("Monday", "Tuesday"...)` + * `months` A 12-tuple `("January", "February",...)` + +# Example usage + +```python +from date import Date +d = Date() +d.month = 1 # Set to January +d.month -= 2 # Date changes to same mday in November previous year. +d.mday = 25 # Set absolute day of month +d.day += 7 # Advance date by one week. Month/year rollover is handled. +today = Date() +if d == today: # Date comparisons + # do something +new_date = Date() +new_date.day = d.day # Assign d to new_date: now new_date == d. +print(d) # Basic numeric print. +``` +The DateCal class: +```python +from date import DateCal +d = DateCal() +# Correct a UK clock for DST +d.now() +hour = (hour_utc + d.time_offset(hour_utc)) % 24 +print(d) # Pretty print +x = d.wday_n(1) # Get day of week of 1st day of month +sundays = d.mday_list(6) # List Sundays for the month. +wday_last = d.wday_n(d.month_length) # Weekday of last day of month +``` diff --git a/date/date.py b/date/date.py new file mode 100644 index 0000000..2601ee0 --- /dev/null +++ b/date/date.py @@ -0,0 +1,160 @@ +# date.py Minimal Date class for micropython + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +from time import mktime, localtime + +_SECS_PER_DAY = const(86400) +def leap(year): + return bool((not year % 4) ^ (not year % 100)) + +class Date: + + def __init__(self, lt=None): + self.now(lt) + + def now(self, lt=None): + self._lt = list(localtime()) if lt is None else list(lt) + self._update() + + def _update(self, ltmod=True): # If ltmod is False ._cur has been changed + if ltmod: # Otherwise ._lt has been modified + self._lt[3] = 6 + self._cur = mktime(self._lt) // _SECS_PER_DAY + self._lt = list(localtime(self._cur * _SECS_PER_DAY)) + + def _mlen(self, d=bytearray((31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31))): + days = d[self._lt[1] - 1] + return days if days else (29 if leap(self._lt[0]) else 28) + + @property + def year(self): + return self._lt[0] + + @year.setter + def year(self, v): + if self.mday == 29 and self.month == 2 and not leap(v): + self.mday = 28 # Ensure it doesn't skip a month + self._lt[0] = v + self._update() + + @property + def month(self): + return self._lt[1] + + # Can write d.month = 4 or d.month += 15 + @month.setter + def month(self, v): + y, m = divmod(v - 1, 12) + self._lt[0] += y + self._lt[1] = m + 1 + self._lt[2] = min(self._lt[2], self._mlen()) + self._update() + + @property + def mday(self): + return self._lt[2] + + @mday.setter + def mday(self, v): + if not 0 < v <= self._mlen(): + raise ValueError(f"mday {v} is out of range") + self._lt[2] = v + self._update() + + @property + def day(self): # Days since epoch. + return self._cur + + @day.setter + def day(self, v): # Usuge: d.day += 7 or date_1.day = d.day. + self._cur = v + self._update(False) # Flag _cur change + + # Read-only properties + + @property + def wday(self): + return self._lt[6] + + # Date comparisons + + def __lt__(self, other): + return self.day < other.day + + def __le__(self, other): + return self.day <= other.day + + def __eq__(self, other): + return self.day == other.day + + def __ne__(self, other): + return self.day != other.day + + def __gt__(self, other): + return self.day > other.day + + def __ge__(self, other): + return self.day >= other.day + + def __str__(self): + return f"{self.year}/{self.month}/{self.mday}" + + +class DateCal(Date): + days = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") + months = ( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ) + + def __init__(self, lt=None): + super().__init__(lt) + + @property + def month_length(self): + return self._mlen() + + @property + def day_str(self): + return self.days[self.wday] + + @property + def month_str(self): + return self.months[self.month - 1] + + def wday_n(self, mday=1): + return (self._lt[6] - self._lt[2] + mday) % 7 + + def mday_list(self, wday): + ml = self._mlen() # 1 + ((wday - wday1) % 7) + d0 = 1 + ((wday - (self._lt[6] - self._lt[2] + 1)) % 7) + return [d for d in range(d0, ml + 1, 7)] + + # Optional: return UK DST offset in hours. Can pass hr to ensure that time change occurs + # at 1am UTC otherwise it occurs on date change (0:0 UTC) + # offs is offset by month + def time_offset(self, hr=6, offs=bytearray((0, 0, 3, 1, 1, 1, 1, 1, 1, 10, 0, 0))): + ml = self._mlen() + wdayld = self.wday_n(ml) # Weekday of last day of month + mday_sun = self.mday_list(6)[-1] # Month day of last Sunday + m = offs[self._lt[1] - 1] + if m < 3: + return m # Deduce time offset from month alone + return int( + ((self._lt[2] < mday_sun) or (self._lt[2] == mday_sun and hr <= 1)) ^ (m == 3) + ) # Months where offset changes + + def __str__(self): + return f"{self.day_str} {self.mday} {self.month_str} {self.year}"