codex/circadian-free-running-actogram.py

circadian-free-running-actogram.py
"""Draw a stipulated circadian actogram, not a record from an animal.

Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
Run from _media; the PNG is written to the current directory.
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Patch, Rectangle


TAU = 23.5
ENVIRONMENT_PERIOD = 24.0
LD_DAYS = 7
TOTAL_DAYS = 24
ACTIVITY_DURATION = 8.0
RELEASE = LD_DAYS * ENVIRONMENT_PERIOD


def activity_intervals():
    """Absolute-time intervals; the first DD onset preserves the LD phase."""
    intervals = [(24.0 * day + 12.0, 24.0 * day + 20.0)
                 for day in range(LD_DAYS)]
    onset = RELEASE + 12.0
    while onset < TOTAL_DAYS * ENVIRONMENT_PERIOD:
        intervals.append((onset, onset + ACTIVITY_DURATION))
        onset += TAU
    return intervals


def main():
    plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
                         "axes.spines.right": False})
    fig, ax = plt.subplots(figsize=(9, 6.6), dpi=100, facecolor="white")
    fig.subplots_adjust(left=0.16, right=0.97, bottom=0.19, top=0.83)
    for day in range(TOTAL_DAYS):
        ax.add_patch(Rectangle((0, day + 0.5), 24, 1,
                               facecolor="#edf0f3", edgecolor="white", lw=0.8))
        if day < LD_DAYS:
            ax.add_patch(Rectangle((0, day + 0.5), 12, 1,
                                   facecolor="#fff2c9", edgecolor="white", lw=0.8))
    for start, end in activity_intervals():
        for day in range(TOTAL_DAYS):
            left = max(start, day * 24.0)
            right = min(end, (day + 1) * 24.0)
            if right > left:
                ax.add_patch(Rectangle((left - day * 24.0, day + 0.68),
                                       right - left, 0.64,
                                       facecolor="#087e8b", edgecolor="none"))
        day = int(start // 24.0)
        if day < TOTAL_DAYS:
            ax.plot(start % 24, day + 1, "|", color="#003e47", markersize=11)
    ax.axhline(LD_DAYS + 0.5, color="#b94b32", lw=1.5, ls="--")
    ax.text(-2.7, 4, "12:12 LD", rotation=90, ha="center", va="center",
            color="#66521e", clip_on=False)
    ax.text(-2.7, 16, "Constant darkness (DD)", rotation=90,
            ha="center", va="center", color="#49535e", clip_on=False)
    ax.annotate("First DD onset:\n12 h", xy=(12, 8), xytext=(20.5, 10),
                fontsize=10, arrowprops={"arrowstyle": "->", "color": "#003e47"})
    ax.annotate("17th DD onset: 4 h", xy=(4, 24), xytext=(17, 22),
                fontsize=10, arrowprops={"arrowstyle": "->", "color": "#003e47"})
    ax.set(xlim=(0, 24), ylim=(24.65, 0.4), xlabel="Hour within a fixed 24-hour plotting row",
           ylabel="Successive calendar day")
    ax.set_xticks(np.arange(0, 25, 4))
    ax.set_yticks([1, 7, 8, 12, 16, 20, 24])
    fig.suptitle("A clock that keeps running when the light cycle stops", y=0.97, fontsize=15)
    fig.text(0.5, 0.915, "Original idealized model: τ = 23.5 h; every activity bout lasts 8 h",
             ha="center", fontsize=11)
    fig.legend(handles=[Patch(facecolor="#fff2c9", label="Light"),
                        Patch(facecolor="#edf0f3", label="Dark"),
                        Patch(facecolor="#087e8b", label="Constructed activity")],
               loc="upper center", bbox_to_anchor=(0.5, 0.9), ncol=3, frameon=False)
    fig.text(0.5, 0.065, "DD onsets are 23.5 h apart: 0.5 h earlier per cycle on this 24 h grid.",
             ha="center", fontsize=11)
    fig.text(0.5, 0.027, "Stipulated phase and activity windows; no mouse or human observations.",
             ha="center", fontsize=10, color="#555555")
    fig.savefig(Path(__file__).stem + ".png", facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()