compass-lib 0.0.2__py3-none-any.whl → 0.0.3__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.
- compass_lib/__init__.py +113 -3
- compass_lib/commands/__init__.py +2 -0
- compass_lib/commands/convert.py +223 -36
- compass_lib/commands/encrypt.py +33 -7
- compass_lib/commands/geojson.py +118 -0
- compass_lib/commands/main.py +1 -1
- compass_lib/constants.py +84 -36
- compass_lib/enums.py +292 -84
- compass_lib/errors.py +86 -0
- compass_lib/geo_utils.py +47 -0
- compass_lib/geojson.py +1024 -0
- compass_lib/interface.py +332 -0
- compass_lib/io.py +246 -0
- compass_lib/models.py +217 -95
- compass_lib/plot/__init__.py +28 -0
- compass_lib/plot/models.py +265 -0
- compass_lib/plot/parser.py +610 -0
- compass_lib/project/__init__.py +36 -0
- compass_lib/project/format.py +158 -0
- compass_lib/project/models.py +494 -0
- compass_lib/project/parser.py +638 -0
- compass_lib/survey/__init__.py +24 -0
- compass_lib/survey/format.py +284 -0
- compass_lib/survey/models.py +160 -0
- compass_lib/survey/parser.py +842 -0
- compass_lib/validation.py +74 -0
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/METADATA +8 -11
- compass_lib-0.0.3.dist-info/RECORD +31 -0
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/entry_points.txt +2 -1
- compass_lib/encoding.py +0 -27
- compass_lib/parser.py +0 -435
- compass_lib/utils.py +0 -15
- compass_lib-0.0.2.dist-info/RECORD +0 -16
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/WHEEL +0 -0
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Formatting (serialization) for Compass .DAT survey files.
|
|
3
|
+
|
|
4
|
+
This module provides functions to convert survey data models back to
|
|
5
|
+
the Compass .DAT file format string representation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from compass_lib.constants import FLAG_CHARS
|
|
11
|
+
from compass_lib.constants import MISSING_VALUE_STRING
|
|
12
|
+
from compass_lib.constants import NUMBER_WIDTH
|
|
13
|
+
from compass_lib.constants import STATION_NAME_WIDTH
|
|
14
|
+
from compass_lib.enums import LrudAssociation
|
|
15
|
+
from compass_lib.survey.models import CompassShot
|
|
16
|
+
from compass_lib.survey.models import CompassTrip
|
|
17
|
+
from compass_lib.survey.models import CompassTripHeader
|
|
18
|
+
from compass_lib.validation import validate_station_name
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cell(value: str, width: int) -> str:
|
|
22
|
+
"""Format a value as a right-aligned cell.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
value: String value
|
|
26
|
+
width: Minimum width (actual width may be larger if value is longer)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Right-aligned, space-padded string (never truncated to preserve data)
|
|
30
|
+
"""
|
|
31
|
+
# Never truncate - the parser is whitespace-delimited so longer values work fine
|
|
32
|
+
# Always ensure at least one leading space to separate from previous column
|
|
33
|
+
if len(value) >= width:
|
|
34
|
+
return " " + value
|
|
35
|
+
return value.rjust(width)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _format_number(value: float | None, width: int = NUMBER_WIDTH) -> str:
|
|
39
|
+
"""Format a number for output.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
value: Numeric value (None for missing)
|
|
43
|
+
width: Column width
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Formatted string representation
|
|
47
|
+
"""
|
|
48
|
+
if value is None:
|
|
49
|
+
return _cell(MISSING_VALUE_STRING, width)
|
|
50
|
+
# Check if value has more than 2 decimal places of precision
|
|
51
|
+
# by comparing rounded vs original
|
|
52
|
+
rounded_2 = round(value, 2)
|
|
53
|
+
if abs(value - rounded_2) > 1e-9:
|
|
54
|
+
# Value has more precision, use 3 decimal places
|
|
55
|
+
return _cell(f"{value:.3f}", width)
|
|
56
|
+
return _cell(f"{value:.2f}", width)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_shot(shot: CompassShot, header: CompassTripHeader) -> str:
|
|
60
|
+
"""Format a single shot as a line of text.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
shot: Shot data
|
|
64
|
+
header: Trip header (for format settings)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Formatted shot line with CRLF
|
|
68
|
+
"""
|
|
69
|
+
# Validate station names
|
|
70
|
+
validate_station_name(shot.from_station_name)
|
|
71
|
+
validate_station_name(shot.to_station_name)
|
|
72
|
+
|
|
73
|
+
# Handle depth gauge inclination unit
|
|
74
|
+
# (would need Length unit instead of Angle, but we store as float)
|
|
75
|
+
|
|
76
|
+
columns = [
|
|
77
|
+
_cell(shot.from_station_name, STATION_NAME_WIDTH),
|
|
78
|
+
_cell(shot.to_station_name, STATION_NAME_WIDTH),
|
|
79
|
+
_format_number(shot.length),
|
|
80
|
+
_format_number(shot.frontsight_azimuth),
|
|
81
|
+
_format_number(shot.frontsight_inclination),
|
|
82
|
+
_format_number(shot.left),
|
|
83
|
+
_format_number(shot.up),
|
|
84
|
+
_format_number(shot.down),
|
|
85
|
+
_format_number(shot.right),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Add backsights if present
|
|
89
|
+
if header.has_backsights:
|
|
90
|
+
columns.append(_format_number(shot.backsight_azimuth))
|
|
91
|
+
columns.append(_format_number(shot.backsight_inclination))
|
|
92
|
+
|
|
93
|
+
# Build flags
|
|
94
|
+
flags = ""
|
|
95
|
+
if shot.excluded_from_length:
|
|
96
|
+
flags += FLAG_CHARS["exclude_distance"]
|
|
97
|
+
if shot.excluded_from_plotting:
|
|
98
|
+
flags += FLAG_CHARS["exclude_from_plotting"]
|
|
99
|
+
if shot.excluded_from_all_processing:
|
|
100
|
+
flags += FLAG_CHARS["exclude_from_all_processing"]
|
|
101
|
+
if shot.do_not_adjust:
|
|
102
|
+
flags += FLAG_CHARS["do_not_adjust"]
|
|
103
|
+
|
|
104
|
+
if flags:
|
|
105
|
+
columns.append(f" #|{flags}#")
|
|
106
|
+
|
|
107
|
+
# Add comment
|
|
108
|
+
if shot.comment:
|
|
109
|
+
# Clean comment: replace newlines with space (no truncation to preserve data)
|
|
110
|
+
clean_comment = shot.comment.replace("\r\n", " ").replace("\n", " ")
|
|
111
|
+
clean_comment = clean_comment.strip()
|
|
112
|
+
columns.append(" " + clean_comment)
|
|
113
|
+
|
|
114
|
+
columns.append("\r\n")
|
|
115
|
+
return "".join(columns)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def format_trip_header(
|
|
119
|
+
header: CompassTripHeader,
|
|
120
|
+
*,
|
|
121
|
+
include_column_headers: bool = True,
|
|
122
|
+
) -> str:
|
|
123
|
+
"""Format a trip header as text.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
header: Trip header data
|
|
127
|
+
include_column_headers: Whether to include column header line
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Formatted header text with CRLF
|
|
131
|
+
"""
|
|
132
|
+
lines = []
|
|
133
|
+
|
|
134
|
+
# Cave name (no truncation to preserve data)
|
|
135
|
+
lines.append(header.cave_name or "")
|
|
136
|
+
|
|
137
|
+
# Survey name (preserve full name for roundtrip, Compass may use longer names)
|
|
138
|
+
survey_name = header.survey_name or ""
|
|
139
|
+
lines.append(f"SURVEY NAME: {survey_name}")
|
|
140
|
+
|
|
141
|
+
# Survey date and comment
|
|
142
|
+
if header.date:
|
|
143
|
+
date_str = f"{header.date.month} {header.date.day} {header.date.year}"
|
|
144
|
+
else:
|
|
145
|
+
date_str = "1 1 1"
|
|
146
|
+
|
|
147
|
+
date_line = f"SURVEY DATE: {date_str}"
|
|
148
|
+
if header.comment:
|
|
149
|
+
date_line += f" COMMENT:{header.comment}"
|
|
150
|
+
lines.append(date_line)
|
|
151
|
+
|
|
152
|
+
# Team (no truncation to preserve data)
|
|
153
|
+
lines.append("SURVEY TEAM:")
|
|
154
|
+
lines.append(header.team or "")
|
|
155
|
+
|
|
156
|
+
# Declination and format
|
|
157
|
+
declination = header.declination
|
|
158
|
+
format_items = [
|
|
159
|
+
header.azimuth_unit.value,
|
|
160
|
+
header.length_unit.value,
|
|
161
|
+
header.lrud_unit.value,
|
|
162
|
+
header.inclination_unit.value,
|
|
163
|
+
]
|
|
164
|
+
format_items.extend(item.value for item in header.lrud_order)
|
|
165
|
+
format_items.extend(item.value for item in header.shot_measurement_order)
|
|
166
|
+
|
|
167
|
+
# Backsight indicator
|
|
168
|
+
if header.has_backsights or header.lrud_association:
|
|
169
|
+
format_items.append("B" if header.has_backsights else "N")
|
|
170
|
+
assoc = header.lrud_association or LrudAssociation.FROM
|
|
171
|
+
format_items.append(assoc.value)
|
|
172
|
+
|
|
173
|
+
format_str = "".join(format_items)
|
|
174
|
+
decl_line = f"DECLINATION: {declination:.2f} FORMAT: {format_str}"
|
|
175
|
+
|
|
176
|
+
# Corrections
|
|
177
|
+
has_corrections = any(
|
|
178
|
+
[
|
|
179
|
+
header.length_correction,
|
|
180
|
+
header.frontsight_azimuth_correction,
|
|
181
|
+
header.frontsight_inclination_correction,
|
|
182
|
+
]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if has_corrections:
|
|
186
|
+
corr_values = [
|
|
187
|
+
header.length_correction or 0.0,
|
|
188
|
+
header.frontsight_azimuth_correction or 0.0,
|
|
189
|
+
header.frontsight_inclination_correction or 0.0,
|
|
190
|
+
]
|
|
191
|
+
corr_str = " ".join(f"{v:.2f}" for v in corr_values)
|
|
192
|
+
decl_line += f" CORRECTIONS: {corr_str}"
|
|
193
|
+
|
|
194
|
+
# Backsight corrections
|
|
195
|
+
has_bs_corrections = any(
|
|
196
|
+
[
|
|
197
|
+
header.backsight_azimuth_correction,
|
|
198
|
+
header.backsight_inclination_correction,
|
|
199
|
+
]
|
|
200
|
+
)
|
|
201
|
+
if has_bs_corrections:
|
|
202
|
+
bs_values = [
|
|
203
|
+
header.backsight_azimuth_correction or 0.0,
|
|
204
|
+
header.backsight_inclination_correction or 0.0,
|
|
205
|
+
]
|
|
206
|
+
bs_str = " ".join(f"{v:.2f}" for v in bs_values)
|
|
207
|
+
decl_line += f" CORRECTIONS2: {bs_str}"
|
|
208
|
+
|
|
209
|
+
lines.append(decl_line)
|
|
210
|
+
|
|
211
|
+
# Column headers
|
|
212
|
+
if include_column_headers:
|
|
213
|
+
lines.append("") # Blank line
|
|
214
|
+
|
|
215
|
+
col_headers = [
|
|
216
|
+
"FROM ",
|
|
217
|
+
"TO ",
|
|
218
|
+
"LEN ",
|
|
219
|
+
"BEAR ",
|
|
220
|
+
"INC ",
|
|
221
|
+
"LEFT ",
|
|
222
|
+
"UP ",
|
|
223
|
+
"DOWN ",
|
|
224
|
+
"RIGHT ",
|
|
225
|
+
]
|
|
226
|
+
if header.has_backsights:
|
|
227
|
+
col_headers.append("AZM2 ")
|
|
228
|
+
col_headers.append("INC2 ")
|
|
229
|
+
col_headers.append("FLAGS ")
|
|
230
|
+
col_headers.append("COMMENTS")
|
|
231
|
+
|
|
232
|
+
lines.append("".join(col_headers))
|
|
233
|
+
lines.append("") # Blank line before data
|
|
234
|
+
|
|
235
|
+
# Join with CRLF
|
|
236
|
+
return "\r\n".join(lines) + "\r\n"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def format_trip(trip: CompassTrip, *, include_column_headers: bool = True) -> str:
|
|
240
|
+
"""Format a complete trip (header + shots).
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
trip: Trip data
|
|
244
|
+
include_column_headers: Whether to include column headers
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Formatted trip text
|
|
248
|
+
"""
|
|
249
|
+
parts = [
|
|
250
|
+
format_trip_header(trip.header, include_column_headers=include_column_headers)
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
parts.extend(format_shot(shot, trip.header) for shot in trip.shots)
|
|
254
|
+
|
|
255
|
+
return "".join(parts)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def format_dat_file(
|
|
259
|
+
trips: list[CompassTrip],
|
|
260
|
+
*,
|
|
261
|
+
write: Callable[[str], None] | None = None,
|
|
262
|
+
) -> str | None:
|
|
263
|
+
"""Format a complete DAT file from trips.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
trips: List of trips
|
|
267
|
+
write: Optional callback for streaming output. If provided,
|
|
268
|
+
chunks are written via this callback and None is returned.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Formatted file content as string (if write is None),
|
|
272
|
+
or None (if write callback is provided)
|
|
273
|
+
"""
|
|
274
|
+
if write is not None:
|
|
275
|
+
# Streaming mode
|
|
276
|
+
for trip in trips:
|
|
277
|
+
write(format_trip(trip))
|
|
278
|
+
write("\f\r\n")
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# Return mode
|
|
282
|
+
chunks: list[str] = []
|
|
283
|
+
format_dat_file(trips, write=chunks.append)
|
|
284
|
+
return "".join(chunks)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Survey data models for Compass .DAT files.
|
|
3
|
+
|
|
4
|
+
This module contains Pydantic models for representing survey data:
|
|
5
|
+
- CompassShot: A single shot between two stations
|
|
6
|
+
- CompassTripHeader: Metadata and settings for a survey trip
|
|
7
|
+
- CompassTrip: A complete trip with header and shots
|
|
8
|
+
- CompassDatFile: A DAT file containing one or more trips
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime # noqa: TC003
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from pydantic import ConfigDict
|
|
17
|
+
from pydantic import Field
|
|
18
|
+
from pydantic import field_serializer
|
|
19
|
+
from pydantic import field_validator
|
|
20
|
+
|
|
21
|
+
from compass_lib.enums import AzimuthUnit
|
|
22
|
+
from compass_lib.enums import InclinationUnit
|
|
23
|
+
from compass_lib.enums import LengthUnit
|
|
24
|
+
from compass_lib.enums import LrudAssociation
|
|
25
|
+
from compass_lib.enums import LrudItem
|
|
26
|
+
from compass_lib.enums import ShotItem
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CompassShot(BaseModel):
|
|
30
|
+
"""A single survey shot between two stations.
|
|
31
|
+
|
|
32
|
+
All measurements are stored in internal units (feet, degrees).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(
|
|
36
|
+
populate_by_name=True,
|
|
37
|
+
extra="ignore",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from_station_name: str = Field(alias="from_station")
|
|
41
|
+
to_station_name: str = Field(alias="to_station")
|
|
42
|
+
length: float | None = Field(default=None, alias="distance")
|
|
43
|
+
frontsight_azimuth: float | None = None
|
|
44
|
+
frontsight_inclination: float | None = None
|
|
45
|
+
backsight_azimuth: float | None = None
|
|
46
|
+
backsight_inclination: float | None = None
|
|
47
|
+
left: float | None = None
|
|
48
|
+
right: float | None = None
|
|
49
|
+
up: float | None = None
|
|
50
|
+
down: float | None = None
|
|
51
|
+
comment: str | None = None
|
|
52
|
+
excluded_from_length: bool = Field(default=False, alias="exclude_distance")
|
|
53
|
+
excluded_from_plotting: bool = False
|
|
54
|
+
excluded_from_all_processing: bool = False
|
|
55
|
+
do_not_adjust: bool = False
|
|
56
|
+
|
|
57
|
+
# NOTE: Validation is relaxed to allow real-world data with out-of-range values.
|
|
58
|
+
# The parser tracks these issues in its errors list.
|
|
59
|
+
# Strict validation can be performed separately if needed.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CompassTripHeader(BaseModel):
|
|
63
|
+
"""Metadata and settings for a survey trip."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
66
|
+
|
|
67
|
+
cave_name: str | None = None
|
|
68
|
+
survey_name: str | None = None
|
|
69
|
+
date: datetime.date | None = None
|
|
70
|
+
comment: str | None = None
|
|
71
|
+
team: str | None = None
|
|
72
|
+
declination: float = 0.0
|
|
73
|
+
length_unit: LengthUnit = LengthUnit.DECIMAL_FEET
|
|
74
|
+
lrud_unit: LengthUnit = LengthUnit.DECIMAL_FEET
|
|
75
|
+
azimuth_unit: AzimuthUnit = AzimuthUnit.DEGREES
|
|
76
|
+
inclination_unit: InclinationUnit = InclinationUnit.DEGREES
|
|
77
|
+
lrud_order: list[LrudItem] = Field(
|
|
78
|
+
default_factory=lambda: [
|
|
79
|
+
LrudItem.LEFT,
|
|
80
|
+
LrudItem.RIGHT,
|
|
81
|
+
LrudItem.UP,
|
|
82
|
+
LrudItem.DOWN,
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
shot_measurement_order: list[ShotItem] = Field(
|
|
86
|
+
default_factory=lambda: [
|
|
87
|
+
ShotItem.LENGTH,
|
|
88
|
+
ShotItem.FRONTSIGHT_AZIMUTH,
|
|
89
|
+
ShotItem.FRONTSIGHT_INCLINATION,
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
has_backsights: bool = True
|
|
93
|
+
lrud_association: LrudAssociation = LrudAssociation.FROM
|
|
94
|
+
length_correction: float = 0.0
|
|
95
|
+
frontsight_azimuth_correction: float = 0.0
|
|
96
|
+
frontsight_inclination_correction: float = 0.0
|
|
97
|
+
backsight_azimuth_correction: float = 0.0
|
|
98
|
+
backsight_inclination_correction: float = 0.0
|
|
99
|
+
|
|
100
|
+
@field_serializer("comment", "team")
|
|
101
|
+
@classmethod
|
|
102
|
+
def serialize_empty_as_none(cls, v: str | None) -> str | None:
|
|
103
|
+
return None if v == "" else v
|
|
104
|
+
|
|
105
|
+
@field_serializer("lrud_order")
|
|
106
|
+
@classmethod
|
|
107
|
+
def serialize_lrud_order(cls, v: list[LrudItem]) -> list[str]:
|
|
108
|
+
return [item.value for item in v]
|
|
109
|
+
|
|
110
|
+
@field_serializer("shot_measurement_order")
|
|
111
|
+
@classmethod
|
|
112
|
+
def serialize_shot_order(cls, v: list[ShotItem]) -> list[str]:
|
|
113
|
+
return [item.value for item in v]
|
|
114
|
+
|
|
115
|
+
@field_validator("lrud_order", mode="before")
|
|
116
|
+
@classmethod
|
|
117
|
+
def parse_lrud_order(cls, v: list) -> list[LrudItem]:
|
|
118
|
+
if v and isinstance(v[0], str):
|
|
119
|
+
return [LrudItem(item) for item in v]
|
|
120
|
+
return v
|
|
121
|
+
|
|
122
|
+
@field_validator("shot_measurement_order", mode="before")
|
|
123
|
+
@classmethod
|
|
124
|
+
def parse_shot_order(cls, v: list) -> list[ShotItem]:
|
|
125
|
+
if v and isinstance(v[0], str):
|
|
126
|
+
return [ShotItem(item) for item in v]
|
|
127
|
+
return v
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CompassTrip(BaseModel):
|
|
131
|
+
"""A complete survey trip with header and shots."""
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
134
|
+
|
|
135
|
+
header: CompassTripHeader
|
|
136
|
+
shots: list[CompassShot] = Field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CompassDatFile(BaseModel):
|
|
140
|
+
"""A Compass .DAT file containing one or more survey trips."""
|
|
141
|
+
|
|
142
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
143
|
+
|
|
144
|
+
trips: list[CompassTrip] = Field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def total_shots(self) -> int:
|
|
148
|
+
return sum(len(trip.shots) for trip in self.trips)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def trip_names(self) -> list[str]:
|
|
152
|
+
return [trip.header.survey_name or "<unnamed>" for trip in self.trips]
|
|
153
|
+
|
|
154
|
+
def get_all_stations(self) -> set[str]:
|
|
155
|
+
stations: set[str] = set()
|
|
156
|
+
for trip in self.trips:
|
|
157
|
+
for shot in trip.shots:
|
|
158
|
+
stations.add(shot.from_station_name)
|
|
159
|
+
stations.add(shot.to_station_name)
|
|
160
|
+
return stations
|