thistle-db 0.6.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.
- thistle_db/__init__.py +4 -0
- thistle_db/_version.py +24 -0
- thistle_db/api.py +61 -0
- thistle_db/cli.py +286 -0
- thistle_db/config.py +118 -0
- thistle_db/generator.py +155 -0
- thistle_db/ingest.py +312 -0
- thistle_db/model.py +198 -0
- thistle_db/reader.py +204 -0
- thistle_db-0.6.0.dist-info/METADATA +269 -0
- thistle_db-0.6.0.dist-info/RECORD +14 -0
- thistle_db-0.6.0.dist-info/WHEEL +4 -0
- thistle_db-0.6.0.dist-info/entry_points.txt +2 -0
- thistle_db-0.6.0.dist-info/licenses/LICENSE +7 -0
thistle_db/__init__.py
ADDED
thistle_db/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.6.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 6, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
thistle_db/api.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Programmatic query API for the thistle-db database."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.engine import Engine
|
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
8
|
+
|
|
9
|
+
from thistle_db.config import Settings, load_config
|
|
10
|
+
from thistle_db.model import TLE, Base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def open_session(config: Settings) -> tuple[Session, Engine]:
|
|
14
|
+
"""Create the schema if needed and open a session on the configured database."""
|
|
15
|
+
engine = config.database.engine
|
|
16
|
+
Base.metadata.create_all(engine)
|
|
17
|
+
session_factory = sessionmaker(bind=engine)
|
|
18
|
+
return session_factory(), engine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def tles_for_object(session: Session, satnum: int) -> list[TLE]:
|
|
22
|
+
"""All TLEs for one satellite, ordered by epoch."""
|
|
23
|
+
stmt = select(TLE).where(TLE.norad_cat_id == satnum).order_by(TLE.epoch)
|
|
24
|
+
return list(session.execute(stmt).scalars().all())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def nearest_tles_for_date(
|
|
28
|
+
session: Session, date: datetime.date, days: float
|
|
29
|
+
) -> list[TLE]:
|
|
30
|
+
"""Nearest TLE per satellite to 12:00 UTC on `date`, within +/- `days`."""
|
|
31
|
+
center = datetime.datetime.combine(date, datetime.time(12))
|
|
32
|
+
window = datetime.timedelta(days=days)
|
|
33
|
+
stmt = select(TLE).where(
|
|
34
|
+
TLE.epoch >= center - window,
|
|
35
|
+
TLE.epoch <= center + window,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
nearest: dict[int, TLE] = {}
|
|
39
|
+
for tle in session.execute(stmt).scalars():
|
|
40
|
+
if tle.norad_cat_id is None:
|
|
41
|
+
continue
|
|
42
|
+
best = nearest.get(tle.norad_cat_id)
|
|
43
|
+
if best is None or abs(tle.epoch - center) < abs(best.epoch - center):
|
|
44
|
+
nearest[tle.norad_cat_id] = tle
|
|
45
|
+
return [nearest[satnum] for satnum in sorted(nearest)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_tles(satnum: int, config: Settings | None = None) -> list[tuple[str, str]]:
|
|
49
|
+
"""Return (line1, line2) pairs for one satellite, ordered by epoch.
|
|
50
|
+
|
|
51
|
+
Opens a session on the configured database (config.toml / THISTLE_DB_*
|
|
52
|
+
env vars when `config` is None) and disposes it before returning.
|
|
53
|
+
"""
|
|
54
|
+
if config is None:
|
|
55
|
+
config = load_config(None)
|
|
56
|
+
session, engine = open_session(config)
|
|
57
|
+
try:
|
|
58
|
+
return [(tle.line1, tle.line2) for tle in tles_for_object(session, satnum)]
|
|
59
|
+
finally:
|
|
60
|
+
session.close()
|
|
61
|
+
engine.dispose()
|
thistle_db/cli.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from sgp4.alpha5 import from_alpha5
|
|
9
|
+
|
|
10
|
+
from thistle_db.api import nearest_tles_for_date, open_session, tles_for_object
|
|
11
|
+
from thistle_db.config import load_config
|
|
12
|
+
from thistle_db.generator import generate as generate_outputs
|
|
13
|
+
from thistle_db.ingest import FileStatus, ingest_source_file, ingest_sources
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="thistle-db",
|
|
17
|
+
help="TLE database management tool",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _setup_logging(level: str) -> None:
|
|
23
|
+
logger.remove()
|
|
24
|
+
logger.add(sys.stderr, level=level.upper())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CONFIG_TEMPLATE = """\
|
|
28
|
+
# thistle-db configuration
|
|
29
|
+
# See: https://github.com/jolsten/thistle
|
|
30
|
+
|
|
31
|
+
# ----------------------------------------------
|
|
32
|
+
# Database connection
|
|
33
|
+
# ----------------------------------------------
|
|
34
|
+
[database]
|
|
35
|
+
# SQLAlchemy driver string. Examples:
|
|
36
|
+
# "sqlite" - local SQLite file (default)
|
|
37
|
+
# "mysql+pymysql" - MariaDB/MySQL via PyMySQL (install with: pip install thistle-db[mysql])
|
|
38
|
+
drivername = "sqlite"
|
|
39
|
+
|
|
40
|
+
# For SQLite: path to the database file (relative or absolute)
|
|
41
|
+
# For MariaDB/MySQL: the database name
|
|
42
|
+
name = "thistle-db.db"
|
|
43
|
+
|
|
44
|
+
# Uncomment for MariaDB/MySQL:
|
|
45
|
+
# host = "localhost"
|
|
46
|
+
# port = 3306
|
|
47
|
+
#
|
|
48
|
+
# Credentials are loaded separately (never put passwords here).
|
|
49
|
+
# Resolution order (highest priority first):
|
|
50
|
+
# 1. Environment variables: THISTLE_DB_DATABASE__USERNAME / THISTLE_DB_DATABASE__PASSWORD
|
|
51
|
+
# 2. User secrets file: ~/.config/thistle-db.toml
|
|
52
|
+
# 3. System secrets file: set secrets_file below
|
|
53
|
+
#
|
|
54
|
+
# secrets_file = "/etc/thistle-db/secrets.toml"
|
|
55
|
+
|
|
56
|
+
# ----------------------------------------------
|
|
57
|
+
# Ingest sources
|
|
58
|
+
# ----------------------------------------------
|
|
59
|
+
# Each [[ingest.sources]] entry defines a directory to scan for TLE/OMM files.
|
|
60
|
+
# You can have multiple entries. File format is auto-detected by extension:
|
|
61
|
+
# .tle / .txt / .3le -> Two-Line Element format
|
|
62
|
+
# .json -> Space-Track OMM JSON
|
|
63
|
+
# .csv -> OMM CSV
|
|
64
|
+
# .xml -> OMM XML
|
|
65
|
+
|
|
66
|
+
[[ingest.sources]]
|
|
67
|
+
path = "./incoming"
|
|
68
|
+
pattern = "*.tle" # glob pattern for matching files
|
|
69
|
+
|
|
70
|
+
# Add more sources as needed:
|
|
71
|
+
# [[ingest.sources]]
|
|
72
|
+
# path = "/data/spacetrack/daily"
|
|
73
|
+
# pattern = "*.json"
|
|
74
|
+
|
|
75
|
+
# ----------------------------------------------
|
|
76
|
+
# Output generation
|
|
77
|
+
# ----------------------------------------------
|
|
78
|
+
[output]
|
|
79
|
+
dir = "./output" # directory where generated files are written
|
|
80
|
+
|
|
81
|
+
# Which output formats to produce
|
|
82
|
+
[output.formats]
|
|
83
|
+
tle = true # two-line element format (.tle files)
|
|
84
|
+
omm = true # OMM CSV format (.omm files)
|
|
85
|
+
|
|
86
|
+
# Which output file types to generate
|
|
87
|
+
[output.types]
|
|
88
|
+
date_files = true # one file per date: YYYYMMDD.{tle,omm}
|
|
89
|
+
# contains the latest TLE per satellite for that date
|
|
90
|
+
object_files = true # one file per satellite: NORAD_ID.{tle,omm}
|
|
91
|
+
# contains all TLEs for that satellite, ordered by epoch
|
|
92
|
+
|
|
93
|
+
# ----------------------------------------------
|
|
94
|
+
# Logging
|
|
95
|
+
# ----------------------------------------------
|
|
96
|
+
[logging]
|
|
97
|
+
level = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
SECRETS_TEMPLATE = """\
|
|
101
|
+
# thistle-db user secrets
|
|
102
|
+
# This file stores database credentials for your user account.
|
|
103
|
+
# Keep this file private (chmod 600 on Linux/macOS).
|
|
104
|
+
#
|
|
105
|
+
# These values are overridden by environment variables:
|
|
106
|
+
# THISTLE_DB_DATABASE__USERNAME
|
|
107
|
+
# THISTLE_DB_DATABASE__PASSWORD
|
|
108
|
+
|
|
109
|
+
username = ""
|
|
110
|
+
password = ""
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.callback()
|
|
115
|
+
def main_callback(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
config: Annotated[
|
|
118
|
+
Path,
|
|
119
|
+
typer.Option(
|
|
120
|
+
"-c",
|
|
121
|
+
"--config",
|
|
122
|
+
envvar="THISTLE_DB_CONFIG",
|
|
123
|
+
help="Path to config.toml (default: $THISTLE_DB_CONFIG or ./config.toml)",
|
|
124
|
+
),
|
|
125
|
+
] = Path("config.toml"),
|
|
126
|
+
) -> None:
|
|
127
|
+
ctx.obj = config
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def init(ctx: typer.Context) -> None:
|
|
132
|
+
"""Scaffold config.toml and ~/.config/thistle-db.toml."""
|
|
133
|
+
config_path: Path = ctx.obj
|
|
134
|
+
secrets_path = Path.home() / ".config" / "thistle-db.toml"
|
|
135
|
+
|
|
136
|
+
created = []
|
|
137
|
+
|
|
138
|
+
if config_path.exists():
|
|
139
|
+
print(f"Config already exists: {config_path}")
|
|
140
|
+
else:
|
|
141
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
config_path.write_text(CONFIG_TEMPLATE)
|
|
143
|
+
created.append(str(config_path))
|
|
144
|
+
|
|
145
|
+
if secrets_path.exists():
|
|
146
|
+
print(f"Secrets file already exists: {secrets_path}")
|
|
147
|
+
else:
|
|
148
|
+
secrets_path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
secrets_path.write_text(SECRETS_TEMPLATE)
|
|
150
|
+
created.append(str(secrets_path))
|
|
151
|
+
|
|
152
|
+
if created:
|
|
153
|
+
print("Created:")
|
|
154
|
+
for path in created:
|
|
155
|
+
print(f" {path}")
|
|
156
|
+
print("\nEdit these files to configure your database and credentials.")
|
|
157
|
+
else:
|
|
158
|
+
print("Nothing to do - all files already exist.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def ingest(
|
|
163
|
+
ctx: typer.Context,
|
|
164
|
+
files: Annotated[
|
|
165
|
+
Optional[list[Path]],
|
|
166
|
+
typer.Argument(
|
|
167
|
+
help="Specific files to ingest (if omitted, scans configured source dirs)"
|
|
168
|
+
),
|
|
169
|
+
] = None,
|
|
170
|
+
force: Annotated[
|
|
171
|
+
bool,
|
|
172
|
+
typer.Option(
|
|
173
|
+
"--force",
|
|
174
|
+
help="Re-ingest files even if their recorded state is unchanged",
|
|
175
|
+
),
|
|
176
|
+
] = False,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Ingest TLE/OMM files into the database."""
|
|
179
|
+
config = load_config(ctx.obj)
|
|
180
|
+
_setup_logging(config.logging.level)
|
|
181
|
+
|
|
182
|
+
session, engine = open_session(config)
|
|
183
|
+
try:
|
|
184
|
+
if files:
|
|
185
|
+
total = 0
|
|
186
|
+
failed = 0
|
|
187
|
+
for file in files:
|
|
188
|
+
# Explicitly named files always parse; state is still recorded.
|
|
189
|
+
status, count = ingest_source_file(session, file, force=True)
|
|
190
|
+
if status == FileStatus.FAILED:
|
|
191
|
+
failed += 1
|
|
192
|
+
total += count
|
|
193
|
+
logger.info(
|
|
194
|
+
f"Ingested {total} new records from {len(files)} files"
|
|
195
|
+
+ (f" ({failed} failed)" if failed else "")
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
total = ingest_sources(session, config.ingest.sources, force=force)
|
|
199
|
+
logger.info(f"Ingested {total} new records from configured sources")
|
|
200
|
+
finally:
|
|
201
|
+
session.close()
|
|
202
|
+
engine.dispose()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.command()
|
|
206
|
+
def generate(ctx: typer.Context) -> None:
|
|
207
|
+
"""Generate output TLE/OMM files from the database."""
|
|
208
|
+
config = load_config(ctx.obj)
|
|
209
|
+
_setup_logging(config.logging.level)
|
|
210
|
+
|
|
211
|
+
session, engine = open_session(config)
|
|
212
|
+
try:
|
|
213
|
+
generate_outputs(session, config.output)
|
|
214
|
+
finally:
|
|
215
|
+
session.close()
|
|
216
|
+
engine.dispose()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_target(value: str) -> int | datetime.date:
|
|
220
|
+
"""Parse a get-tle target: 8-digit YYYYMMDD -> date, else alpha-5 NORAD ID."""
|
|
221
|
+
if len(value) == 8 and value.isdigit():
|
|
222
|
+
try:
|
|
223
|
+
return datetime.datetime.strptime(value, "%Y%m%d").date()
|
|
224
|
+
except ValueError:
|
|
225
|
+
raise typer.BadParameter(
|
|
226
|
+
f"{value!r} is not a valid YYYYMMDD date"
|
|
227
|
+
) from None
|
|
228
|
+
try:
|
|
229
|
+
return from_alpha5(value.upper())
|
|
230
|
+
except ValueError:
|
|
231
|
+
raise typer.BadParameter(
|
|
232
|
+
f"{value!r} is neither an alpha-5 NORAD ID nor a YYYYMMDD date"
|
|
233
|
+
) from None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@app.command(name="get-tle")
|
|
237
|
+
def get_tle(
|
|
238
|
+
ctx: typer.Context,
|
|
239
|
+
target: Annotated[
|
|
240
|
+
str,
|
|
241
|
+
typer.Argument(
|
|
242
|
+
help="NORAD ID (alpha-5, e.g. 25544 or E5693) or date (YYYYMMDD)",
|
|
243
|
+
show_default=False,
|
|
244
|
+
),
|
|
245
|
+
],
|
|
246
|
+
days: Annotated[
|
|
247
|
+
float,
|
|
248
|
+
typer.Option(
|
|
249
|
+
"--days",
|
|
250
|
+
"-d",
|
|
251
|
+
help="Date mode: search window around the date, in days",
|
|
252
|
+
),
|
|
253
|
+
] = 7.0,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Print TLEs to stdout.
|
|
256
|
+
|
|
257
|
+
With a NORAD ID (alpha-5): all TLEs for that object, ordered by epoch.
|
|
258
|
+
With a YYYYMMDD date: the nearest TLE per object to 12:00 UTC on that
|
|
259
|
+
date, within +/- DAYS days.
|
|
260
|
+
"""
|
|
261
|
+
parsed = _parse_target(target)
|
|
262
|
+
|
|
263
|
+
config = load_config(ctx.obj)
|
|
264
|
+
_setup_logging(config.logging.level)
|
|
265
|
+
|
|
266
|
+
session, engine = open_session(config)
|
|
267
|
+
try:
|
|
268
|
+
if isinstance(parsed, int):
|
|
269
|
+
tles = tles_for_object(session, parsed)
|
|
270
|
+
else:
|
|
271
|
+
tles = nearest_tles_for_date(session, parsed, days)
|
|
272
|
+
|
|
273
|
+
for tle in tles:
|
|
274
|
+
print(tle.line1)
|
|
275
|
+
print(tle.line2)
|
|
276
|
+
finally:
|
|
277
|
+
session.close()
|
|
278
|
+
engine.dispose()
|
|
279
|
+
|
|
280
|
+
if not tles:
|
|
281
|
+
logger.warning(f"No TLEs found for {target}")
|
|
282
|
+
raise typer.Exit(code=1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main() -> None:
|
|
286
|
+
app()
|
thistle_db/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
from sqlalchemy import Engine, create_engine
|
|
8
|
+
from sqlalchemy.engine import URL
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Database(BaseModel):
|
|
12
|
+
drivername: str = "sqlite"
|
|
13
|
+
username: Optional[str] = None
|
|
14
|
+
password: Optional[str] = None
|
|
15
|
+
host: Optional[str] = None
|
|
16
|
+
port: Optional[int] = None
|
|
17
|
+
name: Optional[str] = ":memory:"
|
|
18
|
+
secrets_file: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def url(self) -> URL:
|
|
22
|
+
return URL.create(
|
|
23
|
+
drivername=self.drivername,
|
|
24
|
+
username=self.username,
|
|
25
|
+
password=self.password,
|
|
26
|
+
host=self.host,
|
|
27
|
+
port=self.port,
|
|
28
|
+
database=self.name,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def engine(self) -> Engine:
|
|
33
|
+
return create_engine(self.url)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IngestSource(BaseModel):
|
|
37
|
+
path: str
|
|
38
|
+
pattern: str = "*.tle"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IngestConfig(BaseModel):
|
|
42
|
+
sources: list[IngestSource] = []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OutputFormats(BaseModel):
|
|
46
|
+
tle: bool = True
|
|
47
|
+
omm: bool = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OutputTypes(BaseModel):
|
|
51
|
+
date_files: bool = True
|
|
52
|
+
object_files: bool = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OutputConfig(BaseModel):
|
|
56
|
+
dir: str = "./output"
|
|
57
|
+
formats: OutputFormats = OutputFormats()
|
|
58
|
+
types: OutputTypes = OutputTypes()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LoggingConfig(BaseModel):
|
|
62
|
+
level: str = "INFO"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Settings(BaseSettings):
|
|
66
|
+
model_config = SettingsConfigDict(
|
|
67
|
+
env_prefix="THISTLE_DB_",
|
|
68
|
+
env_nested_delimiter="__",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
database: Database = Database()
|
|
72
|
+
ingest: IngestConfig = IngestConfig()
|
|
73
|
+
output: OutputConfig = OutputConfig()
|
|
74
|
+
logging: LoggingConfig = LoggingConfig()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_config(path: Path | None = None) -> Settings:
|
|
78
|
+
"""Load settings from TOML file with layered credential resolution.
|
|
79
|
+
|
|
80
|
+
Resolution order (highest priority first):
|
|
81
|
+
1. Environment variables (THISTLE_DB_DATABASE__USERNAME, etc.)
|
|
82
|
+
2. User secrets (~/.config/thistle-db.toml)
|
|
83
|
+
3. System secrets file (database.secrets_file in config)
|
|
84
|
+
4. Values in config.toml
|
|
85
|
+
"""
|
|
86
|
+
toml_data: dict = {}
|
|
87
|
+
|
|
88
|
+
if path is not None and path.exists():
|
|
89
|
+
with open(path, "rb") as f:
|
|
90
|
+
toml_data = tomllib.load(f)
|
|
91
|
+
|
|
92
|
+
# Load system secrets file if specified
|
|
93
|
+
db_data = toml_data.get("database", {})
|
|
94
|
+
secrets_file = db_data.get("secrets_file")
|
|
95
|
+
if secrets_file:
|
|
96
|
+
secrets_path = Path(secrets_file)
|
|
97
|
+
if secrets_path.exists():
|
|
98
|
+
with open(secrets_path, "rb") as f:
|
|
99
|
+
secrets = tomllib.load(f)
|
|
100
|
+
# Merge secrets into database config (secrets_file has lower priority
|
|
101
|
+
# than values already in db_data, which have lower priority than env vars)
|
|
102
|
+
for key in ("username", "password"):
|
|
103
|
+
if key in secrets and key not in db_data:
|
|
104
|
+
db_data[key] = secrets[key]
|
|
105
|
+
toml_data["database"] = db_data
|
|
106
|
+
|
|
107
|
+
# Load user-local secrets (~/.config/thistle-db.toml)
|
|
108
|
+
user_secrets_path = Path.home() / ".config" / "thistle-db.toml"
|
|
109
|
+
if user_secrets_path.exists():
|
|
110
|
+
with open(user_secrets_path, "rb") as f:
|
|
111
|
+
user_secrets = tomllib.load(f)
|
|
112
|
+
for key in ("username", "password"):
|
|
113
|
+
if key in user_secrets and key not in db_data:
|
|
114
|
+
db_data[key] = user_secrets[key]
|
|
115
|
+
toml_data["database"] = db_data
|
|
116
|
+
|
|
117
|
+
# pydantic-settings handles env var overrides automatically
|
|
118
|
+
return Settings(**toml_data)
|
thistle_db/generator.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from sgp4.api import Satrec
|
|
6
|
+
from sgp4.exporter import export_omm
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from thistle_db.config import OutputConfig
|
|
11
|
+
from thistle_db.model import TLE
|
|
12
|
+
from thistle_db.reader import TLETuple, write_omm_csv, write_tle
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _reconstruct_satrec(tle: TLE) -> Satrec:
|
|
16
|
+
"""Reconstruct a Satrec object from stored TLE lines."""
|
|
17
|
+
return Satrec.twoline2rv(tle.line1, tle.line2)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _safe_export_omm(sat: Satrec, name: str) -> dict | None:
|
|
21
|
+
"""Export OMM dict from Satrec, returning None if export fails."""
|
|
22
|
+
try:
|
|
23
|
+
return export_omm(sat, name)
|
|
24
|
+
except (ValueError, AttributeError):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_object_name(tle: TLE) -> str:
|
|
29
|
+
"""Get object name from OmmMetadata if available, else fall back to object_id."""
|
|
30
|
+
if tle.omm_metadata and tle.omm_metadata.object_name:
|
|
31
|
+
return tle.omm_metadata.object_name
|
|
32
|
+
return tle.object_id
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_date_files(
|
|
36
|
+
session: Session, output_dir: pathlib.Path, config: OutputConfig
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Generate one file per date with the latest TLE per object."""
|
|
39
|
+
# Get distinct dates (UTC date truncation of epoch)
|
|
40
|
+
date_query = select(func.date(TLE.epoch).label("d")).distinct()
|
|
41
|
+
dates = session.execute(date_query).scalars().all()
|
|
42
|
+
|
|
43
|
+
logger.info(f"Generating date files for {len(dates)} dates")
|
|
44
|
+
|
|
45
|
+
for raw_date in dates:
|
|
46
|
+
if raw_date is None:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# func.date() returns str on SQLite and datetime.date on MariaDB/MySQL.
|
|
50
|
+
if isinstance(raw_date, datetime.date):
|
|
51
|
+
date_val = raw_date
|
|
52
|
+
else:
|
|
53
|
+
date_val = datetime.date.fromisoformat(str(raw_date))
|
|
54
|
+
|
|
55
|
+
# Subquery: max epoch per norad_cat_id on this date
|
|
56
|
+
subq = (
|
|
57
|
+
select(
|
|
58
|
+
TLE.norad_cat_id,
|
|
59
|
+
func.max(TLE.epoch).label("max_epoch"),
|
|
60
|
+
)
|
|
61
|
+
.where(func.date(TLE.epoch) == date_val)
|
|
62
|
+
.group_by(TLE.norad_cat_id)
|
|
63
|
+
.subquery()
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Join back to get full TLE rows
|
|
67
|
+
stmt = (
|
|
68
|
+
select(TLE)
|
|
69
|
+
.join(
|
|
70
|
+
subq,
|
|
71
|
+
(TLE.norad_cat_id == subq.c.norad_cat_id)
|
|
72
|
+
& (TLE.epoch == subq.c.max_epoch),
|
|
73
|
+
)
|
|
74
|
+
.order_by(TLE.norad_cat_id)
|
|
75
|
+
)
|
|
76
|
+
tles = session.execute(stmt).scalars().all()
|
|
77
|
+
|
|
78
|
+
if not tles:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
filename_date = date_val.strftime("%Y%m%d")
|
|
82
|
+
|
|
83
|
+
# TLE output
|
|
84
|
+
if config.formats.tle:
|
|
85
|
+
tle_path = output_dir / f"{filename_date}.tle"
|
|
86
|
+
tle_tuples: list[TLETuple] = [(t.line1, t.line2) for t in tles]
|
|
87
|
+
write_tle(tle_path, tle_tuples)
|
|
88
|
+
|
|
89
|
+
# OMM output
|
|
90
|
+
if config.formats.omm:
|
|
91
|
+
omm_path = output_dir / f"{filename_date}.omm"
|
|
92
|
+
omm_records = []
|
|
93
|
+
for tle in tles:
|
|
94
|
+
sat = _reconstruct_satrec(tle)
|
|
95
|
+
name = _get_object_name(tle)
|
|
96
|
+
record = _safe_export_omm(sat, name)
|
|
97
|
+
if record is not None:
|
|
98
|
+
omm_records.append(record)
|
|
99
|
+
write_omm_csv(omm_path, omm_records)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def generate_object_files(
|
|
103
|
+
session: Session, output_dir: pathlib.Path, config: OutputConfig
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Generate one file per satellite with all its TLEs."""
|
|
106
|
+
# Get distinct NORAD IDs
|
|
107
|
+
norad_ids = (
|
|
108
|
+
session.execute(select(TLE.norad_cat_id).distinct().order_by(TLE.norad_cat_id))
|
|
109
|
+
.scalars()
|
|
110
|
+
.all()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
logger.info(f"Generating object files for {len(norad_ids)} satellites")
|
|
114
|
+
|
|
115
|
+
for norad_id in norad_ids:
|
|
116
|
+
if norad_id is None:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
stmt = select(TLE).where(TLE.norad_cat_id == norad_id).order_by(TLE.epoch)
|
|
120
|
+
tles = session.execute(stmt).scalars().all()
|
|
121
|
+
|
|
122
|
+
if not tles:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# TLE output
|
|
126
|
+
if config.formats.tle:
|
|
127
|
+
tle_path = output_dir / f"{norad_id}.tle"
|
|
128
|
+
tle_tuples: list[TLETuple] = [(t.line1, t.line2) for t in tles]
|
|
129
|
+
write_tle(tle_path, tle_tuples)
|
|
130
|
+
|
|
131
|
+
# OMM output
|
|
132
|
+
if config.formats.omm:
|
|
133
|
+
omm_path = output_dir / f"{norad_id}.omm"
|
|
134
|
+
omm_records = []
|
|
135
|
+
for tle in tles:
|
|
136
|
+
sat = _reconstruct_satrec(tle)
|
|
137
|
+
name = _get_object_name(tle)
|
|
138
|
+
record = _safe_export_omm(sat, name)
|
|
139
|
+
if record is not None:
|
|
140
|
+
omm_records.append(record)
|
|
141
|
+
write_omm_csv(omm_path, omm_records)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def generate(session: Session, config: OutputConfig) -> None:
|
|
145
|
+
"""Generate all configured output files."""
|
|
146
|
+
output_dir = pathlib.Path(config.dir)
|
|
147
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
if config.types.date_files:
|
|
150
|
+
generate_date_files(session, output_dir, config)
|
|
151
|
+
|
|
152
|
+
if config.types.object_files:
|
|
153
|
+
generate_object_files(session, output_dir, config)
|
|
154
|
+
|
|
155
|
+
logger.info(f"Output generation complete → {output_dir}")
|