sopran 0.0.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.
Files changed (42) hide show
  1. sopran/__init__.py +86 -0
  2. sopran/bodies/__init__.py +3 -0
  3. sopran/bodies/moon.py +618 -0
  4. sopran/core/__init__.py +88 -0
  5. sopran/core/alignment.py +922 -0
  6. sopran/core/data.py +339 -0
  7. sopran/core/database.py +189 -0
  8. sopran/core/errors.py +37 -0
  9. sopran/core/loaders.py +32 -0
  10. sopran/core/pages.py +165 -0
  11. sopran/core/pipeline.py +405 -0
  12. sopran/core/plotting.py +499 -0
  13. sopran/core/project.py +462 -0
  14. sopran/core/schema.py +300 -0
  15. sopran/core/store.py +880 -0
  16. sopran/core/time.py +95 -0
  17. sopran/frames/__init__.py +179 -0
  18. sopran/maps/__init__.py +110 -0
  19. sopran/missions/__init__.py +1 -0
  20. sopran/missions/artemis/README.ja.md +39 -0
  21. sopran/missions/artemis/README.md +41 -0
  22. sopran/missions/artemis/__init__.py +3 -0
  23. sopran/missions/artemis/mission.py +529 -0
  24. sopran/missions/kaguya/ESA1.ja.md +56 -0
  25. sopran/missions/kaguya/ESA1.md +68 -0
  26. sopran/missions/kaguya/README.ja.md +32 -0
  27. sopran/missions/kaguya/README.md +58 -0
  28. sopran/missions/kaguya/__init__.py +37 -0
  29. sopran/missions/kaguya/data.py +356 -0
  30. sopran/missions/kaguya/files.py +98 -0
  31. sopran/missions/kaguya/lmag.py +209 -0
  32. sopran/missions/kaguya/mission.py +1545 -0
  33. sopran/missions/kaguya/pace.py +529 -0
  34. sopran/missions/kaguya/schema.py +52 -0
  35. sopran/missions/kaguya/sensors.py +45 -0
  36. sopran/schema_docs.py +119 -0
  37. sopran-0.0.0.dist-info/METADATA +582 -0
  38. sopran-0.0.0.dist-info/RECORD +42 -0
  39. sopran-0.0.0.dist-info/WHEEL +4 -0
  40. sopran-0.0.0.dist-info/entry_points.txt +2 -0
  41. sopran-0.0.0.dist-info/licenses/LICENSE +201 -0
  42. sopran-0.0.0.dist-info/licenses/NOTICE +9 -0
sopran/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """Satellite Observation Package for Retrieval, Analysis, and Navigation."""
2
+
3
+ from sopran.bodies import Moon
4
+ from sopran.core import AlignmentResult
5
+ from sopran.core import BackendError, ConfigError, DecodeError, DownloadError
6
+ from sopran.core import Database, DatasetNotFoundError, GuidePage, InfoPage
7
+ from sopran.core import FeatureMatrix
8
+ from sopran.core import FrameTransformError, PipelineError, SchemaError
9
+ from sopran.core import InstrumentSchema, VariableSchema
10
+ from sopran.core import PlotArtifact, PlotItem, PlotPlan, PlotResult, ProductRef
11
+ from sopran.core import PlotStack, SampleSpec, SampleTable, SopranError, Store, TimeRange
12
+ from sopran.core import QuicklookResult
13
+ from sopran.core import TimeBins, align, day, histogram, line, lines, load, month, period
14
+ from sopran.core import spectrogram, stack, year
15
+ from sopran.core import time_bins
16
+ from sopran.core import validate_schema
17
+ from sopran.core.project import Project
18
+ from sopran.frames import FrameContext, FrameTransformPlan, normalize_frame
19
+ from sopran.maps import Region
20
+ from sopran.missions.artemis import Artemis
21
+ from sopran.missions.kaguya import Kaguya
22
+
23
+ __version__ = "0.0.0"
24
+
25
+
26
+ def __getattr__(name: str):
27
+ if name in {"builtin_schemas", "schema_reference_markdown"}:
28
+ from importlib import import_module
29
+
30
+ return getattr(import_module("sopran.schema_docs"), name)
31
+ raise AttributeError(f"module 'sopran' has no attribute {name!r}")
32
+
33
+ __all__ = [
34
+ "GuidePage",
35
+ "InfoPage",
36
+ "AlignmentResult",
37
+ "Artemis",
38
+ "BackendError",
39
+ "ConfigError",
40
+ "Database",
41
+ "DatasetNotFoundError",
42
+ "DecodeError",
43
+ "DownloadError",
44
+ "FrameTransformError",
45
+ "FrameContext",
46
+ "FrameTransformPlan",
47
+ "FeatureMatrix",
48
+ "InstrumentSchema",
49
+ "Kaguya",
50
+ "Moon",
51
+ "PlotArtifact",
52
+ "PlotItem",
53
+ "PlotPlan",
54
+ "PlotResult",
55
+ "PlotStack",
56
+ "Project",
57
+ "PipelineError",
58
+ "ProductRef",
59
+ "QuicklookResult",
60
+ "Region",
61
+ "SampleSpec",
62
+ "SampleTable",
63
+ "SchemaError",
64
+ "SopranError",
65
+ "Store",
66
+ "TimeRange",
67
+ "TimeBins",
68
+ "VariableSchema",
69
+ "__version__",
70
+ "align",
71
+ "builtin_schemas",
72
+ "day",
73
+ "histogram",
74
+ "line",
75
+ "lines",
76
+ "load",
77
+ "month",
78
+ "normalize_frame",
79
+ "period",
80
+ "schema_reference_markdown",
81
+ "spectrogram",
82
+ "stack",
83
+ "time_bins",
84
+ "validate_schema",
85
+ "year",
86
+ ]
@@ -0,0 +1,3 @@
1
+ from sopran.bodies.moon import Moon, SurfaceEndpoint, SurfacePlan
2
+
3
+ __all__ = ["Moon", "SurfaceEndpoint", "SurfacePlan"]
sopran/bodies/moon.py ADDED
@@ -0,0 +1,618 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from sopran.core.pages import GuidePage, InfoPage
7
+ from sopran.core.schema import InstrumentSchema, VariableSchema
8
+
9
+ _GUIDE_LANGUAGES = ("ja", "en")
10
+ _PUBLIC_DOC_URL = "https://nkzono99.github.io/sopran/maps/moon/"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SurfacePlan:
15
+ body: str
16
+ product: str
17
+ parameters: dict[str, Any]
18
+
19
+ def to_metadata(self) -> dict[str, Any]:
20
+ return {
21
+ "body": self.body,
22
+ "product": self.product,
23
+ "parameters": _metadata_value(self.parameters),
24
+ }
25
+
26
+
27
+ class Moon:
28
+ """Body-first entry point for Moon map products."""
29
+
30
+ name = "moon"
31
+
32
+ def __init__(self) -> None:
33
+ self.dem = SurfaceEndpoint(self, "dem", "DEM")
34
+ self.svm = SurfaceEndpoint(self, "svm", "SVM")
35
+ self.shadow = SurfaceEndpoint(self, "shadow", "Shadow map")
36
+ self.illumination = SurfaceEndpoint(self, "illumination", "Illumination map")
37
+ self.sza = SurfaceEndpoint(self, "sza", "Solar zenith angle")
38
+
39
+ def info(self) -> InfoPage:
40
+ return InfoPage(
41
+ title="Moon",
42
+ lines=(
43
+ "dem: digital elevation model endpoint",
44
+ "svm: surface vector map endpoint",
45
+ "shadow: terrain-aware shadow map endpoint skeleton",
46
+ "illumination: terrain-aware illumination endpoint skeleton",
47
+ "sza: solar zenith angle planning endpoint skeleton",
48
+ "schema: "
49
+ + _format_list(variable.name for variable in self.schema().variables),
50
+ ),
51
+ )
52
+
53
+ def guide(self, *, language: str = "ja") -> GuidePage:
54
+ return _guide_page(
55
+ title="Moon Maps",
56
+ source="sopran.bodies.moon",
57
+ markdowns=_MOON_GUIDES,
58
+ language=language,
59
+ url=_PUBLIC_DOC_URL,
60
+ ).with_schema(MOON_SURFACE_SCHEMA)
61
+
62
+ def help(self, *, language: str = "ja") -> GuidePage:
63
+ return self.guide(language=language)
64
+
65
+ def schema(self) -> InstrumentSchema:
66
+ return MOON_SURFACE_SCHEMA
67
+
68
+ def example(self) -> GuidePage:
69
+ return _example_page(
70
+ "Moon Maps Example",
71
+ """# Moon Maps Example
72
+
73
+ ```python
74
+ import sopran as spn
75
+
76
+ moon = spn.Moon()
77
+ region = spn.Region(lon=(120, 160), lat=(-45, -10), body="moon")
78
+
79
+ dem_plan = moon.dem.plan(
80
+ source="kaguya.tc.dem",
81
+ region=region,
82
+ resolution="512ppd",
83
+ projection="native",
84
+ )
85
+ shadow_plan = moon.shadow.plan(time="2008-02-01T12:00:00Z", dem=dem_plan)
86
+ sza_plan = moon.sza.plan(
87
+ time="2008-02-01T12:00:00Z",
88
+ region=region,
89
+ geometry_source="spice",
90
+ )
91
+ metadata = shadow_plan.to_metadata()
92
+ ```
93
+ """,
94
+ )
95
+
96
+ def map(self, product: str) -> SurfaceEndpoint:
97
+ endpoints = {
98
+ "dem": self.dem,
99
+ "svm": self.svm,
100
+ "shadow": self.shadow,
101
+ "illumination": self.illumination,
102
+ "sza": self.sza,
103
+ }
104
+ try:
105
+ canonical = MOON_SURFACE_SCHEMA.variable(product).name
106
+ return endpoints[canonical]
107
+ except KeyError as exc:
108
+ raise ValueError(
109
+ "Unknown Moon surface product. Available products: "
110
+ + _format_list(variable.name for variable in MOON_SURFACE_SCHEMA.variables)
111
+ + ". Aliases: "
112
+ + _format_list(
113
+ alias
114
+ for variable in MOON_SURFACE_SCHEMA.variables
115
+ for alias in variable.aliases
116
+ )
117
+ ) from exc
118
+
119
+
120
+ class SurfaceEndpoint:
121
+ def __init__(self, body: Moon, product: str, label: str) -> None:
122
+ self.body = body
123
+ self.product = product
124
+ self.label = label
125
+
126
+ def info(self) -> InfoPage:
127
+ schema = self.schema()
128
+ return InfoPage(
129
+ title=f"Moon.{self.product}",
130
+ lines=(
131
+ f"{self.label} surface product.",
132
+ "v0.1 implements planning only; load/compute backends are later milestones.",
133
+ "sources: " + _format_list(self.sources()),
134
+ "dims: " + _format_list(schema.dims),
135
+ f"units: {schema.units or 'none'}",
136
+ f"frame: {schema.frame or 'none'}",
137
+ "aliases: " + _format_list(schema.aliases),
138
+ ),
139
+ )
140
+
141
+ def guide(self, *, language: str = "ja") -> GuidePage:
142
+ return _guide_page(
143
+ title=f"Moon {self.label}",
144
+ source=f"sopran.bodies.moon.{self.product}",
145
+ markdowns=_SURFACE_GUIDES.get(self.product, _MOON_GUIDES),
146
+ language=language,
147
+ url=_PUBLIC_DOC_URL,
148
+ ).with_schema(_surface_product_schema(self.product))
149
+
150
+ def help(self, *, language: str = "ja") -> GuidePage:
151
+ return self.guide(language=language)
152
+
153
+ def schema(self) -> VariableSchema:
154
+ return MOON_SURFACE_SCHEMA.variable(self.product)
155
+
156
+ def example(self) -> GuidePage:
157
+ examples = {
158
+ "dem": (
159
+ "Moon DEM Example",
160
+ """# Moon DEM Example
161
+
162
+ ```python
163
+ import sopran as spn
164
+
165
+ moon = spn.Moon()
166
+ region = spn.Region(lon=(120, 160), lat=(-45, -10), body="moon")
167
+
168
+ dem_plan = moon.dem.plan(
169
+ source="kaguya.tc.dem",
170
+ region=region,
171
+ resolution="512ppd",
172
+ projection="native",
173
+ )
174
+ metadata = dem_plan.to_metadata()
175
+ ```
176
+ """,
177
+ ),
178
+ "shadow": (
179
+ "Moon Shadow Example",
180
+ """# Moon Shadow Example
181
+
182
+ ```python
183
+ import sopran as spn
184
+
185
+ moon = spn.Moon()
186
+ region = spn.Region(lon=(120, 160), lat=(-45, -10), body="moon")
187
+ dem_plan = moon.dem.plan(source="kaguya.tc.dem", region=region)
188
+
189
+ shadow_plan = moon.shadow.plan(
190
+ time="2008-02-01T12:00:00Z",
191
+ dem=dem_plan,
192
+ model="terrain_ray",
193
+ )
194
+ metadata = shadow_plan.to_metadata()
195
+ ```
196
+ """,
197
+ ),
198
+ "illumination": (
199
+ "Moon Illumination Example",
200
+ """# Moon Illumination Example
201
+
202
+ ```python
203
+ import sopran as spn
204
+
205
+ moon = spn.Moon()
206
+ dem_plan = moon.dem.plan(source="kaguya.tc.dem")
207
+
208
+ illumination_plan = moon.illumination.plan(
209
+ time="2008-02-01T12:00:00Z",
210
+ dem=dem_plan,
211
+ geometry_source="spice",
212
+ )
213
+ metadata = illumination_plan.to_metadata()
214
+ ```
215
+ """,
216
+ ),
217
+ "sza": (
218
+ "Moon Solar Zenith Angle Example",
219
+ """# Moon Solar Zenith Angle Example
220
+
221
+ ```python
222
+ import sopran as spn
223
+
224
+ moon = spn.Moon()
225
+ region = spn.Region(lon=(120, 160), lat=(-45, -10), body="moon")
226
+
227
+ sza_plan = moon.sza.plan(
228
+ time="2008-02-01T12:00:00Z",
229
+ region=region,
230
+ geometry_source="spice",
231
+ )
232
+ metadata = sza_plan.to_metadata()
233
+ ```
234
+ """,
235
+ ),
236
+ "svm": (
237
+ "Moon SVM Example",
238
+ """# Moon SVM Example
239
+
240
+ ```python
241
+ import sopran as spn
242
+
243
+ moon = spn.Moon()
244
+ region = spn.Region(lon=(120, 160), lat=(-45, -10), body="moon")
245
+
246
+ svm_plan = moon.svm.plan(source="kaguya.lism.svm", region=region)
247
+ metadata = svm_plan.to_metadata()
248
+ ```
249
+ """,
250
+ ),
251
+ }
252
+ title, markdown = examples.get(self.product, examples["dem"])
253
+ return _example_page(title, markdown)
254
+
255
+ def sources(self) -> tuple[str, ...]:
256
+ return _SURFACE_SOURCES.get(self.product, ())
257
+
258
+ def plan(self, **parameters: Any) -> SurfacePlan:
259
+ return SurfacePlan(
260
+ body=self.body.name,
261
+ product=self.product,
262
+ parameters=_surface_parameters(self.product, parameters),
263
+ )
264
+
265
+ def load(self, **parameters: Any) -> None:
266
+ plan = self.plan(**parameters)
267
+ raise NotImplementedError(f"Moon.{plan.product}.load() is not implemented yet")
268
+
269
+ def compute(self, **parameters: Any) -> None:
270
+ plan = self.plan(**parameters)
271
+ raise NotImplementedError(f"Moon.{plan.product}.compute() is not implemented yet")
272
+
273
+
274
+ _MOON_GUIDES = {
275
+ "en": """# Moon Maps
276
+
277
+ SOPRAN uses a body-first API for Moon map products. Mission modules provide
278
+ provider-specific discovery, while `spn.Moon()` owns body-fixed DEM, SVM, SZA,
279
+ shadow, illumination, projection, and region semantics.
280
+
281
+ The v0.1 implementation is a planning skeleton. Terrain-aware shadow and
282
+ illumination backends will require DEM data, solar geometry, body shape, and
283
+ explicit longitude/projection metadata.
284
+ """,
285
+ "ja": """# Moon Maps
286
+
287
+ SOPRAN は月面マップを body-first API として扱います。mission module は
288
+ provider-specific discovery を担当し、`spn.Moon()` は月固定 DEM、SVM、SZA、shadow、
289
+ illumination、projection、region semantics を受け持ちます。
290
+
291
+ v0.1 実装は planning skeleton です。terrain-aware shadow と illumination backend では
292
+ DEM data、solar geometry、body shape、longitude/projection metadata を明示的に扱います。
293
+ """,
294
+ }
295
+
296
+ _SURFACE_GUIDES = {
297
+ "dem": {
298
+ "en": """# Moon DEM
299
+
300
+ DEM products represent body-fixed lunar elevation rasters. Planned metadata
301
+ includes source, resolution, datum or shape model, longitude domain, projection,
302
+ and area-or-point interpretation.
303
+ """,
304
+ "ja": """# Moon DEM
305
+
306
+ DEM product は月固定の elevation raster を表します。予定している metadata には source、
307
+ resolution、datum または shape model、longitude domain、projection、area-or-point
308
+ interpretation を含めます。
309
+ """,
310
+ },
311
+ "svm": {
312
+ "en": """# Moon SVM
313
+
314
+ SVM products represent lunar surface vector maps or classified map layers.
315
+ They share the same body-fixed region and projection metadata as DEM products.
316
+ """,
317
+ "ja": """# Moon SVM
318
+
319
+ SVM product は lunar surface vector map または classified map layer を表します。
320
+ DEM product と同じ body-fixed region と projection metadata を共有します。
321
+ """,
322
+ },
323
+ "shadow": {
324
+ "en": """# Moon Shadow Map
325
+
326
+ Shadow products must be computed from DEM terrain, solar position, body shape,
327
+ and projection metadata. The current endpoint only records plans.
328
+ """,
329
+ "ja": """# Moon Shadow Map
330
+
331
+ Shadow product は DEM terrain、solar position、body shape、projection metadata から
332
+ 計算する必要があります。現在の endpoint は plan の記録だけを行います。
333
+ """,
334
+ },
335
+ "illumination": {
336
+ "en": """# Moon Illumination Map
337
+
338
+ Illumination products will represent solar incidence and visibility derived
339
+ from DEM terrain and SPICE-backed solar geometry.
340
+ """,
341
+ "ja": """# Moon Illumination Map
342
+
343
+ Illumination product は DEM terrain と SPICE-backed solar geometry から導く
344
+ solar incidence と visibility を表す予定です。
345
+ """,
346
+ },
347
+ "sza": {
348
+ "en": """# Moon Solar Zenith Angle
349
+
350
+ SZA products represent solar zenith angle on the lunar surface. The planning
351
+ endpoint records time, region, geometry_source backend, and projection metadata
352
+ before SPICE-backed computation is implemented.
353
+ """,
354
+ "ja": """# Moon Solar Zenith Angle
355
+
356
+ SZA product は月面上の solar zenith angle を表します。planning endpoint は
357
+ SPICE-backed computation の実装前に、time、region、geometry_source backend、
358
+ projection metadata を記録します。
359
+ """,
360
+ },
361
+ }
362
+
363
+ _SURFACE_SOURCES = {
364
+ "dem": ("kaguya.tc.dem", "lro.lola.dem"),
365
+ "svm": ("kaguya.lism.svm",),
366
+ "shadow": ("legacy.shadowmap_sza",),
367
+ "illumination": (),
368
+ "sza": ("computed.spice.sza",),
369
+ }
370
+
371
+ MOON_SURFACE_SCHEMA = InstrumentSchema(
372
+ mission="moon",
373
+ instrument="surface",
374
+ variables=(
375
+ VariableSchema(
376
+ name="dem",
377
+ dims=("lat", "lon"),
378
+ units="m",
379
+ dtype="float64",
380
+ frame="Moon body-fixed",
381
+ description="Digital elevation model on a body-fixed lunar grid.",
382
+ aliases=("elevation", "height"),
383
+ ),
384
+ VariableSchema(
385
+ name="svm",
386
+ dims=("lat", "lon"),
387
+ dtype="string",
388
+ frame="Moon body-fixed",
389
+ description="Surface vector map or classified lunar map layer.",
390
+ aliases=("surface_vector_map",),
391
+ ),
392
+ VariableSchema(
393
+ name="shadow",
394
+ dims=("lat", "lon"),
395
+ units="fraction",
396
+ dtype="float64",
397
+ frame="Moon body-fixed",
398
+ description="terrain-aware shadow or shadow-fraction map.",
399
+ aliases=("shadow_map", "shadow_fraction"),
400
+ ),
401
+ VariableSchema(
402
+ name="illumination",
403
+ dims=("lat", "lon"),
404
+ units="fraction",
405
+ dtype="float64",
406
+ frame="Moon body-fixed",
407
+ description="Illumination or visibility fraction derived from solar geometry.",
408
+ aliases=("illumination_map", "visibility"),
409
+ ),
410
+ VariableSchema(
411
+ name="sza",
412
+ dims=("lat", "lon"),
413
+ units="deg",
414
+ dtype="float64",
415
+ frame="Moon body-fixed",
416
+ description="Solar zenith angle on the lunar surface.",
417
+ aliases=("solar_zenith_angle",),
418
+ ),
419
+ ),
420
+ )
421
+
422
+
423
+ def _metadata_value(value: Any) -> Any:
424
+ if hasattr(value, "to_metadata"):
425
+ return value.to_metadata()
426
+ if isinstance(value, dict):
427
+ return {str(key): _metadata_value(item) for key, item in value.items()}
428
+ if isinstance(value, (tuple, list)):
429
+ return [_metadata_value(item) for item in value]
430
+ return value
431
+
432
+
433
+ def _format_list(values) -> str:
434
+ items = tuple(str(value) for value in values)
435
+ return ", ".join(items) if items else "none"
436
+
437
+
438
+ def _surface_parameters(product: str, parameters: dict[str, Any]) -> dict[str, Any]:
439
+ normalized = dict(parameters)
440
+ if product in {"shadow", "illumination"} and (
441
+ "method" in normalized or "model" in normalized
442
+ ):
443
+ model = _surface_model(normalized)
444
+ normalized["method"] = model
445
+ normalized["model"] = model
446
+ if (
447
+ product == "sza"
448
+ or "geometry_source" in normalized
449
+ or "geometry" in normalized
450
+ or "ephemeris" in normalized
451
+ ):
452
+ default_geometry = "spice" if product == "sza" else None
453
+ geometry = _geometry_source(normalized, default=default_geometry)
454
+ normalized["geometry"] = geometry
455
+ normalized["geometry_source"] = geometry
456
+ reference = _coordinate_reference(normalized)
457
+ normalized["lon_domain"] = _canonical_lon_domain(
458
+ str(normalized.get("lon_domain", reference.get("lon_domain", "0_360")))
459
+ )
460
+ normalized["lon_direction"] = _canonical_lon_direction(
461
+ str(
462
+ normalized.get(
463
+ "lon_direction",
464
+ reference.get("lon_direction", "east_positive"),
465
+ )
466
+ )
467
+ )
468
+ normalized["lat_type"] = _canonical_lat_type(
469
+ str(normalized.get("lat_type", reference.get("lat_type", "planetocentric")))
470
+ )
471
+ normalized["shape"] = _canonical_shape(
472
+ str(normalized.get("shape", reference.get("shape", "spherical")))
473
+ )
474
+ datum = normalized.get("datum", reference.get("datum"))
475
+ if datum is not None:
476
+ normalized["datum"] = str(datum)
477
+ normalized["projection"] = _canonical_projection(
478
+ str(normalized.get("projection", reference.get("projection", "native")))
479
+ )
480
+ normalized["area_or_point"] = _canonical_area_or_point(
481
+ str(normalized.get("area_or_point", reference.get("area_or_point", "area")))
482
+ )
483
+ return normalized
484
+
485
+
486
+ def _surface_model(parameters: dict[str, Any]) -> str:
487
+ method = parameters.get("method")
488
+ model = parameters.get("model")
489
+ if method is not None and model is not None and str(method) != str(model):
490
+ raise ValueError("method and model must match when both are provided")
491
+ value = method if method is not None else model
492
+ if value is None:
493
+ raise ValueError("method/model cannot be empty")
494
+ return str(value)
495
+
496
+
497
+ def _geometry_source(parameters: dict[str, Any], *, default: str | None) -> str:
498
+ value = parameters.get(
499
+ "geometry_source",
500
+ parameters.get("geometry", parameters.get("ephemeris", default)),
501
+ )
502
+ if value is None:
503
+ raise ValueError("geometry_source cannot be empty")
504
+ return str(value)
505
+
506
+
507
+ def _coordinate_reference(parameters: dict[str, Any]) -> dict[str, Any]:
508
+ region = _metadata_value(parameters.get("region"))
509
+ if isinstance(region, dict):
510
+ return region
511
+ dem = _metadata_value(parameters.get("dem"))
512
+ if isinstance(dem, dict) and isinstance(dem.get("parameters"), dict):
513
+ return dem["parameters"]
514
+ return {}
515
+
516
+
517
+ def _canonical_lon_domain(lon_domain: str) -> str:
518
+ if lon_domain == "minus180_180":
519
+ return "-180_180"
520
+ if lon_domain in {"0_360", "-180_180"}:
521
+ return lon_domain
522
+ raise ValueError("lon_domain must be '0_360', '-180_180', or 'minus180_180'")
523
+
524
+
525
+ def _canonical_lon_direction(lon_direction: str) -> str:
526
+ if lon_direction in {"east_positive", "west_positive"}:
527
+ return lon_direction
528
+ raise ValueError("lon_direction must be 'east_positive' or 'west_positive'")
529
+
530
+
531
+ def _canonical_lat_type(lat_type: str) -> str:
532
+ if lat_type in {"planetocentric", "planetographic"}:
533
+ return lat_type
534
+ raise ValueError("lat_type must be 'planetocentric' or 'planetographic'")
535
+
536
+
537
+ def _canonical_shape(shape: str) -> str:
538
+ aliases = {
539
+ "sphere": "spherical",
540
+ "spice": "spice_body_radii",
541
+ "body_radii": "spice_body_radii",
542
+ }
543
+ canonical = aliases.get(shape, shape)
544
+ allowed = {"spherical", "ellipsoid", "triaxial", "spice_body_radii"}
545
+ if canonical in allowed:
546
+ return canonical
547
+ raise ValueError(
548
+ "shape must be one of spherical, ellipsoid, triaxial, "
549
+ "spice_body_radii, sphere, spice, or body_radii"
550
+ )
551
+
552
+
553
+ def _canonical_projection(projection: str) -> str:
554
+ aliases = {
555
+ "polar_stereo": "polar_stereographic",
556
+ }
557
+ canonical = aliases.get(projection, projection)
558
+ allowed = {
559
+ "equirectangular",
560
+ "polar_stereographic",
561
+ "orthographic",
562
+ "azimuthal_equidistant",
563
+ "lambert",
564
+ "native",
565
+ }
566
+ if canonical in allowed:
567
+ return canonical
568
+ raise ValueError(
569
+ "projection must be one of equirectangular, polar_stereographic, "
570
+ "orthographic, azimuthal_equidistant, lambert, native, or polar_stereo"
571
+ )
572
+
573
+
574
+ def _canonical_area_or_point(area_or_point: str) -> str:
575
+ if area_or_point in {"area", "point"}:
576
+ return area_or_point
577
+ raise ValueError("area_or_point must be 'area' or 'point'")
578
+
579
+
580
+ def _guide_page(
581
+ *,
582
+ title: str,
583
+ source: str,
584
+ markdowns: dict[str, str],
585
+ language: str,
586
+ url: str | None = None,
587
+ ) -> GuidePage:
588
+ if language not in _GUIDE_LANGUAGES:
589
+ raise ValueError(f"Moon guide language is not available: {language}")
590
+ return GuidePage(
591
+ title=title,
592
+ markdown=markdowns[language],
593
+ source=source,
594
+ url=url,
595
+ language=language,
596
+ available_languages=_GUIDE_LANGUAGES,
597
+ translations={
598
+ available_language: markdowns[available_language]
599
+ for available_language in _GUIDE_LANGUAGES
600
+ if available_language != language
601
+ },
602
+ )
603
+
604
+
605
+ def _surface_product_schema(product: str) -> InstrumentSchema:
606
+ return InstrumentSchema(
607
+ mission="moon",
608
+ instrument=f"surface.{product}",
609
+ variables=(MOON_SURFACE_SCHEMA.variable(product),),
610
+ )
611
+
612
+
613
+ def _example_page(title: str, markdown: str) -> GuidePage:
614
+ return GuidePage(
615
+ title=title,
616
+ markdown=markdown,
617
+ source="sopran.bodies.moon.examples",
618
+ )