Skip to content

Commit 5b3fc1a

Browse files
committed
Add statistics module for debugging
[TGSRVF-59]
1 parent 2f14cc0 commit 5b3fc1a

File tree

3 files changed

+148
-2
lines changed

3 files changed

+148
-2
lines changed

README.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,14 @@ The `pysnapping.util` module contains common helper functions used in other part
4040

4141
### Snapping
4242

43-
The `pysanpping.snap` module is the main entry point for users of the `pysnapping` library and
43+
The `pysnapping.snap` module is the main entry point for users of the `pysnapping` library and
4444
provides the classes needed to use the library.
4545

46+
### Statistics
47+
48+
The `pysnapping.stats` module provides tools to aggregate statistics about snapping
49+
failures and the methods used in case of success.
50+
4651
## Usage
4752

4853
The typical usage pattern is to create a `pysnapping.snap.DubiousTrajectory` instance which represents

pysnapping/snap.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,14 @@ def snap_trusted(
578578
)
579579
ppoints_list[i_global] = ppoints
580580
else:
581-
logger.debug("spacing of distances is not OK, untrusting all distances")
581+
logger.debug(
582+
"spacing of distances %s is not OK (d_min=%s, d_max=%s, "
583+
"min_spacing=%s), untrusting all distances",
584+
self.dists,
585+
self.trajectory.d_min,
586+
self.trajectory.d_max,
587+
params.min_spacing,
588+
)
582589
# if the spacing was not ok, it can still be bad after untrusting all points
583590
# since the total length can be too short for the points
584591
untrusted_dists = np.full_like(self.dists, np.NaN)

pysnapping/stats.py

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from dataclasses import dataclass, field
2+
from enum import Enum
3+
import typing
4+
from collections import Counter
5+
import logging
6+
7+
from pysnapping import SnappingError, SnappingMethod
8+
9+
if typing.TYPE_CHECKING:
10+
from pysnapping.snap import SnappedTripPoints, TrajectoryTrip
11+
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class AggregatedSnappingMethod(Enum):
17+
failed = "failed"
18+
all_trusted = "all_trusted"
19+
all_routed = "all_routed"
20+
all_fallback = "all_fallback"
21+
mixed = "mixed"
22+
total = "total"
23+
24+
25+
@dataclass
26+
class SnappingStats:
27+
method_counters: list[Counter[SnappingMethod]] = field(default_factory=list)
28+
aggregated_counter: Counter[AggregatedSnappingMethod] = field(
29+
default_factory=Counter
30+
)
31+
32+
def reset(self) -> None:
33+
self.method_counters.clear()
34+
self.aggregated_counter.clear()
35+
36+
def process_result(
37+
self, result: "SnappedTripPoints"
38+
) -> tuple[Counter[SnappingMethod], AggregatedSnappingMethod]:
39+
counter = Counter(result.methods)
40+
self.method_counters.append(counter)
41+
agg_method = self.aggregate_methods(counter)
42+
self.aggregated_counter[agg_method] += 1
43+
self.aggregated_counter[AggregatedSnappingMethod.total] += 1
44+
return counter, agg_method
45+
46+
def aggregate_methods(
47+
self, counter: Counter[SnappingMethod]
48+
) -> AggregatedSnappingMethod:
49+
n_total = sum(v for v in counter.values())
50+
if counter[SnappingMethod.trusted] == n_total:
51+
return AggregatedSnappingMethod.all_trusted
52+
elif counter[SnappingMethod.routed] == n_total:
53+
return AggregatedSnappingMethod.all_routed
54+
elif counter[SnappingMethod.fallback] == n_total:
55+
return AggregatedSnappingMethod.all_fallback
56+
else:
57+
return AggregatedSnappingMethod.mixed
58+
59+
def process_failure(self) -> None:
60+
agg_method = AggregatedSnappingMethod.failed
61+
self.aggregated_counter[agg_method] += 1
62+
self.aggregated_counter[AggregatedSnappingMethod.total] += 1
63+
logger.debug("snapping failed")
64+
65+
@typing.overload
66+
def snap_trip_points(
67+
self,
68+
ttrip: "TrajectoryTrip",
69+
*args,
70+
log_failed: bool = False,
71+
raise_failed: typing.Literal[True] = ...,
72+
**kwargs,
73+
) -> "SnappedTripPoints":
74+
...
75+
76+
@typing.overload
77+
def snap_trip_points(
78+
self,
79+
ttrip: "TrajectoryTrip",
80+
*args,
81+
log_failed: bool = False,
82+
raise_failed: typing.Literal[False],
83+
**kwargs,
84+
) -> typing.Optional["SnappedTripPoints"]:
85+
...
86+
87+
def snap_trip_points(
88+
self,
89+
ttrip: "TrajectoryTrip",
90+
*args,
91+
log_failed: bool = False,
92+
raise_failed: bool = True,
93+
**kwargs,
94+
) -> typing.Optional["SnappedTripPoints"]:
95+
"""Wrapper for `TrajectoryTrip.snap_trip_points` that updates the statistics.
96+
97+
Snapping statistics and failures are aggregated and logged at level DEBUG. Apart
98+
from that, by default, snapping errors are raised and not logged. By setting
99+
`raise_failed` and/or `log_failed` you can choose whether to raise and/or log
100+
failures at level ERROR (with traceback).
101+
102+
If you need more custom logging, you should either raise, catch and log or not
103+
use this wrapper and use `process_result` and `process_failure` instead for even
104+
more control.
105+
"""
106+
try:
107+
result = ttrip.snap_trip_points(*args, **kwargs)
108+
except SnappingError as error:
109+
self.process_failure()
110+
(logger.exception if log_failed else logger.debug)(
111+
"snapping %r failed: %s",
112+
ttrip,
113+
error,
114+
)
115+
if raise_failed:
116+
raise
117+
else:
118+
return None
119+
else:
120+
counter, agg_method = self.process_result(result)
121+
logger.debug(
122+
"snapping %r succeeded with methods %s aggregated to %s",
123+
ttrip,
124+
{k.value: v for k, v in counter.items()},
125+
agg_method.value,
126+
)
127+
return result
128+
129+
def log_aggregated(self, prefix: str = "") -> None:
130+
logger.info(
131+
"%s%s",
132+
prefix,
133+
{k.value: v for k, v in self.aggregated_counter.items()},
134+
)

0 commit comments

Comments
 (0)