CamTrapTools 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """CamTrapTools: an open-source toolkit for camera-trap projects."""
2
+
3
+ __version__ = "0.6.0"
@@ -0,0 +1,255 @@
1
+ """Daily activity patterns and the overlap between species (Ridout & Linkie 2009).
2
+
3
+ Each independent event's start time is a point on the 24-hour circle. A species'
4
+ activity curve is a kernel density on that circle with von Mises kernels, whose
5
+ concentration follows the rule of Taylor (2008) as in the R package ``overlap``. The
6
+ overlap coefficient Δ is the area under the lower of two curves: 0 when two species
7
+ are never active at the same time of day, 1 when their patterns are identical.
8
+ """
9
+
10
+ import math
11
+ from functools import cache
12
+ from itertools import combinations
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ ACTIVITY_FILE = "activity.csv"
19
+ OVERLAP_FILE = "overlap.csv"
20
+ PLOT_FILE = "activity.svg"
21
+ DEFAULT_MIN_EVENTS = 20
22
+ GRID_POINTS = 512
23
+ DIRECT_LIMIT = 2_000_000
24
+ BINS = 1440
25
+ MAX_MOMENT = 3
26
+ LARGE_SAMPLE = 75
27
+ BOOTSTRAP_SAMPLES = 999
28
+ PLOTTED_SPECIES = 6
29
+ COLOURS = ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9"]
30
+
31
+
32
+ def to_radians(times: pd.Series) -> np.ndarray:
33
+ """Return clock times as angles on the 24-hour circle, in radians."""
34
+ stamps = pd.to_datetime(times, format="ISO8601")
35
+ hours = stamps.dt.hour + stamps.dt.minute / 60 + stamps.dt.second / 3600
36
+ return (hours.to_numpy(dtype=float) / 24) * 2 * math.pi
37
+
38
+
39
+ def scaled_bessel(order: int, value):
40
+ """Return e^(−value) · I_order(value), the modified Bessel function of the first
41
+ kind scaled to stay finite, from its integral form. ``value`` may be an array."""
42
+ theta = np.linspace(0, math.pi, 2001)
43
+ values = np.atleast_1d(np.asarray(value, dtype=float))
44
+ integrand = np.exp(values[:, None] * (np.cos(theta) - 1)) * np.cos(order * theta)
45
+ result = np.trapezoid(integrand, theta, axis=1) / math.pi
46
+ return result if np.ndim(value) else float(result[0])
47
+
48
+
49
+ KAPPA_GRID = np.concatenate([np.linspace(0, 20, 2001), np.geomspace(20.05, 500, 400)])
50
+
51
+
52
+ @cache
53
+ def moment_lengths(order: int) -> np.ndarray:
54
+ """Return I_order(κ) / I_0(κ), the mean resultant length of the trigonometric
55
+ moment of ``order`` of a von Mises distribution, for every κ in ``KAPPA_GRID``."""
56
+ return scaled_bessel(order, KAPPA_GRID) / scaled_bessel(0, KAPPA_GRID)
57
+
58
+
59
+ def concentration_from_moment(order: int, length: float) -> float:
60
+ """Return the von Mises concentration κ whose trigonometric moment of ``order`` has
61
+ mean resultant length ``length``, i.e. I_order(κ) / I_0(κ) = length."""
62
+ return float(np.interp(length, moment_lengths(order), KAPPA_GRID))
63
+
64
+
65
+ def von_mises_concentration(angles: np.ndarray) -> float:
66
+ """Return the concentration κ of a von Mises distribution matching ``angles``,
67
+ estimated from each trigonometric moment up to order ``MAX_MOMENT`` and taking
68
+ the largest, so activity with two or three peaks is not mistaken for flat
69
+ activity (as in the R package ``overlap``)."""
70
+ estimates = []
71
+ for order in range(1, MAX_MOMENT + 1):
72
+ length = abs(np.exp(1j * order * angles).mean())
73
+ estimates.append(concentration_from_moment(order, length))
74
+ return max(estimates)
75
+
76
+
77
+ def bandwidth(angles: np.ndarray) -> float:
78
+ """Return the concentration of the von Mises kernels for ``angles`` with the
79
+ plug-in rule of Taylor (2008): larger for bigger samples and sharper activity, so
80
+ the curve follows the data more closely."""
81
+ kappa = von_mises_concentration(angles)
82
+ if kappa == 0:
83
+ return 0.0
84
+ # I_2(2κ) / I_0(κ)² is the same for the scaled functions, as e^(2κ) cancels
85
+ ratio = scaled_bessel(2, 2 * kappa) / scaled_bessel(0, kappa) ** 2
86
+ return (3 * len(angles) * kappa**2 * ratio / (4 * math.sqrt(math.pi))) ** (2 / 5)
87
+
88
+
89
+ def density(angles: np.ndarray, at: np.ndarray, concentration: float) -> np.ndarray:
90
+ """Return the circular kernel density of ``angles`` at the angles ``at``, per
91
+ radian, so that it integrates to 1 over the day.
92
+
93
+ Small samples are computed exactly. For large ones (more than
94
+ ``DIRECT_LIMIT`` kernel evaluations), times are counted in one-minute bins, the
95
+ kernel is applied to the bins with a fast Fourier transform, and the result is
96
+ interpolated, which is far faster and differs negligibly.
97
+ """
98
+ normalizer = 2 * math.pi * scaled_bessel(0, concentration)
99
+ if len(angles) * len(at) <= DIRECT_LIMIT:
100
+ differences = np.cos(at[:, None] - angles[None, :]) - 1
101
+ return (np.exp(concentration * differences) / normalizer).mean(axis=1)
102
+ step = 2 * math.pi / BINS
103
+ counts = np.bincount(np.floor(np.mod(angles, 2 * math.pi) / step).astype(int) % BINS, minlength=BINS)
104
+ centres = (np.arange(BINS) + 0.5) * step
105
+ kernel = np.exp(concentration * (np.cos(np.arange(BINS) * step) - 1)) / normalizer
106
+ smoothed = np.fft.irfft(np.fft.rfft(counts / len(angles)) * np.fft.rfft(kernel), n=BINS)
107
+ return np.interp(np.mod(at, 2 * math.pi), centres, smoothed, period=2 * math.pi)
108
+
109
+
110
+ def grid() -> np.ndarray:
111
+ """Return evenly spaced angles around the day."""
112
+ return np.linspace(0, 2 * math.pi, GRID_POINTS, endpoint=False)
113
+
114
+
115
+ def overlap(first: np.ndarray, second: np.ndarray) -> float:
116
+ """Return the overlap coefficient of two samples of times: Δ₄ when the smaller has
117
+ at least ``LARGE_SAMPLE`` events, otherwise Δ₁, as Ridout & Linkie recommend."""
118
+ if min(len(first), len(second)) >= LARGE_SAMPLE:
119
+ f_first = density(first, first, bandwidth(first))
120
+ g_first = density(second, first, bandwidth(second))
121
+ f_second = density(first, second, bandwidth(first))
122
+ g_second = density(second, second, bandwidth(second))
123
+ return 0.5 * (np.minimum(1, g_first / f_first).mean() + np.minimum(1, f_second / g_second).mean())
124
+ points = grid()
125
+ f = density(first, points, bandwidth(first) / 0.8)
126
+ g = density(second, points, bandwidth(second) / 0.8)
127
+ return float(np.minimum(f, g).mean() * 2 * math.pi)
128
+
129
+
130
+ def overlap_interval(
131
+ first: np.ndarray, second: np.ndarray, samples: int = BOOTSTRAP_SAMPLES, seed: int = 0
132
+ ) -> tuple[float, float, float]:
133
+ """Return the overlap coefficient with a 95% percentile bootstrap interval,
134
+ resampling each species' events with replacement. In simulations, percentile
135
+ intervals held their 95% coverage where basic intervals fell to about 80%."""
136
+ estimate = overlap(first, second)
137
+ rng = np.random.default_rng(seed)
138
+ replicates = np.array(
139
+ [overlap(rng.choice(first, len(first)), rng.choice(second, len(second))) for _ in range(samples)]
140
+ )
141
+ low, high = np.quantile(replicates, [0.025, 0.975])
142
+ return estimate, float(low), float(high)
143
+
144
+
145
+ def activity_curves(times: dict[str, np.ndarray]) -> pd.DataFrame:
146
+ """Return each species' activity curve on a grid of times of day, as the share of
147
+ its daily activity per hour."""
148
+ points = grid()
149
+ curves = pd.DataFrame({"hour": np.round(points / (2 * math.pi) * 24, 4)})
150
+ for name, angles in times.items():
151
+ curves[name] = density(angles, points, bandwidth(angles)) * 2 * math.pi / 24
152
+ return curves
153
+
154
+
155
+ def peak_hour(curve: pd.Series, hours: pd.Series) -> str:
156
+ """Return the time of day at which an activity curve is highest, as HH:MM."""
157
+ hour = float(hours[curve.idxmax()])
158
+ return f"{int(hour):02d}:{round(hour % 1 * 60) % 60:02d}"
159
+
160
+
161
+ def overlaps(times: dict[str, np.ndarray], samples: int = BOOTSTRAP_SAMPLES) -> pd.DataFrame:
162
+ """Return the overlap coefficient and its interval for every pair of species."""
163
+ rows = []
164
+ for first, second in combinations(times, 2):
165
+ estimate, low, high = overlap_interval(times[first], times[second], samples)
166
+ rows.append(
167
+ {
168
+ "first": first,
169
+ "second": second,
170
+ "estimator": "Δ4" if min(len(times[first]), len(times[second])) >= LARGE_SAMPLE else "Δ1",
171
+ "overlap": round(estimate, 2),
172
+ "overlapLower": round(low, 2),
173
+ "overlapUpper": round(high, 2),
174
+ }
175
+ )
176
+ columns = ["first", "second", "estimator", "overlap", "overlapLower", "overlapUpper"]
177
+ return pd.DataFrame(rows, columns=columns).sort_values("overlap", ascending=False, ignore_index=True)
178
+
179
+
180
+ def plot(curves: pd.DataFrame, counts: dict[str, int]) -> str:
181
+ """Return an SVG line chart of the activity curves of the most recorded species."""
182
+ width, height, left, right, top, bottom = 720, 360, 56, 200, 20, 44
183
+ plotted = sorted(counts, key=counts.get, reverse=True)[:PLOTTED_SPECIES]
184
+ peak = max(curves[name].max() for name in plotted) * 1.1
185
+ inner_width, inner_height = width - left - right, height - top - bottom
186
+
187
+ def x(hour: float) -> float:
188
+ return left + hour / 24 * inner_width
189
+
190
+ def y(value: float) -> float:
191
+ return top + inner_height * (1 - value / peak)
192
+
193
+ parts = [
194
+ f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" font-family="sans-serif" font-size="12">',
195
+ f'<rect width="{width}" height="{height}" fill="white"/>',
196
+ ]
197
+ step = next((size for size in [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0] if peak / size <= 6), peak / 4)
198
+ for tick in np.arange(step, peak, step):
199
+ parts.append(
200
+ f'<line x1="{left}" y1="{y(tick):.1f}" x2="{left + inner_width}" y2="{y(tick):.1f}" stroke="#f0f0f0"/>'
201
+ )
202
+ parts.append(f'<text x="{left - 6}" y="{y(tick) + 4:.1f}" text-anchor="end">{tick:.0%}</text>')
203
+ for hour in range(0, 25, 3):
204
+ parts.append(
205
+ f'<line x1="{x(hour):.1f}" y1="{top}" x2="{x(hour):.1f}" y2="{top + inner_height}" stroke="#e5e5e5"/>'
206
+ )
207
+ parts.append(f'<text x="{x(hour):.1f}" y="{height - 24}" text-anchor="middle">{hour:02d}:00</text>')
208
+ parts.append(
209
+ f'<line x1="{left}" y1="{top + inner_height}" x2="{left + inner_width}" y2="{top + inner_height}" '
210
+ 'stroke="#444"/>'
211
+ )
212
+ parts.append(f'<text x="{left + inner_width / 2}" y="{height - 6}" text-anchor="middle">Time of day</text>')
213
+ parts.append(
214
+ f'<text transform="translate(14 {top + inner_height / 2}) rotate(-90)" text-anchor="middle">'
215
+ "Share of activity per hour</text>"
216
+ )
217
+ for index, name in enumerate(plotted):
218
+ colour = COLOURS[index % len(COLOURS)]
219
+ points = " ".join(f"{x(hour):.1f},{y(value):.1f}" for hour, value in zip(curves["hour"], curves[name]))
220
+ points += f" {x(24):.1f},{y(curves[name].iloc[0]):.1f}"
221
+ parts.append(f'<polyline points="{points}" fill="none" stroke="{colour}" stroke-width="2"/>')
222
+ legend_y = top + 16 + index * 22
223
+ parts.append(
224
+ f'<line x1="{width - right + 16}" y1="{legend_y}" x2="{width - right + 40}" y2="{legend_y}" '
225
+ f'stroke="{colour}" stroke-width="3"/>'
226
+ )
227
+ label = f"{name} ({counts[name]})".replace("&", "&amp;").replace("<", "&lt;")
228
+ parts.append(f'<text x="{width - right + 46}" y="{legend_y + 4}" font-style="italic">{label}</text>')
229
+ parts.append("</svg>")
230
+ return "\n".join(parts)
231
+
232
+
233
+ def run(events: pd.DataFrame, out_dir: Path, min_events: int = DEFAULT_MIN_EVENTS) -> tuple[pd.DataFrame, pd.DataFrame]:
234
+ """Write activity curves (``activity.csv``), pairwise overlaps (``overlap.csv``) and
235
+ a chart (``activity.svg``) for every species with at least ``min_events`` events.
236
+
237
+ ``events`` has an ``eventStart`` and a ``scientificName`` column. Returns a table of
238
+ events and peak hour per species, and the overlaps.
239
+ """
240
+ counts = events["scientificName"].value_counts()
241
+ chosen = [name for name, count in counts.items() if count >= min_events]
242
+ times = {name: to_radians(events.loc[events["scientificName"] == name, "eventStart"]) for name in chosen}
243
+ out_dir.mkdir(parents=True, exist_ok=True)
244
+ if not times:
245
+ return pd.DataFrame(columns=["events", "peak"]), overlaps({})
246
+ curves = activity_curves(times)
247
+ curves.to_csv(out_dir / ACTIVITY_FILE, index=False)
248
+ pairs = overlaps(times)
249
+ pairs.to_csv(out_dir / OVERLAP_FILE, index=False)
250
+ (out_dir / PLOT_FILE).write_text(plot(curves, {name: len(times[name]) for name in times}), encoding="utf-8")
251
+ peaks = pd.DataFrame(
252
+ {"events": [len(times[name]) for name in times], "peak": [peak_hour(curves[name], curves["hour"]) for name in times]},
253
+ index=pd.Index(list(times), name="taxon"),
254
+ )
255
+ return peaks, pairs