AoT Time Handling~
This page explains how AoT handles time and timezones. The design background and decisions live in the repository at docs/design/timezone-management.md (dev-only, not published in the manual); this page covers how it actually behaves and what developers should use.
1. At a glance — three core rules~
- Storage is always UTC. Every timestamp in the database, InfluxDB, and logs is UTC.
- Display uses whichever clock fits the context. A device schedule is shown in the device's local time; a personal notification is shown in the viewer's time.
- There is one entry point for interpretation. All timezone-related calculations go through
aot/utils/timekit.py.
2. Time comes in two kinds~
Everything else starts from distinguishing these two.
| A. Instant | B. Wall-clock intent | |
|---|---|---|
| Example | A sensor reading, a log entry, created_at, "the moment the valve opened" | "Irrigate at 06:00", "schedule it every early morning" |
| Nature | A point on the timeline | A time someone stated using a particular clock |
| Storage | UTC alone is sufficient and unambiguous | Can't be collapsed to UTC directly — needs whose 06:00 (an anchor) |
| Display | Just convert to the desired tz | Must be converted back through the anchor tz or the intent is lost |
Why this matters: "irrigate at 06:00 for a device in a +6 region" means watering when that field's clock reads 6 AM — not when a +9 office's clock does. A wall-clock intent is meaningless without also knowing whose clock it refers to.
3. The four timezone layers, plus the host clock~
Four sources of timezone are involved in the system, and they can all differ from one another.
| Layer | What it is | Where it's stored |
|---|---|---|
| Device tz | The location timezone of each device (input/output/function/PID…) | input.timezone, etc. (derived from coordinates/inheritance and cached) |
| Shape tz | The timezone of a map shape (site/zone/facility) — the authority for tz | geo_shape.timezone, geo_facility.timezone |
| User tz | An individual's display preference | users.timezone (falls back to the system if unset) |
| System tz | The farm-wide default (the last resort) | misc.timezone |
Separate from all four, there is the host OS clock.
- The host OS clock is the sole source of "now" — it is not a timezone. Scheduling code must not depend on the OS's (or container's) local tz. Its only job is to give
timekit.utc_now()the current UTC instant. Scheduler firing is decided purely byfire_utc <= utc_now(); no timezone is involved in that comparison. - The container
TZvariable is not part of the resolution chain. Its only effect is the first-run default: when the database is first created,TZis copied into the system tz (misc.timezone). After that the System timezone setting governs, and changingTZon an existing install does nothing. Log timestamps also follow the system tz, notTZ. So: setTZfor the first-run default; set the System timezone and place sites/zones on the map (or set device timezones) for scheduling. misc.timezone(the system tz) is only the "farm default" fallback of last resort — it does not interpret intent. Once a device or shape has a location, it never falls back to the system tz. When the system tz genuinely is used, that fact is recorded and labeled.
4. The storage convention — why mixed naive datetimes are safe~
- New code uses tz-aware UTC (
timekit.utc_now(), equivalent todatetime.now(timezone.utc)). - A lot of legacy code still uses naive
datetime.utcnow(). This is not a bug — by project convention, a naive datetime is assumed to be UTC, andtimekit.ensure_utc()normalizes it by attaching+00:00at the point it's read. - Round-tripping through a SQLite column is also safe. Storing an aware UTC value (
00:00+00:00) into a naiveDateTimecolumn makes SQLite/SQLAlchemy strip only the tzinfo, keeping the value itself (UTC 00:00) intact. Reading it back gives a naive00:00, whichensure_utcrestores to00:00+00:00. SQL filters (schedule_time >= utc_now()) are correct for the same reason — the tz is stripped at bind time, so the comparison ends up naive-UTC vs. naive-UTC.
Pitfall: a column that stores naive values must only ever receive UTC-aware (or UTC-equivalent naive) values. Storing an
Asia/Seoul-aware value directly strips its tzinfo and the Seoul wall-clock value gets mistaken for UTC, landing 9 hours off. Always convert to UTC before storing (wall_to_utc;utc_nowis already UTC).
5. The single resolver — aot/utils/timekit.py~
Everything timezone-related converges on this module. Gates that used to be scattered around the codebase (get_device_tz, get_user_tz, get_timezone_name, resolve_location_tz, etc.) now delegate to it.
| Function | Purpose |
|---|---|
utc_now() / now_utc() | tz-aware current UTC "now" |
ensure_utc(dt) | Normalizes: naive assumed UTC, aware converted to UTC |
to_tz(dt, tz) | UTC (or naive=UTC) to a target tz |
iso_utc(dt) | The API serialization standard — always a +00:00 ISO string |
resolve_tz(entity=None, *, user=None) → (tzinfo, source) | The single resolution chain (below) |
system_tz() / system_tz_name() | The farm-wide default tz (Misc) |
current_user_tz() | The requesting user's personal tz (system if unset). For personal display only |
wall_to_utc(wall, tz) | Wall-clock time + anchor tz → UTC-aware (schedule storage, DST-correct) |
utc_to_wall(dt, tz) | UTC → wall-clock time in the anchor tz (schedule display) |
resolve_tz priority chain~
entity is a shape (GeoShape/GeoFacility):
uses its own inheritance-aware resolver (stored value → parent → facility → centroid)
entity is a device row:
1. entity.timezone (materialized cache) → cache's tz_source
2. inherited from its shape (device_id→shape→parent chain) → inherited
3. entity coordinates → timezonefinder → coords
4. system (Misc.timezone) → system
user:
User.timezone → system
entity=None:
system → UTC
Reads are O(1): a device's
timezonecolumn is a materialized cache. Coordinate-to-timezone conversion (timezonefinder) is expensive, but it only runs once, when a shape or device is created or edited, and the result is stored in the column. Execution paths such as scheduler firing and display only read that column.
6. How a schedule flows (a +9 user / +6 device example)~
Say a user in Seoul (+9) schedules "irrigate at 06:00" for a valve in Bangladesh (Dhaka, +6).
1. Anchor decided : the target is a device, so anchor tz = device local (Asia/Dhaka, +6)
(_resolve_schedule_anchor)
2. Stored : wall_to_utc("06:00", Asia/Dhaka) = 2026-07-22 00:00Z
SchedulerJobMeta.schedule_time=00:00Z, anchor_tz='Asia/Dhaka'
3. Fires : executes once utc_now() reaches 00:00Z (tz plays no part)
4. Shown (ops) : device local 06:00 (Asia/Dhaka) — _schedule_summary.when
5. Shown (user) : the same instant converted to Seoul → 09:00 (Asia/Seoul)
The point: only one UTC value (00:00Z) is stored, and 06:00 (device) and 09:00 (user) are just two representations of that same instant. The new-task form shows all three together as a live dual clock:
Irrigate · [Valve 6 (+6)] · 06:00
Device-local: 2026-07-22 06:00 (Asia/Dhaka) ← what actually fires
Your time: 2026-07-22 09:00 (Asia/Seoul) ← your screen
UTC: 2026-07-22 00:00
Settled policy: a device schedule's wall-clock time is interpreted as the device's local time by default (following the crop's solar day). The
datetime-localseed value in the edit form is also shown in device-local time, so it matches the storage interpretation and round-trips without drifting.
7. Display rules~
Context determines the tz. The server never bakes in a specific tz.
| Context | Clock | Method |
|---|---|---|
| Operations (scheduler, device logs, "when did it fire") | Device tz + label | Frontend AoTTz.formatDevice(iso, deviceTz) |
| Personal (user notifications, "what you just did") | Viewer/user tz | AoTTz.formatViewer(iso) |
| A calendar axis spanning multiple tz's | Viewer tz | FullCalendar timeZone:'local' |
- The server API only ever returns UTC ISO (
iso_utc); the display tz is chosen client-side byAoTTz. - The single frontend utility is
aot/aot_flask/static/js/common/aot-tz.js(window.AoTTz). formatDevice(iso, tz)— device local timeformatViewer(iso)— viewer (personal tz first, then browser, then system)wallToInstant(wall, tz)— interprets a wall-clock value against a tz and returns the absolute instant (used for the dual clock)- The viewer tz is resolved as
<meta name="aot-user-tz">(User.timezone) > browser tz >aot-fallback-tz(system).
8. Timezone inheritance (the shape tree)~
Even with thousands of devices, the timezone is never computed per device. The tz is a property of the location group.
Site (GeoShape) → the tz authority. Explicit override | one-time centroid resolve → materialized
└ Zone (GeoShape) → inherits from Site. Override if needed → materialized
└ Device → inherits from its Zone/Site (cached)
- The shape tree is built from
geo_shape.parent_id(self-referencing); the link to a physical device isgeo_shape.device_id. tz_source(explicit|inherited|coords) records where a value came from.explicit(a manual override, or a boundary-group selection) is treated as pinned and is not overwritten by automatic updates.- Materialization and propagation: after a map save commits (
save_overlays/save_delta),GeoOverlayManager.materialize_timezones(map_uuid)computes and caches tz in site→zone→device order and propagates it to linked devices. A parent shape's tz override flows down to its children and devices.
9. Timezone boundaries / the date line~
An operational group doesn't split just because it straddles a boundary.
- Even when a field straddles a tz boundary, the site settles on a single tz and the whole subtree inherits it — one field, one clock.
- Boundary detection happens once, at edit time: when a shape is saved,
GeoShape.detect_tz_boundary()looks up the tz at all four corners of its bounding box and markstz_boundary=Trueif they differ. timezonefinderresolves tz by legal (political) boundary, so it already correctly returns "legal time that differs from solar time" cases such as all of China at +8, Spain at +1, or Kazakhstan's Almaty at +5.
10. Developer quick reference — "what to use, when"~
| What you want | What to use |
|---|---|
| "Now" (UTC) | timekit.utc_now() |
| Normalize a stored naive/aware datetime to UTC | timekit.ensure_utc(dt) |
| Serialize a timestamp for an API response | timekit.iso_utc(dt) → frontend AoTTz |
| Find an entity's timezone | timekit.resolve_tz(entity) (or device_tz.get_device_tz) |
| Get the local timezone from a location id | device_tz.resolve_location_tz(target_id) |
| Schedule wall-clock time → storage | timekit.wall_to_utc(wall, anchor_tz) |
| Schedule UTC → display | timekit.utc_to_wall(dt, tz) or to_tz |
| Personal display tz (per request) | timekit.current_user_tz() |
| Show a device's time in the frontend | AoTTz.formatDevice(iso, deviceTz) |
| Show my own time in the frontend | AoTTz.formatViewer(iso) |
11. Pitfalls~
- Only UTC values in a naive column. Storing a non-UTC aware value strips its tzinfo and it gets misread (see §4).
- Interpret wall-clock values against the anchor tz. Using the user's browser tz or the system tz instead breaks for devices in a different tz. Schedules use
wall_to_utc(wall, device_anchor). get_user_tz()is actually the system tz, despite its name — it is not the personal tz (it exists for wall-clock interpretation and daemon compatibility). Usecurrent_user_tz()for personal display.serialize_ts()converts on the server using the system tz. Don't use it for anything device-related — useiso_utc+AoTTzinstead. (The scheduler's_serialize_jobstill usesserialize_tsfor itsdecided_at/executed_at/created_ataudit metadata — a known, minor gap.)- Don't treat
datetime.now()(system local) as "UTC now." It happens to match inside Docker, but drifts on a non-UTC host. Useutc_now()instead. (Adatetime.now()used for elapsed-time measurement or offset calculation is intentional and stays as-is.) - A device's
timezonecache only updates on a location-edit event. If you change coordinates and the tz doesn't change, check the materialization (materialize_timezones) path or the coordinate listener.
12. Related files~
- Backend single entry point:
aot/utils/timekit.py - Coordinate-to-tz / location resolution:
aot/utils/device_tz.py - Shape tz / inheritance / boundaries:
aot/databases/models/geo.py,aot/aot_flask/geo/geo_overlays.py - Coordinate-to-tz auto-materialization listener:
aot/databases/device_tz_listeners.py - Schedule anchor / display:
aot/tools/aot_data_tool_service.py,aot/aot_flask/routes_scheduler.py - Frontend display utility:
aot/aot_flask/static/js/common/aot-tz.js - Design and decisions:
docs/design/timezone-management.md(dev-only, not published in the manual) - Earlier audit report:
docs/design/timezone_audit.md(dev-only, not published in the manual)