clarity-api-sdk-python 0.4.3__tar.gz → 0.4.4__tar.gz

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.
Files changed (64) hide show
  1. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/PKG-INFO +1 -1
  2. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/pyproject.toml +1 -1
  3. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/PKG-INFO +1 -1
  4. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/SOURCES.txt +1 -0
  5. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/api/sonar_wiz_async_api.py +21 -0
  6. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/cli/main.py +288 -60
  7. clarity_api_sdk_python-0.4.4/src/cti/events/__init__.py +152 -0
  8. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/README.md +0 -0
  9. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/setup.cfg +0 -0
  10. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/dependency_links.txt +0 -0
  11. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/entry_points.txt +0 -0
  12. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/requires.txt +0 -0
  13. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/top_level.txt +0 -0
  14. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/__init__.py +0 -0
  15. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/api/__init__.py +0 -0
  16. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/api/async_client.py +0 -0
  17. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/api/client.py +0 -0
  18. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/api/session.py +0 -0
  19. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/api/sonar_wiz_api.py +0 -0
  20. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/cli/__init__.py +0 -0
  21. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/cli/__main__.py +0 -0
  22. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/cli/client.py +0 -0
  23. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/logger/__init__.py +0 -0
  24. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/logger/logger.py +0 -0
  25. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/main.py +0 -0
  26. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/main_api.py +0 -0
  27. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/__init__.py +0 -0
  28. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/altitude_source.py +0 -0
  29. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/attitude_source.py +0 -0
  30. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/deferred_object_deletion.py +0 -0
  31. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/depth_source.py +0 -0
  32. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/device.py +0 -0
  33. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/device_type.py +0 -0
  34. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/final_product.py +0 -0
  35. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/hierarchy.py +0 -0
  36. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_algorithm.py +0 -0
  37. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_source.py +0 -0
  38. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_type.py +0 -0
  39. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/organization.py +0 -0
  40. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/platform.py +0 -0
  41. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/platform_type.py +0 -0
  42. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/position_source.py +0 -0
  43. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/processing_job.py +0 -0
  44. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/processing_log.py +0 -0
  45. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/project.py +0 -0
  46. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/projection_option.py +0 -0
  47. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file.py +0 -0
  48. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file_configuration.py +0 -0
  49. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file_device_mapping.py +0 -0
  50. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file_state.py +0 -0
  51. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/s3.py +0 -0
  52. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/sidescan_ping_source.py +0 -0
  53. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/source.py +0 -0
  54. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/survey.py +0 -0
  55. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/target.py +0 -0
  56. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/tow_system.py +0 -0
  57. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/model/user_layer.py +0 -0
  58. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/positioning/__init__.py +0 -0
  59. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/src/cti/positioning/target_geometry.py +0 -0
  60. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/tests/test_cli.py +0 -0
  61. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/tests/test_raw_file_device_mapping_model.py +0 -0
  62. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/tests/test_sdk_async_methods.py +0 -0
  63. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/tests/test_sdk_methods.py +0 -0
  64. {clarity_api_sdk_python-0.4.3 → clarity_api_sdk_python-0.4.4}/tests/test_target_geometry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clarity-api-sdk-python
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: A Python SDK to connect to the CTI Clarity API server.
5
5
  Author-email: "Chesapeake Technology Inc." <support@chesapeaketech.com>
6
6
  Project-URL: Homepage, https://github.com/chesapeake-tech/clarity-api-sdk-python
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "clarity-api-sdk-python"
8
- version = "0.4.3"
8
+ version = "0.4.4"
9
9
  authors = [
10
10
  { name="Chesapeake Technology Inc.", email="support@chesapeaketech.com" },
11
11
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clarity-api-sdk-python
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: A Python SDK to connect to the CTI Clarity API server.
5
5
  Author-email: "Chesapeake Technology Inc." <support@chesapeaketech.com>
6
6
  Project-URL: Homepage, https://github.com/chesapeake-tech/clarity-api-sdk-python
@@ -19,6 +19,7 @@ src/cti/cli/__init__.py
19
19
  src/cti/cli/__main__.py
20
20
  src/cti/cli/client.py
21
21
  src/cti/cli/main.py
22
+ src/cti/events/__init__.py
22
23
  src/cti/logger/__init__.py
23
24
  src/cti/logger/logger.py
24
25
  src/cti/model/__init__.py
@@ -1902,6 +1902,27 @@ class SonarWizAsyncApi:
1902
1902
  response.raise_for_status()
1903
1903
  return [FinalProduct.model_validate(item) for item in response.json()]
1904
1904
 
1905
+ async def delete_final_product(self, final_product_id: UUID | str) -> None:
1906
+ """Hard-delete a final product row.
1907
+
1908
+ Used by ``xs_pipeline`` to clear superseded ``FinalProduct`` rows
1909
+ for a (survey, product_type) before inserting the fresh one — so
1910
+ the latest run is the only product the client UI auto-picks.
1911
+ S3 artifacts at the row's ``file_path`` are not touched here;
1912
+ callers that need full cleanup should remove the S3 prefix
1913
+ separately.
1914
+
1915
+ Args:
1916
+ final_product_id: Final product UUID or string identifier.
1917
+
1918
+ Raises:
1919
+ httpx.HTTPStatusError: 404 if the product was already gone.
1920
+ """
1921
+ response = await self._client.delete(
1922
+ f"/api/v1/final-products/{final_product_id}"
1923
+ )
1924
+ response.raise_for_status()
1925
+
1905
1926
  async def update_final_product(
1906
1927
  self, final_product_id: UUID | str, final_product: FinalProductUpdate
1907
1928
  ) -> FinalProduct:
@@ -113,67 +113,20 @@ def upload(
113
113
  f"Uploaded: {result.raw_file_id} ({result.file_name}, {result.file_size} bytes)"
114
114
  )
115
115
 
116
- # Auto-create device mappings
116
+ # Stream-to-device mapping is handled by ``ftm automap`` after upload.
117
+ # Auto-mapping at upload time was guessing stream URIs (e.g.
118
+ # ``xtf://sidescan(source=ping_header,pair=0)``) that don't match what
119
+ # the engine's scan phase actually emits in the manifest, so the
120
+ # rows it created weren't usable downstream. Run ``ftm automap`` after
121
+ # upload — it reads the actual scan manifest and replicates the UI's
122
+ # "Generate auto-platform" + "Auto-mapping" buttons.
117
123
  if not no_mappings:
118
- try:
119
- # Get the survey's platform and its devices
120
- platform = api.get_first_survey_platform(survey_id=survey)
121
- devices = api.get_platform_devices(platform_id=platform.platform_id)
122
-
123
- # Look up device types to identify sidescan and GNSS
124
- dt_list = api.list_device_types()
125
- dt_by_id = {dt.device_type_id: dt for dt in dt_list}
126
-
127
- # Get survey timezone for mapping
128
- survey_obj = api.get_survey(survey)
129
- tz = survey_obj.timezone_name
130
-
131
- mappings_created = 0
132
- for device in devices:
133
- dt = dt_by_id.get(device.device_type_id)
134
- if not dt:
135
- continue
136
- if dt.enum_name == "sidescan_sonar":
137
- stream_id = _identifier_from_uri(
138
- sidescan_stream, ("samples", "timestamp")
139
- )
140
- elif dt.enum_name == "gnss_gps_position_sensor":
141
- stream_id = _identifier_from_uri(
142
- gnss_stream, ("position", "timestamp")
143
- )
144
- else:
145
- continue
146
-
147
- api.create_raw_file_device_mapping(
148
- RawFileDeviceMappingCreate(
149
- raw_file_id=result.raw_file_id,
150
- device_id=device.device_id,
151
- source_stream_identifier=stream_id,
152
- input_srid=4326,
153
- input_timezone=tz,
154
- )
155
- )
156
- typer.echo(
157
- f"Mapped: {device.name} → stream {stream_id.source.selected}"
158
- )
159
- mappings_created += 1
160
-
161
- if mappings_created == 0:
162
- typer.echo(
163
- "Warning: no sidescan/GNSS devices found on survey platform — no mappings created",
164
- err=True,
165
- )
166
- except httpx.HTTPStatusError as e:
167
- if e.response.status_code == 404:
168
- typer.echo(
169
- "Warning: no platform found on survey — skipping device mappings. Run 'ftm init' first.",
170
- err=True,
171
- )
172
- else:
173
- typer.echo(
174
- f"Warning: failed to create device mappings: {_get_error_detail(e)}",
175
- err=True,
176
- )
124
+ typer.echo(
125
+ "Note: per-stream device mappings are no longer created at upload time. "
126
+ "Run `ftm automap <survey>` after upload (waits for prescan, then mirrors "
127
+ "the UI's Auto-platform + Auto-mapping buttons).",
128
+ err=True,
129
+ )
177
130
 
178
131
  _output(
179
132
  {
@@ -190,6 +143,281 @@ def upload(
190
143
  )
191
144
 
192
145
 
146
+ # =============================================================================
147
+ # Stream type → seeded DeviceType.enum_name mapping.
148
+ # Mirrors clarity-client/src/lib/auto-platform/stream-device-mapping.ts —
149
+ # keep in sync with that table. Multiple stream types may collapse to
150
+ # the same device type (geographic + projected nav both → GNSS).
151
+ # =============================================================================
152
+ _STREAM_TO_DEVICE_ENUM: dict[str, str] = {
153
+ "sidescan": "sidescan_sonar",
154
+ "geographic_navigation": "gnss_gps_position_sensor",
155
+ "projected_navigation": "gnss_gps_position_sensor",
156
+ "attitude": "imu",
157
+ "altitude": "single_beam_echosounder_sbes",
158
+ "depth": "depth_sensor",
159
+ "cable_counter": "sheave_block",
160
+ }
161
+
162
+
163
+ @app.command()
164
+ def automap(
165
+ survey: UUID = typer.Argument(..., help="Survey ID to map streams for"),
166
+ timeout: float = typer.Option(
167
+ 180.0,
168
+ "--timeout",
169
+ help="Seconds to wait for prescan to finish across all raw files.",
170
+ ),
171
+ output_json: bool = typer.Option(False, "--json", "-j"),
172
+ ) -> None:
173
+ """Auto-platform + auto-map streams for every raw file in a survey.
174
+
175
+ Mirrors the clarity-client UI flow operators run after upload:
176
+
177
+ 1. Wait for the prescan phase to complete on every raw file
178
+ (the engine writes the parsed stream manifest to
179
+ ``ProcessingLog.result`` for each file as soon as scan finishes).
180
+ 2. Discover the per-file stream manifests, derive the auto-platform
181
+ spec from them, and create the platform + one device per stream
182
+ type — equivalent to clicking "Generate auto-platform" on the
183
+ Platform page.
184
+ 3. For every (raw_file, stream) in the manifests, find the matching
185
+ device on the auto-platform (by ``DeviceType.enum_name``) and
186
+ POST a ``RawFileDeviceMapping`` carrying the scan manifest's
187
+ exact ``source`` + ``fields_by_source`` shapes — equivalent to
188
+ clicking "Auto-mapping" on the stream-mapping page.
189
+
190
+ Idempotent: existing platforms / devices / mappings are reused; the
191
+ server's unique-constraint dedup matches the planner's "first wins"
192
+ rule on ``(raw_file, device)``.
193
+ """
194
+ import time
195
+ from cti.model.device import DeviceCreate
196
+ from cti.model.platform import PlatformCreate
197
+ from cti.model.raw_file_device_mapping import (
198
+ RawFileDeviceMappingCreate,
199
+ SourceSelection,
200
+ StreamIdentifier,
201
+ )
202
+
203
+ api = get_api()
204
+
205
+ survey_obj = api.get_survey(survey)
206
+ raw_files = api.get_raw_files(survey)
207
+ if not raw_files:
208
+ typer.echo(f"Error: survey {survey} has no raw files; upload first", err=True)
209
+ raise typer.Exit(1)
210
+
211
+ typer.echo(
212
+ f"Survey {survey_obj.name}: {len(raw_files)} raw file(s); "
213
+ f"waiting up to {timeout:.0f}s for prescan to finish..."
214
+ )
215
+
216
+ # ── 1. Wait for prescan ─────────────────────────────────────────────
217
+ deadline = time.monotonic() + timeout
218
+ manifests: dict[UUID, dict] = {}
219
+ pending = {rf.raw_file_id for rf in raw_files}
220
+ while pending and time.monotonic() < deadline:
221
+ for raw_file_id in list(pending):
222
+ logs = api.list_processing_logs(
223
+ raw_file_id=raw_file_id, processing_step="scan"
224
+ )
225
+ done = next(
226
+ (
227
+ l for l in logs
228
+ if l.status == "complete" and l.result
229
+ ),
230
+ None,
231
+ )
232
+ if done:
233
+ try:
234
+ manifests[raw_file_id] = json.loads(done.result)
235
+ except (ValueError, TypeError) as exc:
236
+ typer.echo(
237
+ f"Error: scan manifest for {raw_file_id} is not valid JSON: {exc}",
238
+ err=True,
239
+ )
240
+ raise typer.Exit(1)
241
+ pending.discard(raw_file_id)
242
+ if pending:
243
+ time.sleep(2.0)
244
+
245
+ if pending:
246
+ typer.echo(
247
+ f"Error: prescan still pending for {len(pending)} file(s) after {timeout:.0f}s",
248
+ err=True,
249
+ )
250
+ raise typer.Exit(1)
251
+
252
+ typer.echo(f"Prescan complete: {len(manifests)} manifest(s) loaded")
253
+
254
+ # ── 2. Generate auto-platform (clicks "Generate auto-platform") ─────
255
+ file_format = next(
256
+ (m.get("file_format", "").upper() for m in manifests.values() if m.get("file_format")),
257
+ "MIXED",
258
+ )
259
+ auto_name = f"Auto-{file_format}"
260
+
261
+ existing_platforms = [
262
+ p for p in api.list_platforms() if p.survey_id == survey
263
+ ]
264
+ auto_platform = next((p for p in existing_platforms if p.name == auto_name), None)
265
+
266
+ device_types = api.list_device_types()
267
+ dt_by_enum = {dt.enum_name: dt for dt in device_types}
268
+
269
+ if auto_platform is None:
270
+ platform_types = api.list_platform_types()
271
+ if not platform_types:
272
+ typer.echo("Error: no PlatformType seeded; cannot create auto-platform", err=True)
273
+ raise typer.Exit(1)
274
+ auto_platform = api.create_platform(
275
+ PlatformCreate(
276
+ survey_id=survey,
277
+ platform_type_id=platform_types[0].platform_type_id,
278
+ name=auto_name,
279
+ description=f"Auto-generated for {file_format} files",
280
+ )
281
+ )
282
+ typer.echo(f"Created auto-platform: {auto_name} ({auto_platform.platform_id})")
283
+ else:
284
+ typer.echo(f"Reusing auto-platform: {auto_name} ({auto_platform.platform_id})")
285
+
286
+ # Aggregate which DeviceType enums we need across all manifests.
287
+ needed_enums: set[str] = set()
288
+ for manifest in manifests.values():
289
+ for stream in manifest.get("streams", []) or []:
290
+ stype = stream.get("stream_type")
291
+ target_enum = _STREAM_TO_DEVICE_ENUM.get(stype) if stype else None
292
+ if target_enum and target_enum in dt_by_enum:
293
+ needed_enums.add(target_enum)
294
+
295
+ # Existing devices on the auto-platform — first-wins per stream type.
296
+ existing_devices = api.get_platform_devices(platform_id=auto_platform.platform_id)
297
+ device_by_enum: dict[str, "Device"] = {} # type: ignore[name-defined]
298
+ for d in existing_devices:
299
+ dt = next((t for t in device_types if t.device_type_id == d.device_type_id), None)
300
+ if dt and dt.enum_name not in device_by_enum:
301
+ device_by_enum[dt.enum_name] = d
302
+
303
+ # Create devices for any missing enum.
304
+ created_devices = 0
305
+ for enum_name in sorted(needed_enums):
306
+ if enum_name in device_by_enum:
307
+ continue
308
+ dt = dt_by_enum[enum_name]
309
+ # Defaults match the JS auto-platform planner
310
+ # (clarity-client/src/lib/auto-platform/plan.ts): all offsets
311
+ # zero, channel 1, is_towed=false. Refinement is deferred —
312
+ # operators can edit these in the UI after auto-platform creation.
313
+ new_device = api.create_device(
314
+ DeviceCreate(
315
+ platform_id=auto_platform.platform_id,
316
+ device_type_id=dt.device_type_id,
317
+ name=dt.name,
318
+ description=f"Auto-generated for {file_format} {enum_name} stream",
319
+ channel=1,
320
+ offset_x=0.0,
321
+ offset_y=0.0,
322
+ offset_z=0.0,
323
+ offset_heading=0.0,
324
+ offset_pitch=0.0,
325
+ offset_roll=0.0,
326
+ latency=0.0,
327
+ is_towed=False,
328
+ )
329
+ )
330
+ device_by_enum[enum_name] = new_device
331
+ created_devices += 1
332
+ typer.echo(f" + device: {dt.name} ({enum_name})")
333
+
334
+ typer.echo(
335
+ f"Auto-platform devices: {len(device_by_enum)} total ({created_devices} new)"
336
+ )
337
+
338
+ # ── 3. Auto-map streams (clicks "Auto-mapping") ─────────────────────
339
+ tz = survey_obj.timezone_name
340
+ mappings_created = 0
341
+ mappings_skipped = 0
342
+ for raw_file_id, manifest in manifests.items():
343
+ # Existing mappings for this file — dedup by device_id (matches
344
+ # server's unique constraint on (raw_file, device)).
345
+ existing = api.get_raw_file_device_mappings(raw_file_id=raw_file_id)
346
+ existing_device_ids = {m.device_id for m in existing}
347
+ used_device_ids: set = set()
348
+
349
+ for stream in manifest.get("streams", []) or []:
350
+ stype = stream.get("stream_type")
351
+ target_enum = _STREAM_TO_DEVICE_ENUM.get(stype) if stype else None
352
+ if not target_enum:
353
+ continue
354
+ device = device_by_enum.get(target_enum)
355
+ if device is None:
356
+ continue
357
+ if device.device_id in existing_device_ids or device.device_id in used_device_ids:
358
+ mappings_skipped += 1
359
+ continue
360
+
361
+ # Build StreamIdentifier from manifest's source + fields_by_source.
362
+ src = stream.get("source") or {}
363
+ uri = src.get("selected") if isinstance(src, dict) else None
364
+ if not uri:
365
+ continue
366
+ available = src.get("available", [uri]) if isinstance(src, dict) else [uri]
367
+ fields_by_source_raw = stream.get("fields_by_source") or {}
368
+ fields_by_source: dict = {}
369
+ for fs_uri, roles in fields_by_source_raw.items():
370
+ role_dict: dict = {}
371
+ for role_name, role_val in (roles or {}).items():
372
+ if isinstance(role_val, dict):
373
+ ravail = role_val.get("available", [role_name])
374
+ rsel = role_val.get("selected", role_name)
375
+ else:
376
+ ravail = [str(role_val)]
377
+ rsel = str(role_val)
378
+ role_dict[role_name] = SourceSelection(
379
+ available=list(ravail), selected=rsel
380
+ )
381
+ fields_by_source[fs_uri] = role_dict
382
+
383
+ stream_id = StreamIdentifier(
384
+ source=SourceSelection(available=list(available), selected=uri),
385
+ fields_by_source=fields_by_source,
386
+ )
387
+
388
+ api.create_raw_file_device_mapping(
389
+ RawFileDeviceMappingCreate(
390
+ raw_file_id=raw_file_id,
391
+ device_id=device.device_id,
392
+ source_stream_identifier=stream_id,
393
+ input_srid=4326,
394
+ input_timezone=tz,
395
+ )
396
+ )
397
+ used_device_ids.add(device.device_id)
398
+ mappings_created += 1
399
+ typer.echo(
400
+ f" + mapping: {raw_file_id} {stype} → {device.name}"
401
+ )
402
+
403
+ typer.echo(
404
+ f"Done. {mappings_created} mapping(s) created, {mappings_skipped} skipped (already existed)."
405
+ )
406
+
407
+ _output(
408
+ {
409
+ "platform_id": str(auto_platform.platform_id),
410
+ "platform_name": auto_name,
411
+ "devices_total": len(device_by_enum),
412
+ "devices_created": created_devices,
413
+ "mappings_created": mappings_created,
414
+ "mappings_skipped": mappings_skipped,
415
+ "files_processed": len(manifests),
416
+ },
417
+ output_json,
418
+ )
419
+
420
+
193
421
  @app.command()
194
422
  def process(
195
423
  survey: UUID = typer.Argument(..., help="Survey ID to process"),
@@ -0,0 +1,152 @@
1
+ """Typed event classes for structured logging.
2
+
3
+ Each class is the canonical definition of a log event the dashboard or
4
+ QA harness programs against. Construct an instance and ``emit()`` it
5
+ through any ``cti.logger``-compatible logger::
6
+
7
+ from cti.events import TaskCompleted, emit
8
+
9
+ emit(
10
+ TaskCompleted(
11
+ job_id=jid,
12
+ phase="ingest",
13
+ task_type="ingest",
14
+ worker_pid=os.getpid(),
15
+ duration_sec=elapsed,
16
+ bytes_processed=output.bytes,
17
+ pings_processed=output.pings,
18
+ ),
19
+ logger,
20
+ )
21
+
22
+ The dataclass IS the contract — pyright catches missing or wrong-typed
23
+ fields at the call site, and ``from cti.events import <Tab>`` discovers
24
+ every defined event. The string carried in ``event_name`` (a
25
+ ``ClassVar``, so excluded from ``asdict()``) is what shows up in the log
26
+ stream — kept snake-case so existing dashboard log-search prefixes
27
+ (``task.``, ``module.``) still match.
28
+
29
+ The set is small on purpose: only events something programs against
30
+ structurally. Free-form ``logger.info("xs_pipeline_v2_uploaded",
31
+ extra={...})`` remains the right shape for diagnostic chatter that
32
+ nothing consumes by name.
33
+ """
34
+
35
+ from dataclasses import asdict, dataclass
36
+ from typing import ClassVar
37
+
38
+
39
+ def emit(event, logger) -> None:
40
+ """Emit a typed event through the given structlog-style logger.
41
+
42
+ Looks up the per-class ``level`` (info / error / warning / debug)
43
+ and calls the matching method, so failure events stay at ERROR and
44
+ pass through level filters the dashboard depends on.
45
+ """
46
+ getattr(logger, event.level)(event.event_name, extra=asdict(event))
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class TaskStarted:
51
+ """A worker task has begun executing inside a phase's pool."""
52
+
53
+ event_name: ClassVar[str] = "task.started"
54
+ level: ClassVar[str] = "info"
55
+
56
+ job_id: str
57
+ phase: str
58
+ task_type: str
59
+ worker_pid: int
60
+ raw_file_id: str | None = None
61
+ file_name: str | None = None
62
+ file_size_bytes: int | None = None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TaskCompleted:
67
+ """A worker task finished successfully."""
68
+
69
+ event_name: ClassVar[str] = "task.completed"
70
+ level: ClassVar[str] = "info"
71
+
72
+ job_id: str
73
+ phase: str
74
+ task_type: str
75
+ worker_pid: int
76
+ duration_sec: float
77
+ raw_file_id: str | None = None
78
+ file_name: str | None = None
79
+ file_size_bytes: int | None = None
80
+ bytes_processed: int | None = None
81
+ pings_processed: int | None = None
82
+ batch_size: int | None = None
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class TaskFailed:
87
+ """A worker task raised an exception. Re-raised after emit."""
88
+
89
+ event_name: ClassVar[str] = "task.failed"
90
+ level: ClassVar[str] = "error"
91
+
92
+ job_id: str
93
+ phase: str
94
+ task_type: str
95
+ worker_pid: int
96
+ duration_sec: float
97
+ error: str
98
+ traceback: str
99
+ raw_file_id: str | None = None
100
+ file_name: str | None = None
101
+ file_size_bytes: int | None = None
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class ModuleStarted:
106
+ """A pipeline module began executing on the orchestrator thread."""
107
+
108
+ event_name: ClassVar[str] = "module.started"
109
+ level: ClassVar[str] = "info"
110
+
111
+ module: str
112
+ phase: str
113
+ job_id: str | None = None
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class ModuleCompleted:
118
+ """A pipeline module completed successfully."""
119
+
120
+ event_name: ClassVar[str] = "module.completed"
121
+ level: ClassVar[str] = "info"
122
+
123
+ module: str
124
+ phase: str
125
+ duration_sec: float
126
+ job_id: str | None = None
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class ModuleFailed:
131
+ """A pipeline module raised an exception. Re-raised after emit."""
132
+
133
+ event_name: ClassVar[str] = "module.failed"
134
+ level: ClassVar[str] = "error"
135
+
136
+ module: str
137
+ phase: str
138
+ duration_sec: float
139
+ error: str
140
+ traceback: str
141
+ job_id: str | None = None
142
+
143
+
144
+ __all__ = [
145
+ "ModuleCompleted",
146
+ "ModuleFailed",
147
+ "ModuleStarted",
148
+ "TaskCompleted",
149
+ "TaskFailed",
150
+ "TaskStarted",
151
+ "emit",
152
+ ]