sw7 detect laps from coordinates #9

Merged
schaermicha1 merged 2 commits from sw7_lap_detection into master 2026-05-08 15:45:25 +02:00
14 changed files with 1121 additions and 0 deletions

View File

@ -0,0 +1,11 @@
def load_scores(data: list[tuple[str, int | str]]) -> dict[str, int]:
result: dict[str, int] = {}
for name, points in data:
result[name] = int(points)
return result
if __name__ == "__main__":
print(load_scores([("Housi", 40), ("Theodolf", 45), ("Mehmet", 15)]))

35
src/lap_detection/api.py Normal file
View File

@ -0,0 +1,35 @@
from dataclasses import field, dataclass
from typing import List
@dataclass
class Coordinate:
lon: float
lat: float
@dataclass
class Geometry:
coordinates: list[list[float]]
type: str
@dataclass
class Feature:
type: str
properties: dict
geometry: Geometry
@dataclass
class Collection:
type: str
features: list[Feature]
@dataclass
class Lap:
start_index: int
end_index: int
distance_m: float
coords: List[Coordinate] = field(default_factory=list)

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -0,0 +1,254 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
[
8.765906945161646,
47.258748443007846
],
[
8.766561810054753,
47.259064494933625
],
[
8.767042044310301,
47.25977560486854
],
[
8.770243606009103,
47.26097064310798
],
[
8.773154116644207,
47.261770612311835
],
[
8.773779876431064,
47.262817467331416
],
[
8.774842212812928,
47.26337051445671
],
[
8.7770542008966,
47.262955729654294
],
[
8.779077005788508,
47.261819992730636
],
[
8.77968821302224,
47.26189900130527
],
[
8.78080875961777,
47.26096570498896
],
[
8.779790080895253,
47.26043238542155
],
[
8.778887822597824,
47.25847683437709
],
[
8.776704939621482,
47.25818053246326
],
[
8.775424314941517,
47.25800275051935
],
[
8.771378705157787,
47.25681752230369
],
[
8.767187569842747,
47.25661998168874
],
[
8.765848734950225,
47.258081764790205
],
[
8.76619799622648,
47.258891654270315
],
[
8.766983834097772,
47.25985955471677
],
[
8.769748819201538,
47.260886695021895
],
[
8.771960807285268,
47.26149901918447
],
[
8.773328747283983,
47.26185455899676
],
[
8.773707113666546,
47.262842157058344
],
[
8.774929528133953,
47.26341495549261
],
[
8.776530308982785,
47.263158184547166
],
[
8.778975137916603,
47.261933567519776
],
[
8.779760975789031,
47.261933567519776
],
[
8.780954285149079,
47.26102496238727
],
[
8.779906501320283,
47.26043238542155
],
[
8.778858717491573,
47.25843732755084
],
[
8.776035522175249,
47.25816077894348
],
[
8.773532483027822,
47.257469401105396
],
[
8.771931702178989,
47.25699530822746
],
[
8.769341347712782,
47.25665948987063
],
[
8.767333095374056,
47.25663973578335
],
[
8.766634572821516,
47.25742989352784
],
[
8.765848734950225,
47.25810151833983
],
[
8.766692783034046,
47.259365730169066
],
[
8.768148038352706,
47.26015584723416
],
[
8.769865239627705,
47.26082743746883
],
[
8.771698861327764,
47.261261991316445
],
[
8.772513804305333,
47.26157802823809
],
[
8.773445167709042,
47.26185455899676
],
[
8.773707113666546,
47.26282240527772
],
[
8.77498773834651,
47.26335570076975
],
[
8.776792254940261,
47.26319768785049
],
[
8.778684086853957,
47.261913815399794
],
[
8.77917887366155,
47.261913815399794
],
[
8.780081131959008,
47.261736045991285
],
[
8.78089607493655,
47.26104471483848
],
[
8.779935606426562,
47.260471890758794
],
[
8.778829612385294,
47.25855584794044
],
[
8.776704939621482,
47.25827929995211
],
[
8.773328747283983,
47.25744964732027
],
[
8.771320494945257,
47.256758260196676
],
[
8.769021191542777,
47.25671875208849
],
[
8.767216674949026,
47.25667924395049
],
[
8.765906945162811,
47.25806201123356
],
[
8.766110680907673,
47.25904968004181
]
],
"type": "LineString"
}
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@ -0,0 +1,230 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
[
8.680274515612865,
47.26389838973671
],
[
8.680175818438386,
47.264053132092755
],
[
8.680053297808996,
47.264117800406325
],
[
8.679849096758915,
47.26413858663324
],
[
8.679621072253923,
47.264030036247476
],
[
8.679512165026779,
47.263729789341
],
[
8.679399854450054,
47.26327941578782
],
[
8.67940325780063,
47.26311081342081
],
[
8.679512165026779,
47.263018428334334
],
[
8.679712962726285,
47.262967616467904
],
[
8.679968214037757,
47.2630138090758
],
[
8.680117961474764,
47.263189340617544
],
[
8.680165608385664,
47.263471113717145
],
[
8.680240482104153,
47.263815244403105
],
[
8.680260902209596,
47.26396536782704
],
[
8.680169011737263,
47.264050822508835
],
[
8.680080524615533,
47.26412241956851
],
[
8.679842290057849,
47.26414089621335
],
[
8.679631282305564,
47.2640462033404
],
[
8.679549601886038,
47.26386374586352
],
[
8.679410064501724,
47.263344085046214
],
[
8.679386241046757,
47.263138528915164
],
[
8.679481534869694,
47.26304614387709
],
[
8.679665315814304,
47.26296299720494
],
[
8.679937583880672,
47.26299764166757
],
[
8.680083927966052,
47.263131600042726
],
[
8.680189431841683,
47.263551949985214
],
[
8.680271112261238,
47.26394458153209
],
[
8.6801962385438,
47.2640554416769
],
[
8.679971617388333,
47.26413858663324
],
[
8.679801449846963,
47.26412934831126
],
[
8.679685735919747,
47.26408315667672
],
[
8.679580232044117,
47.26398384452665
],
[
8.679471324816973,
47.26360276129074
],
[
8.67942708125662,
47.263376419646136
],
[
8.679382837695215,
47.263170863640255
],
[
8.679539391833316,
47.26302304759247
],
[
8.679675525867026,
47.26296299720494
],
[
8.679883130267626,
47.26299995129776
],
[
8.680032877703553,
47.26308078828532
],
[
8.680090734668198,
47.26315931552659
],
[
8.68018262514056,
47.26351730588564
],
[
8.680240482104153,
47.26386605545562
],
[
8.680264305560144,
47.26398846370054
],
[
8.680124768175858,
47.264078537511125
],
[
8.679944390581795,
47.26415013453331
],
[
8.6797299794801,
47.26409701417123
],
[
8.679573425341943,
47.26397460617764
],
[
8.679508761676175,
47.26376212370499
],
[
8.679450904712553,
47.263494209806254
],
[
8.679382837695215,
47.26316162514942
],
[
8.679508761676175,
47.26305076313281
],
[
8.679658509113182,
47.26296992609943
],
[
8.679876323565509,
47.262972235730814
]
],
"type": "LineString"
}
}
]
}

View File

@ -0,0 +1,146 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
[
9.834547025245826,
46.80193545785821
],
[
9.835708992332172,
46.802531998995164
],
[
9.83625366440333,
46.803004256037525
],
[
9.836653090589152,
46.804048177947294
],
[
9.83708882824638,
46.805340624627235
],
[
9.8373793200183,
46.80653362477142
],
[
9.838105549447391,
46.807726598457776
],
[
9.83908595917572,
46.80879528158499
],
[
9.840320549204648,
46.809689976710644
],
[
9.840828909804458,
46.810311284014006
],
[
9.841192024519046,
46.81028643185974
],
[
9.842281368662782,
46.81051010083516
],
[
9.843407024276331,
46.8112059539223
],
[
9.844278499590757,
46.81185209515593
],
[
9.84467792577658,
46.811379915787256
],
[
9.845585712562325,
46.81155387708952
],
[
9.845948827276885,
46.81073376888085
],
[
9.845767269920316,
46.81016217091576
],
[
9.84522259784913,
46.809640271815994
],
[
9.845004729019848,
46.80894439847279
],
[
9.844605302833997,
46.80894439847279
],
[
9.844060630762868,
46.808298222306405
],
[
9.843298089862373,
46.80767689174925
],
[
9.843007598090423,
46.80755262477646
],
[
9.844205876648118,
46.807055554014994
],
[
9.844205876648118,
46.806483916960445
],
[
9.84464161430526,
46.805440042316604
],
[
9.84308022103312,
46.805738294281895
],
[
9.840901532747182,
46.805713440014216
],
[
9.839703254190908,
46.805564314169374
],
[
9.840865221275834,
46.80317824442912
],
[
9.842499237490642,
46.80133891010621
],
[
9.844060630762868,
46.79915151175959
]
],
"type": "LineString"
}
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

View File

@ -0,0 +1,150 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
[
8.04235788233035,
47.39493290884846
],
[
8.042885285759382,
47.394196547811134
],
[
8.043511577329639,
47.393493648131596
],
[
8.043841204472528,
47.393627534507715
],
[
8.044368607901589,
47.39304735775431
],
[
8.04623100125653,
47.39356059136196
],
[
8.047516547113759,
47.39120636999834
],
[
8.04857135396935,
47.39139605049661
],
[
8.049543754040798,
47.3916973063549
],
[
8.05041726596852,
47.39192045773251
],
[
8.05003819475445,
47.39296925655
],
[
8.049922825254868,
47.393962248959525
],
[
8.049774493041156,
47.39416307661054
],
[
8.04880209296968,
47.394107291228494
],
[
8.047219882685113,
47.393817206291374
],
[
8.046263963970716,
47.393605220135385
],
[
8.04657710975647,
47.39276842434944
],
[
8.04708803182848,
47.39186466997646
],
[
8.0474835843996,
47.391184054600785
],
[
8.048258208184848,
47.39134026218545
],
[
8.049016350612987,
47.391552257452844
],
[
8.049856899826551,
47.391753094288106
],
[
8.050433747325599,
47.39200971801864
],
[
8.050153564255254,
47.39256759138394
],
[
8.049972269326105,
47.393393233126204
],
[
8.049856899826551,
47.39391762048891
],
[
8.049560235397877,
47.39415191953893
],
[
8.0485548726123,
47.39416307661054
],
[
8.047153957256796,
47.393850677712095
],
[
8.046263963970716,
47.39361637732273
],
[
8.045588228329194,
47.39344901926367
],
[
8.04600026225745,
47.39275726698256
],
[
8.045720079185827,
47.39214360816524
],
[
8.044599346900696,
47.391753094288106
]
],
"type": "LineString"
}
}
]
}

View File

@ -0,0 +1,53 @@
from haversine import haversine, Unit
from lap_detection.api import Coordinate
def dist(a: Coordinate, b: Coordinate):
return haversine((a.lat, a.lon), (b.lat, b.lon), Unit.METERS)
def count_laps(coords: list[Coordinate], start_coord: Coordinate, radius: int) -> int:
"""Count the number of laps completed
by tracking the number of times that the starting coordinate was passed by the given coordinate list
Args:
coords (list[Coordinate]): the list of coordinates
start_coord (Coordinate): the coordinate to compare to
radius (int): the radius around the starting coordinate
Returns:
int: the number of laps completed
"""
if not coords:
return 0
laps = 0
distance_since_last = 0
# Do we start inside or outside the start/finish area?
d_to_first = dist(coords[0], start_coord)
inside = d_to_first < radius
for i in range(1, len(coords)):
prev = coords[i - 1]
curr = coords[i]
# Don't compare the starting coordinate with itself
if curr == start_coord:
continue
d = dist(prev, curr)
distance_since_last += d
# Did we enter the start/finish area?
d_to_start = dist(curr, start_coord)
now_inside = d_to_start < radius
if not inside and now_inside:
# Ignore possible gps stutters with minimum lap distance threshold
if distance_since_last > 100:
laps += 1
distance_since_last = 0
inside = now_inside
return laps

131
src/lap_detection/main.py Normal file
View File

@ -0,0 +1,131 @@
# TASK: Rundenzählung Stoppuhr
# Hintergrund: Sportuhren zeichnen oft mittels GPS alle x Sekunden die Koordination (lat/lon) des jeweiligen Standorts auf.
# Ein automatischer Modus detektiert, wenn der User eine Runde (Lap) gelaufen/gefahren ist und nimmt
# automatisch die Zwischenzeit.
#
# Aufgabe: Unsere Aufgabe ist es nun diese Rundendetektion selbst in Python zu schreiben. Der Programmierer bekommt
# eine .json-File mit Koordinaten. Diese sind immer als Liste [lon, lat], also z.B. [[8.5417, 47.3769],
# [8.5434, 47.3772], [8.5446, 47.3783], ...]. Unser Programm soll als Antwort einen Integer mit der
# ermittelten Anzahl Runden zurückgeben, also z.B. 2.
#
# - Die Aufgabe ist bewusst so offen gestellt, damit eure Kreativität nicht eingeschränkt ist. Ihr könnt den
# Schwierigkeitsgrad selbst steuern, wie stark ihr ins Detail wollt, was ihr berücksichtigen wollt und was
# nicht etc. Es geht damit auch nicht so sehr um die technischen Details, sondern darum, denn Code mithilfe
# von Hints und Docstrings möglichst aussagekräftig und selbsterklärend zu machen. Nutzt auch 2-3 Tests mit
# Pytest, um eueren Code 'sicherer' zu machen und zu validieren.
#
# Denkanstösse: - Was macht eine Runde aus?
# - Wie entscheide ich, was eine Runde ist (geometrisch, Randbedingungen) -> 'Kochrezept' machen
# - Wo schränke ich mich ein, wo mache ich Annahmen, ...
#
# Ziel: - Ein anderer User/Programmierer kann eure Funktionen und die darin hints/docstrings/tests lesen und
# versteht was in eurem Code passiert, ohne den eigentlichen Code anschauen zu müssen.
# - Es muss keine Perfektion angestrebt werden, es geht um die praktische Umsetzung der Theorie in einer
# Praxisanwendung! Macht Annahmen/Vereinfachungen!
# - Diejenigen, welche das Beispiel abgeben wollen, machen am besten einen
# - neuen Branch (Aufgabe Rundenzählung)
# - main-Funktion mit der Hauptfunktion, allfällige Helferfunktionen in einem separaten Modul
# - test-Ordner mit 2-3 Pytests
# - ein kurzer Readme mit Erklärung der Funktionsweise eures Rundenzählers und allfällige
# Einschränkungen/Annahmen
# - alle Funktionen mit sauberen Typehints und docstrings (Validation falls nötig)
# -> commit und PR
#
# Hilfsmittel: - für benötigte geometrische Berechnungen dürfen auch KI-Hilfsmittel (Prompt Engineering) genutzt werden!
# - Bitte nützt aber die KI nicht, um das ganze Programm zu erstellen, dokumentieren und testen :-)
#
# Hints: - für eine allfällige Distanz zwischen 2 Punkten -> haversine
#
# Usecases: - Im Ordner 'data' befinden sich 4 usecases mit Koordinaten.
# - Lösungen für die usecases:
# - usecase1: 3
# - usecase2: 3 (3 3/4)
# - usecase3: 0
# - usecase4: 2
# - Schreibt aber auch noch 1-2 eigene Testcases für allfällige wichtige Hilfsfunktionen.
from pathlib import Path
import json
from dacite import from_dict
from lap_detection.api import Collection, Coordinate
from lap_detection.lap_detector import count_laps, dist
def _check_path(path: str) -> Path:
"""Checks the existence of the provided path
Args:
path (str): The path
Returns:
a Path object
Raises:
ValueError: If the path is not present
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"{path} does not exist")
return path
def _read_file(path: Path) -> Collection:
"""Reads the provided file
Args:
path (str): The path of the file
Returns:
an object of type Collection
"""
with path.open("r", encoding="utf-8") as f:
loaded_file = json.load(f)
collection = from_dict(data_class=Collection, data=loaded_file)
return collection
def _build_coordinates(collection: Collection) -> list[Coordinate]:
"""Build a list of Coordinate objects from a Collection
Args:
collection (Collection): the feature collection
Returns:
a list of Coordinate objects
"""
return [
Coordinate(coordinate[0], coordinate[1])
for coordinate in collection.features[0].geometry.coordinates
]
def get_coordinates(path: str) -> list[Coordinate]:
"""Validate the path, read the file and extract the coordinates
Args:
path (str): The path of the file
Returns:
a list of Coordinate objects
"""
validated_path = _check_path(path)
data = _read_file(validated_path)
coordinates = _build_coordinates(data)
return coordinates
def main(path: str) -> None:
coords = get_coordinates(path=path)
# detect laps -> business logic (build functions)
# Set the radius of the start/finish area based of the minimum distance of two following points
radius = min([dist(coords[i - 1], coords[i]) for i in range(len(coords))])
# Count laps from each coordinate
# The maximum indicates that the starting point is in a high traffic area
n_rounds = max([count_laps(coords, start, radius) for start in coords])
print(f"Anzahl Runden: {n_rounds}")
if __name__ == "__main__":
main("data/usecase1.json")
main("data/usecase2.json")
main("data/usecase3.json")
main("data/usecase4.json")

View File

@ -0,0 +1,57 @@
import pytest
from lap_detection.api import Collection, Feature, Geometry, Coordinate
import lap_detection.main as main
# arrange
@pytest.fixture
def sample_collection():
return Collection(
"FeatureCollection",
[
Feature(
"Feature",
{},
Geometry([[123.456, 654.321], [987.654, 101.010]], "LineString"),
)
],
)
# arrange
@pytest.mark.parametrize(
"path,expected_laps",
[
("src/lap_detection/data/usecase1.json", 3),
("src/lap_detection/data/usecase2.json", 3),
("src/lap_detection/data/usecase3.json", 0),
("src/lap_detection/data/usecase4.json", 2),
],
)
def test_main(path, expected_laps, capsys):
# act
main.main(path)
# assert
captured = capsys.readouterr()
assert captured.out.strip() == f"Anzahl Runden: {expected_laps}"
def test_check_invalid_path():
# assert
with pytest.raises(FileNotFoundError, match="invalid_path.json does not exist"):
# act
main._check_path("invalid_path.json")
def test_build_coordinates(sample_collection):
# act
coordinates = main._build_coordinates(sample_collection)
# assert
assert isinstance(coordinates, list)
assert len(coordinates) == 2
assert isinstance(coordinates[1], Coordinate)
assert coordinates[1].lon == 987.654
assert coordinates[1].lat == 101.010

View File

@ -0,0 +1,54 @@
from lap_detection.api import Coordinate
from lap_detection.lap_detector import count_laps
def test_no_laps():
# arrange
start = Coordinate(lat=47.0, lon=8.0)
coords = [
Coordinate(lat=47.0001, lon=8.0001),
Coordinate(lat=47.0002, lon=8.0002),
Coordinate(lat=47.0003, lon=8.0003),
]
# act
result = count_laps(coords, start, radius=20)
# assert
assert result == 0
def test_single_lap():
# arrange
start = Coordinate(lat=47.0, lon=8.0)
coords = [
Coordinate(lat=47.0030, lon=8.0000), # far from start
Coordinate(lat=47.0020, lon=8.0010),
Coordinate(lat=47.0010, lon=8.0020),
Coordinate(lat=47.0001, lon=8.0001), # back near start -> lap counted
Coordinate(lat=47.0030, lon=8.0000), # far again -> no second lap
]
# act
result = count_laps(coords, start, radius=20)
# assert
assert result == 1
def test_two_laps():
# arrange
start = Coordinate(lat=47.0, lon=8.0)
coords = [
Coordinate(lat=47.0030, lon=8.0000), # far from start
Coordinate(lat=47.0001, lon=8.0001), # back near start -> lap 1
Coordinate(lat=47.0002, lon=8.0002), # still near start -> don't count twice
Coordinate(lat=47.0030, lon=8.0000), # far again
Coordinate(lat=47.0001, lon=8.0001), # back near start -> lap 2
]
# act
result = count_laps(coords, start, radius=20)
# assert
assert result == 2