forked from ChelseaKR/olive-bark-logger
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexport.py
More file actions
135 lines (114 loc) · 5.42 KB
/
Copy pathexport.py
File metadata and controls
135 lines (114 loc) · 5.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
"""CSV export of the event log, for spreadsheets or handing to property management.
Pure stdlib `csv`. Times are written both as unix seconds and as an ISO-8601 string in
the report's time zone, so the file is usable without re-deriving local time. Each row
also records the calibration offset included in its levels (0.0 = raw dBFS), so the
export is self-describing about its calibration state: raw = value - offset.
Like every other export path, the "what this can and cannot prove" cover block is written
as a leading ``#`` comment preamble so the caveat travels with the file. This one is
handed to property management as readily as the quiet-hours export is, and it shipped
without the block until the export gate started enumerating paths rather than naming them.
"""
from __future__ import annotations
import csv
from collections.abc import Sequence
from datetime import datetime, timezone, tzinfo
from pathlib import Path
from typing import TYPE_CHECKING
from monitor.detector import Event
from report.render import CALIBRATION_NONE, CALIBRATION_UNSTATED, cover_text_lines
if TYPE_CHECKING:
from store import Gap
_HEADER = [
"start_unix",
"start_iso",
"end_iso",
"duration_s",
"peak_dbfs",
"avg_dbfs",
"calibration_offset_db",
"calibration_basis",
"monitored",
"rise_time_s",
"loud6_s",
"longest_run_s",
"coarse_tag",
]
def resolve_basis(
basis: Sequence[str] | None, offsets_db: Sequence[float] | None, n: int
) -> list[str]:
"""The per-row calibration basis an export writes, never guessed.
Given, it must parallel the rows. Absent with no offsets, every row is raw
(`none`). Absent with offsets present, the export cannot know whether each offset
was in force or back-applied, and says `unstated` rather than inventing an answer.
"""
if basis is not None:
out = list(basis)
if len(out) != n:
raise ValueError("basis must have one entry per event")
return out
return [CALIBRATION_NONE if offsets_db is None else CALIBRATION_UNSTATED] * n
def _is_monitored(start: float, end: float, gaps: list[Gap]) -> bool:
"""True unless the interval [start, end) overlaps any recorded monitoring gap."""
return not any(g.start < end and g.end > start for g in gaps)
def _sec(value: float | None) -> str:
"""One-decimal seconds, or blank for a missing (legacy) anatomy value."""
return "" if value is None else f"{value:.1f}"
def events_to_csv(
events: list[Event],
path: str | Path,
*,
tz: tzinfo = timezone.utc,
offsets_db: Sequence[float] | None = None,
basis: Sequence[str] | None = None,
gaps: list[Gap] | None = None,
) -> int:
"""Write events to a CSV file. Returns the number of rows written.
`offsets_db`, when given, must parallel `events` and record the calibration offset
already applied (at render time) to each event's peak/avg levels. Omitted means the
levels are raw, uncalibrated dBFS (offset 0.0).
`basis` parallels `offsets_db` and says, per row, whether that offset was in force
when the row was measured (`in-force`), back-applied from a calibration taken later
(`back-applied`), the deprecated config bootstrap (`bootstrap-config`), or absent
(`none`) — see `report.render.CALIBRATION_*`. A row measured nineteen days before
the microphone was ever calibrated carries the same `+48.0` as one measured after,
and without this column nothing in the file distinguishes them. Omitted with
offsets present, the column reads `unstated` rather than guessing; omitted with no
offsets, every row is `none`.
The `monitored` column is "yes" unless the event overlaps a recorded monitoring gap
(the device was not listening), so an event logged at the edge of an outage is flagged.
The R1 cover block leads the file as ``#`` comments; the machine-readable rows below
it are unchanged, and every csv reader in common use skips comment lines.
"""
offs = list(offsets_db) if offsets_db is not None else [0.0] * len(events)
if len(offs) != len(events):
raise ValueError("offsets_db must have one entry per event")
bases = resolve_basis(basis, offsets_db, len(events))
gap_list = gaps or []
with Path(path).open("w", newline="", encoding="utf-8") as fh:
for line in cover_text_lines():
fh.write(f"# {line}\n" if line else "#\n")
writer = csv.writer(fh)
writer.writerow(_HEADER)
for ev, off, how in zip(events, offs, bases):
monitored = _is_monitored(ev.start, ev.end, gap_list)
writer.writerow(
[
f"{ev.start:.3f}",
datetime.fromtimestamp(ev.start, tz=tz).isoformat(),
datetime.fromtimestamp(ev.end, tz=tz).isoformat(),
f"{ev.duration:.3f}",
f"{ev.peak_level:.1f}",
f"{ev.avg_level:.1f}",
f"{off:+.1f}",
how,
"yes" if monitored else "no",
# Envelope anatomy is independent of coarse_tag: it is emitted even
# when the (opt-in) tag is suppressed, since it carries no hint about
# the sound's source — only its shape.
_sec(ev.rise_time_s),
_sec(ev.loud6_s),
_sec(ev.longest_run_s),
ev.coarse_tag or "",
]
)
return len(events)