Repository: ul-mds/gecko

Issue:
Add datetime generation and mutation
Generic datetime generation and mutation routines should be part of Gecko.

Discussion:

Solution patch:
diff --git a/docs/data-generation.md b/docs/data-generation.md
index 415a00a..88a647c 100644
--- a/docs/data-generation.md
+++ b/docs/data-generation.md
@@ -156,6 +156,58 @@ print(normal_generator(100))
 # => [[23.77, 17.13, 22.08, 22.07, ..., 21.10, 22.67]]
 ```
 
+### Date and time information
+
+One of the most commonly collected pieces of identifying information are dates of birth.
+More technical sources of dates and times are record creation and update timestamps, as well as other applications of 
+tracing data entry.
+
+Gecko provides `from_datetime_range` to generate random timestamps from a uniform distribution.
+It can utilize any of [Python's built-in format codes for datetime objects](https://docs.python.org/3/library/datetime.html#format-codes)
+to output them to text.
+
+```python
+import numpy as np
+
+from gecko import generator
+
+
+rng = np.random.default_rng(0xcafebabe)
+datetime_generator = generator.from_datetime_range(
+    start_dt="1920-01-01", 
+    end_dt="2020-01-01", 
+    dt_format="%d.%m.%Y", 
+    unit="D", 
+    rng=rng
+)
+
+print(datetime_generator(100))
+# => [["05.05.1967", "07.06.1923", ..., "09.12.1986", "11.11.1943"]]
+```
+
+The "resolution" of the generated strings can be defined by setting the smallest unit of time to alter.
+Gecko can generate unique strings down to days (`D`), hours (`h`), minutes (`m`) and seconds (`s`) respectively.
+Months are years are currently unsupported since the underlying timespans are nonlinear.
+
+```python
+import numpy as np
+
+from gecko import generator
+
+
+rng = np.random.default_rng(0xdeadbeef)
+datetime_generator = generator.from_datetime_range(
+    start_dt="1920-01-01", 
+    end_dt="2020-01-01", 
+    dt_format="%d.%m.%Y %H:%M:%S", 
+    unit="m", 
+    rng=rng
+)
+
+print(datetime_generator(100))
+# => [["26.02.1933 17:57:00", "17.12.1954 03:01:00", ..., "15.02.1950 01:29:00", "24.06.1922 23:46:00"]]
+```
+
 ### Custom generators
 
 Any function that returns a string can be converted into a generator.
@@ -211,7 +263,7 @@ However, users of Faker are responsible for seeding their own RNG instances to a
 
 ```py
 from faker import Faker
-from geco import generator
+from gecko import generator
 
 fake = Faker("de_DE")
 fake.seed_instance(13579)
diff --git a/docs/data-mutation.md b/docs/data-mutation.md
index 529d498..383375b 100644
--- a/docs/data-mutation.md
+++ b/docs/data-mutation.md
@@ -376,6 +376,55 @@ print(uppercase_mutator([srs]))
 # => ["FOOBAR", "FOOBAZ", "FOOBAT"]
 ```
 
+### Date and time offsets
+
+Date and time information is prone to errors where single fields are offset by a couple units.
+This error source is implemented in the `with_datetime_offset` function.
+It requires a range in which time units can be offset by and the format of the data to mutate as expressed
+by [Python's format codes for datetime objects](https://docs.python.org/3/library/datetime.html#format-codes).
+It is possible to apply offsets in units of days (`D`), hours (`h`), minutes (`m`) and seconds (`s`).
+
+```python
+import numpy as np
+import pandas as pd
+
+from gecko import mutator
+
+srs = pd.Series(pd.date_range("2020-01-01", "2020-01-31", freq="D"))
+rng = np.random.default_rng(0xffd8)
+
+datetime_mutator = mutator.with_datetime_offset(
+   max_delta=5, unit="D", dt_format="%Y-%m-%d", rng=rng
+)
+
+print(datetime_mutator([srs]))
+# => ["2019-12-30", "2020-01-03", ..., "2020-01-25", "2020-02-01"]
+```
+
+When applying offsets, it might happen that the offset applied to a single field affects another field, e.g. subtracting
+a day from January 1st, 2020 will wrap around to December 31st, 2019.
+If this is not desired, Gecko offers an extra flag that prevents these types of wraparounds at the cost of leaving 
+affected rows untouched.
+Note how the first and last entry in the output of the snippet below remains unchanged when compared to the previous 
+snippet.
+
+```python
+import numpy as np
+import pandas as pd
+
+from gecko import mutator
+
+srs = pd.Series(pd.date_range("2020-01-01", "2020-01-31", freq="D"))
+rng = np.random.default_rng(0xffd8)
+
+datetime_mutator = mutator.with_datetime_offset(
+   max_delta=5, unit="D", dt_format="%Y-%m-%d", prevent_wraparound=True, rng=rng
+)
+
+print(datetime_mutator([srs]))
+# => ["2020-01-01", "2020-01-03", ..., "2020-01-25", "2020-01-31"]
+```
+
 ## Multiple mutators
 
 Using `mutate_data_frame`, you can apply multiple mutators on many columns at once.
diff --git a/gecko/generator.py b/gecko/generator.py
index cb60977..991ccf7 100644
--- a/gecko/generator.py
+++ b/gecko/generator.py
@@ -10,11 +10,12 @@
     "from_normal_distribution",
     "from_frequency_table",
     "from_multicolumn_frequency_table",
+    "from_datetime_range",
     "to_data_frame",
 ]
 
 from os import PathLike
-from typing import Callable, Optional, Union
+from typing import Callable, Optional, Union, Literal
 
 import numpy as np
 import pandas as pd
@@ -250,6 +251,59 @@ def _generate(count: int) -> list[pd.Series]:
     return _generate
 
 
+def from_datetime_range(
+    start_dt: Union[str, np.datetime64],
+    end_dt: Union[str, np.datetime64],
+    dt_format: str,
+    unit: Literal["D", "h", "m", "s"],
+    rng: Optional[np.random.Generator] = None,
+) -> Generator:
+    """
+    Generate data from a range of dates and times.
+    The start and end datetime must be provided either as a ISO 8601 datetime string or a NumPy datetime object.
+    The output format must include the same format codes as specified in the `datetime` Python module for the
+    `strftime` function.
+    The unit specifies the smallest unit of time that may change when generating random dates and times.
+    For example if `D` is specified, generated dates will only differ in their days, months and years, leaving hours,
+    minutes and seconds unaffected.
+    The same applies for `h`, `m` and `s` for hours, minutes and seconds respectively.
+
+    Args:
+        start_dt: datetime string or object for start of range
+        end_dt: datetime string or object for end of range
+        dt_format: output format for generated datetimes
+        unit: smallest unit of time that may change when generating random dates and times
+        rng: random number generator to use
+
+    Returns:
+        function returning list of random datetime strings within the specified range
+    """
+    if isinstance(start_dt, str):
+        start_dt = np.datetime64(start_dt)
+
+    if isinstance(end_dt, str):
+        end_dt = np.datetime64(end_dt)
+
+    if start_dt >= end_dt:
+        raise ValueError(
+            f"start datetime `{start_dt}` is greater than end datetime `{end_dt}`"
+        )
+
+    if rng is None:
+        rng = np.random.default_rng()
+
+    def _generate(count: int) -> list[pd.Series]:
+        delta_td = end_dt - start_dt
+        delta_amt = int(delta_td / np.timedelta64(1, unit))
+        random_vals = rng.integers(low=0, high=delta_amt, size=count, endpoint=True)
+        random_dts = start_dt + random_vals.astype(f"timedelta64[{unit}]")
+        dt_srs = pd.Series(random_dts)
+
+        return [dt_srs.dt.strftime(dt_format)]
+
+    return _generate
+
+
 def to_data_frame(
     column_to_generator_dict: dict[Union[str, tuple[str, ...]], Generator],
     count: int,
diff --git a/gecko/mutator.py b/gecko/mutator.py
index 09f14b5..71191d6 100644
--- a/gecko/mutator.py
+++ b/gecko/mutator.py
@@ -20,6 +20,7 @@
     "with_permute",
     "with_lowercase",
     "with_uppercase",
+    "with_datetime_offset",
     "mutate_data_frame",
 ]
 
@@ -1212,6 +1213,85 @@ def _mutate(srs_lst: list[pd.Series]) -> list[pd.Series]:
     return _mutate
 
 
+def with_datetime_offset(
+    max_delta: int,
+    unit: Literal["D", "h", "m", "s"],
+    dt_format: str,
+    prevent_wraparound: bool = False,
+    rng: Optional[np.random.Generator] = None,
+) -> Mutator:
+    """
+    Mutate data from a series by treating it as datetime information and offsetting it by random amounts.
+    The delta and the unit specify which datetime field should be affected, where `D`, `h`, `m` and `s` can
+    be selected for days, hours, minutes and seconds respectively.
+    The datetime format must include the same format codes as specified in the `datetime` Python module for the
+    `strftime` function.
+    By setting `prevent_wraparound` to `True`, this mutator will not apply a mutation if it will cause an
+    unrelated field to change its value, e.g. when subtracting a day from July 1st, 2001.
+
+    Args:
+        max_delta: maximum amount of units to change by
+        unit: affected datetime field
+        dt_format: input and output datetime format
+        prevent_wraparound: `True` if unrelated fields should not be modified, `False` otherwise
+        rng: random number generator to use
+
+    Returns:
+        function returning list of datetime strings with random offsets applied to them
+    """
+    if max_delta <= 0:
+        raise ValueError(f"delta must be positive, is {max_delta}")
+
+    if rng is None:
+        rng = np.random.default_rng()
+
+    def _mutate_series(srs: pd.Series) -> pd.Series:
+        srs_dt = pd.to_datetime(srs, format=dt_format, errors="raise")
+        srs_dt_out = srs_dt.copy()
+
+        arr_rng_vals = rng.integers(
+            low=1, high=max_delta, size=len(srs_dt), endpoint=True
+        ) * rng.choice((-1, 1), size=len(srs_dt))
+
+        srs_vals = pd.Series(arr_rng_vals, copy=False, index=srs_dt.index)
+
+        for sgn in (-1, 1):
+            for val in range(1, max_delta + 1):
+                # compute the delta
+                this_delta = sgn * val
+                # wrap it into a timedelta
+                this_timedelta = pd.Timedelta(this_delta, unit)
+                # select all rows that have this delta applied to them
+                this_srs_mask = srs_vals == this_delta
+                # update rows
+                srs_dt_out.loc[this_srs_mask] += this_timedelta
+
+                # patch stuff if it wrapped around on accident
+                if prevent_wraparound:
+                    if unit == "D":
+                        wraparound_patch_mask = srs_dt_out.dt.month != srs_dt.dt.month
+                    elif unit == "h":
+                        wraparound_patch_mask = srs_dt_out.dt.day != srs_dt.dt.day
+                    elif unit == "m":
+                        wraparound_patch_mask = srs_dt_out.dt.hour != srs_dt.dt.hour
+                    elif unit == "s":
+                        wraparound_patch_mask = srs_dt_out.dt.minute != srs_dt.dt.minute
+                    else:
+                        raise ValueError(f"unrecognized unit: `{unit}`")
+
+                    # use original values (could probably be solved a bit more elegantly?)
+                    srs_dt_out.loc[wraparound_patch_mask] = srs_dt.loc[
+                        wraparound_patch_mask
+                    ]
+
+        return srs_dt_out.dt.strftime(dt_format)
+
+    def _mutate(srs_lst: list[pd.Series]) -> list[pd.Series]:
+        return [_mutate_series(srs) for srs in srs_lst]
+
+    return _mutate
+
+
 def mutate_data_frame(
     df_in: pd.DataFrame,
     column_to_mutator_list: list[


Test patch:
diff --git a/tests/test_generator.py b/tests/test_generator.py
index 8e0fc08..b44c8d2 100644
--- a/tests/test_generator.py
+++ b/tests/test_generator.py
@@ -126,6 +126,64 @@ def test_from_multicolumn_frequency_table(rng):
             raise AssertionError(f"unknown fruit: `{fruit}`")
 
 
+def test_from_datetime_range(rng):
+    gen_datetime = generator.from_datetime_range(
+        "1920-01-01", "2020-01-01", "%d.%m.%Y", "D", rng=rng
+    )
+
+    (dt_srs,) = gen_datetime(100)
+    assert dt_srs.str.fullmatch(r"\d{2}\.\d{2}\.\d{4}").all()
+
+
+def test_from_datetime_range_invalid_start_datetime(rng):
+    with pytest.raises(ValueError) as e:
+        generator.from_datetime_range("foobar", "2020-01-01", "%d.%m.%Y", "D")
+
+    assert str(e.value).startswith("Error parsing datetime string")
+
+
+def test_from_datetime_range_invalid_end_datetime(rng):
+    with pytest.raises(ValueError) as e:
+        generator.from_datetime_range("1920-01-01", "foobar", "%d.%m.%Y", "D")
+
+    assert str(e.value).startswith("Error parsing datetime string")
+
+
+@pytest.mark.parametrize("unit", ["D", "h", "m", "s"])
+def test_from_datetime_range_all_units(rng, unit):
+    gen_datetime = generator.from_datetime_range(
+        "1920-01-01", "2020-01-01", "%d.%m.%Y %H:%M:%S", unit, rng=rng
+    )
+
+    (dt_srs,) = gen_datetime(100)
+
+    df_matches = dt_srs.str.extract(
+        r"(?P<date>\d{2}.\d{2}.\d{4}) (?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})"
+    )
+
+    assert df_matches["date"].notna().all()
+
+    hour_not_all_zero = unit in ("h", "m", "s")
+    minute_not_all_zero = unit in ("m", "s")
+    second_not_all_zero = unit == "s"
+
+    assert (not (df_matches["hour"] == "00").all()) == hour_not_all_zero
+    assert (not (df_matches["minute"] == "00").all()) == minute_not_all_zero
+    assert (not (df_matches["second"] == "00").all()) == second_not_all_zero
+
+
+def test_from_datetime_range_end_before_start(rng):
+    with pytest.raises(ValueError) as e:
+        generator.from_datetime_range(
+            "2020-01-01", "1920-01-01", "%d.%m.%Y", "D", rng=rng
+        )
+
+    assert (
+        str(e.value)
+        == "start datetime `2020-01-01` is greater than end datetime `1920-01-01`"
+    )
+
+
 def test_to_dataframe_error_empty_dict():
     with pytest.raises(ValueError) as e:
         generator.to_data_frame({}, 1000)
diff --git a/tests/test_mutator.py b/tests/test_mutator.py
index 9db9efc..7ae2174 100644
--- a/tests/test_mutator.py
+++ b/tests/test_mutator.py
@@ -22,6 +22,7 @@
     Mutator,
     with_lowercase,
     with_uppercase,
+    with_datetime_offset,
 )
 from tests.helpers import get_asset_path
 
@@ -344,6 +345,58 @@ def test_with_uppercase():
     assert (srs_mutated == "FOOBAR").all()
 
 
+@pytest.mark.parametrize("unit", ["D", "h", "m", "s"])
+def test_with_datetime_offset(rng, unit):
+    srs = pd.Series(
+        pd.date_range("2020-01-01", "2021-01-01", freq="h", inclusive="left")
+    )
+    mutate_datetime_offset = with_datetime_offset(5, unit, "%Y-%m-%d %H:%M:%S", rng=rng)
+    (srs_mutated,) = mutate_datetime_offset([srs])
+
+    assert (srs != srs_mutated).all()
+
+
+@pytest.mark.parametrize("unit", ["D", "h", "m", "s"])
+def test_with_datetime_offset_negative_wraparound(rng, unit):
+    srs = pd.Series(["2020-01-01 00:00:00"] * 100)
+    mutate_datetime_offset = with_datetime_offset(
+        5, unit, "%Y-%m-%d %H:%M:%S", prevent_wraparound=True, rng=rng
+    )
+    (srs_mutated,) = mutate_datetime_offset([srs])
+
+    assert (srs == srs_mutated).any()
+
+
+def test_with_datetime_offset_custom_format(rng):
+    srs = pd.Series(pd.date_range("2020-01-01", periods=28, freq="D")).dt.strftime(
+        "%d.%m.%Y"
+    )
+    mutate_datetime_offset = with_datetime_offset(5, "D", "%d.%m.%Y", rng=rng)
+    (srs_mutated,) = mutate_datetime_offset([srs])
+
+    assert (srs != srs_mutated).all()
+    assert srs_mutated.str.fullmatch(r"\d{2}.\d{2}.\d{4}").all()
+
+
+def test_with_datetime_offset_raise_invalid_format(rng):
+    srs = pd.Series(["2024-01-01", "02.01.2024"])
+    mutate_datetime_offset = with_datetime_offset(5, "D", "%Y-%m-%d", rng=rng)
+
+    with pytest.raises(ValueError) as e:
+        mutate_datetime_offset([srs])
+
+    assert str(e.value).startswith(
+        'time data "02.01.2024" doesn\'t match format "%Y-%m-%d"'
+    )
+
+
+def test_with_datetime_offset_raise_nonpositive_delta(rng):
+    with pytest.raises(ValueError) as e:
+        with_datetime_offset(0, "D", "%Y-%m-%d", rng=rng)
+
+    assert str(e.value) == "delta must be positive, is 0"
+
+
 def test_mutate_data_frame_single(rng):
     df = pd.DataFrame({"foo": list(string.ascii_letters)})
     df_mut = mutate_data_frame(