Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ __pycache__/
build/
.pytest_cache/
dist/
*.egg-info/
*.egg-info/
.pypirc
5 changes: 1 addition & 4 deletions packages/sfm-analysis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,7 @@ tool. Absent entirely for shorter runs.
It deliberately draws **no light/dark shading**. The rig doesn't record
the facility's light schedule, so any shading would be a fixed
clock-time assumption rendered as though it were measured data. Time of
day is on the axis; apply your own light cycle to it. (For zeitgeber
time in your own analysis, `report.timezones.zeitgeber_time(row,
lights_on=...)` takes the schedule explicitly, where it's your stated
input rather than a silent report-wide default.)
day is on the axis; apply your own light cycle to it if you need one.

The section heading and figure caption both name the plotted event
(`Actogram — MousePresence Detected` by default) so a printed page is
Expand Down
11 changes: 3 additions & 8 deletions packages/sfm-analysis/docs/ANALYSIS_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,7 @@ as does the figure's own SVG `<desc>`.
The actogram draws no light/dark shading: the rig doesn't record the
facility's light schedule, so shading it would render a fixed
clock-time assumption as though it were measured data. Time of day is
on the axis — apply your own light cycle to it, or use
`zeitgeber_time(row, lights_on=...)` ([§7](#7-time-timezone-and-time-of-day)),
where the schedule is an explicit input you supply.
on the axis — apply your own light cycle to it if you need one.

A pip-installed user does not edit the package:

Expand Down Expand Up @@ -361,7 +359,7 @@ questions:
| `timestamp_ms` | epoch milliseconds | Correct for ordering; needs `utc_offset_s` to mean anything as wall-clock |
| `timestamp_iso` (`row.iso`) | rig-local wall-clock string | Already correct local time, with **no offset needed** — see below |

`report.timezones` gives you four functions, all reading `row.iso`
`report.timezones` gives you three functions, all reading `row.iso`
directly rather than reconstructing from epoch ms — because
`datetime.fromtimestamp()`/`.astimezone()` with no arguments implicitly use
*your own machine's* timezone, which is wrong the moment analyst and rig
Expand All @@ -370,10 +368,7 @@ are in different zones:
- **`time_of_day(row)`** → hours since local midnight (`0.0`–`24.0`). No
offset needed: `timestamp_iso` was already written in rig-local time.
- **`local_date(row)`** → the calendar date this row falls on, rig-local.
- **`zeitgeber_time(row, lights_on=6.0)`** → hours relative to lights-on
(ZT0 = lights-on). Also needs no offset — your facility's light schedule
is itself set in local wall-clock time.
- **`wall_clock(row, run)`** → the *only* one of the four that needs
- **`wall_clock(row, run)`** → the *only* one of the three that needs
`run.utc_offset_s`. Returns a timezone-**aware** `datetime` when the
offset is known, a naive one otherwise — the naive case is not a
failure, the value is still correct rig-local time, it's just unsafe to
Expand Down
3 changes: 1 addition & 2 deletions packages/sfm-analysis/src/sfm_analysis/analysis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from .intervals import around, count_in, merge, overlap, rate_in, subtract
from .session import AmbiguousSessionError, Session, SessionNotFoundError, load_session
from .tables import bouts_table, cycles_table, events_table, to_dataframe, trials_table
from ..report.timezones import local_date, time_of_day, wall_clock, zeitgeber_time
from ..report.timezones import local_date, time_of_day, wall_clock

__all__ = [
"load_session",
Expand All @@ -45,6 +45,5 @@
"rate_in",
"time_of_day",
"local_date",
"zeitgeber_time",
"wall_clock",
]
15 changes: 0 additions & 15 deletions packages/sfm-analysis/src/sfm_analysis/report/timezones.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,6 @@ def local_date(row: "LogRow"):
return datetime.fromisoformat(row.iso).date()


def zeitgeber_time(row: "LogRow", *, lights_on: float = 6.0) -> float:
"""
Zeitgeber time in hours (``0.0`` to just under ``24.0``, wrapped),
relative to ``lights_on`` — ZT0 is lights-on, ZT12 is nominally
lights-off under a standard 12:12 light/dark cycle.

``lights_on`` is hours since local midnight (default ``6.0`` =
06:00); pass your facility's actual light-cycle start time. Light
schedules are set in local wall-clock time by the people running the
facility, so — same reasoning as ``time_of_day`` — no UTC offset is
needed here either.
"""
return (time_of_day(row) - lights_on) % 24.0


def wall_clock(row: "LogRow", run: "RunData") -> datetime:
"""
A ``datetime`` for this row: timezone-*aware* if ``run.utc_offset_s``
Expand Down
26 changes: 1 addition & 25 deletions packages/sfm-analysis/tests/test_report_timezones.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sfm_analysis.report.loader import load_rows
from sfm_analysis.report.session import split_runs
from sfm_analysis.report.timezones import (
local_date, time_of_day, wall_clock, zeitgeber_time,
local_date, time_of_day, wall_clock,
)


Expand Down Expand Up @@ -56,30 +56,6 @@ def test_matches_the_isos_own_date(self, tmp_path):
assert local_date(lr) == datetime.fromisoformat(lr.iso).date()


class TestZeitgeberTime:
def test_zt0_at_lights_on(self, tmp_path):
rows = [row(ts_ms=1_700_000_000_000 + i * 3_600_000) for i in range(24)]
path = write_session(tmp_path, rows, session="S")
loaded, _, _ = load_rows(path)
run = split_runs(loaded, [], path)[0]
for lr in run.rows:
tod = time_of_day(lr)
zt = zeitgeber_time(lr, lights_on=tod)
assert zt == 0.0

def test_wraps_around_midnight(self):
# A row whose time_of_day is 1.0 and lights_on=23.0 should give
# zt=2.0 (1.0 - 23.0 = -22.0, wrapped mod 24 -> 2.0).
class _FakeRow:
iso = "2026-01-01T01:00:00"
assert zeitgeber_time(_FakeRow(), lights_on=23.0) == 2.0

def test_default_lights_on_is_6am(self):
class _FakeRow:
iso = "2026-01-01T06:00:00"
assert zeitgeber_time(_FakeRow()) == 0.0


class TestWallClock:
def test_naive_when_utc_offset_unknown(self, tmp_path):
path = write_session(tmp_path, [_row_at(1_700_000_000_000)], session="S")
Expand Down
Loading