psrmcp 0.1.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.
- psrmcp/__init__.py +5 -0
- psrmcp/_context.py +44 -0
- psrmcp/adapters/base.py +185 -0
- psrmcp/adapters/dspsr.py +50 -0
- psrmcp/adapters/pint.py +58 -0
- psrmcp/adapters/psrcat.py +197 -0
- psrmcp/adapters/psrchive.py +168 -0
- psrmcp/adapters/tempo2.py +33 -0
- psrmcp/config.py +196 -0
- psrmcp/errors.py +106 -0
- psrmcp/execution/jobs.py +221 -0
- psrmcp/execution/runner.py +203 -0
- psrmcp/execution/tasks.py +19 -0
- psrmcp/execution/workspace.py +227 -0
- psrmcp/ids.py +22 -0
- psrmcp/models/common.py +159 -0
- psrmcp/models/inputs.py +282 -0
- psrmcp/models/results.py +473 -0
- psrmcp/paths.py +120 -0
- psrmcp/plotting/__init__.py +1 -0
- psrmcp/plotting/profile_plots.py +51 -0
- psrmcp/plotting/style.py +28 -0
- psrmcp/prompts.py +141 -0
- psrmcp/resources.py +150 -0
- psrmcp/server.py +170 -0
- psrmcp/tools/_common.py +92 -0
- psrmcp/tools/archive.py +300 -0
- psrmcp/tools/artifacts.py +62 -0
- psrmcp/tools/catalog.py +449 -0
- psrmcp/tools/data.py +128 -0
- psrmcp/tools/dedispersion.py +209 -0
- psrmcp/tools/profile.py +219 -0
- psrmcp/tools/registry.py +173 -0
- psrmcp/tools/rfi.py +211 -0
- psrmcp/tools/system.py +87 -0
- psrmcp/tools/timing.py +445 -0
- psrmcp/workers/pint_worker.py +308 -0
- psrmcp-0.1.0.dist-info/METADATA +83 -0
- psrmcp-0.1.0.dist-info/RECORD +42 -0
- psrmcp-0.1.0.dist-info/WHEEL +4 -0
- psrmcp-0.1.0.dist-info/entry_points.txt +2 -0
- psrmcp-0.1.0.dist-info/licenses/LICENSE +21 -0
psrmcp/__init__.py
ADDED
psrmcp/_context.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""AppContext assembly: wire execution kernel pieces together (server.py helper)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .adapters.base import BackendManager
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .execution.jobs import JobManager
|
|
10
|
+
from .execution.runner import ProcessRunner
|
|
11
|
+
from .execution.tasks import TasksCapability
|
|
12
|
+
from .execution.workspace import ArtifactStore, Workspace
|
|
13
|
+
from .paths import PathResolver
|
|
14
|
+
from .tools._common import AppContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_context(cfg: Config) -> AppContext:
|
|
18
|
+
ws = Workspace(cfg.server.workspace.expanduser())
|
|
19
|
+
runner = ProcessRunner(
|
|
20
|
+
default_timeout_s=60.0,
|
|
21
|
+
max_threads=cfg.limits.max_threads,
|
|
22
|
+
max_output_bytes=int(cfg.limits.max_input_gb * (1 << 30)),
|
|
23
|
+
audit_log=ws.logs_dir / "audit.jsonl",
|
|
24
|
+
)
|
|
25
|
+
store = ArtifactStore(ws, max_output_bytes=int(cfg.limits.max_output_gb * (1 << 30)))
|
|
26
|
+
jobs = JobManager(
|
|
27
|
+
ws.sessions / "jobs.sqlite3",
|
|
28
|
+
max_concurrent=cfg.server.max_concurrent_jobs,
|
|
29
|
+
job_timeout_s=cfg.server.job_timeout_s,
|
|
30
|
+
)
|
|
31
|
+
resolver = PathResolver(cfg, artifact_lookup=store.lookup_id)
|
|
32
|
+
backends = BackendManager(cfg, runner, python_exe=sys.executable)
|
|
33
|
+
return AppContext(
|
|
34
|
+
cfg=cfg,
|
|
35
|
+
runner=runner,
|
|
36
|
+
ws=ws,
|
|
37
|
+
store=store,
|
|
38
|
+
jobs=jobs,
|
|
39
|
+
resolver=resolver,
|
|
40
|
+
backends=backends,
|
|
41
|
+
tasks=TasksCapability(),
|
|
42
|
+
python_exe=sys.executable,
|
|
43
|
+
audit_log=ws.logs_dir / "audit.jsonl",
|
|
44
|
+
)
|
psrmcp/adapters/base.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Backend probing and binary resolution (adapters layer, DESIGN.md §3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..config import Config
|
|
12
|
+
from ..errors import PsrmcpError, env_missing
|
|
13
|
+
from ..execution.runner import ProcessRunner
|
|
14
|
+
from ..models.common import BackendInfo
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class BackendSpec:
|
|
21
|
+
"""How to locate and version-check one backend."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
# Ordered candidate binaries: (path-or-name, version_args, version_regex)
|
|
25
|
+
probes: tuple[tuple[str, tuple[str, ...], str], ...] = ()
|
|
26
|
+
capabilities: tuple[str, ...] = ()
|
|
27
|
+
python: bool = False # python-package backend (PINT), probed via interpreter import
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DSPSR_SPEC = BackendSpec(
|
|
31
|
+
name="dspsr",
|
|
32
|
+
probes=(("dspsr", ("--version",), r"([0-9]+\.[0-9]+[.\-][^\s]*|DSPSR v?[0-9][^\s]*)"),),
|
|
33
|
+
capabilities=("dedisperse",),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
PSRCHIVE_SPEC = BackendSpec(
|
|
37
|
+
name="psrchive",
|
|
38
|
+
probes=(
|
|
39
|
+
# 实测:psrstat/psradd/psrconv 的 --version 打印 "PSRCHIVE <日期> <commit>";
|
|
40
|
+
# psrchive_config 只 dump 配置无版本,vap/pat 不认 --version。
|
|
41
|
+
("psrstat", ("--version",), r"(PSRCHIVE\s+\S+\s+\S+|[0-9]+\.[^\s]+)"),
|
|
42
|
+
("psrchive-config", ("--version",), r"([0-9]+\.[^\s]+)"),
|
|
43
|
+
),
|
|
44
|
+
capabilities=("inspect", "convert", "merge", "zap", "profile", "toa"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
TEMPO2_SPEC = BackendSpec(
|
|
48
|
+
name="tempo2",
|
|
49
|
+
probes=(("tempo2", ("-v",), r"(tempo2[^\s]*|[0-9]{4}[^\s]*)"),),
|
|
50
|
+
capabilities=("timing_fit", "timing_diagnose"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
PSRCAT_SPEC = BackendSpec(
|
|
54
|
+
name="psrcat",
|
|
55
|
+
probes=(("psrcat", ("-v",), r"([0-9]{4}[^\s]*|[0-9]+\.[^\s]+)"),),
|
|
56
|
+
capabilities=("catalog_search", "catalog_get", "ephemeris_export"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
PINT_SPEC = BackendSpec(name="pint", python=True, capabilities=("timing_fit", "timing_diagnose", "ephemeris_export"))
|
|
60
|
+
|
|
61
|
+
ALL_SPECS: dict[str, BackendSpec] = {
|
|
62
|
+
s.name: s for s in (DSPSR_SPEC, PSRCHIVE_SPEC, TEMPO2_SPEC, PSRCAT_SPEC, PINT_SPEC)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BackendManager:
|
|
67
|
+
"""Resolves binaries from config paths or PATH, probes versions, caches results."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, cfg: Config, runner: ProcessRunner, *, python_exe: str | None = None) -> None:
|
|
70
|
+
self._cfg = cfg
|
|
71
|
+
self._runner = runner
|
|
72
|
+
self._python_exe = python_exe or "python3"
|
|
73
|
+
self._cache: dict[str, BackendInfo] = {}
|
|
74
|
+
|
|
75
|
+
def configured_path(self, name: str) -> str | None:
|
|
76
|
+
b = self._cfg.backends
|
|
77
|
+
return {
|
|
78
|
+
"dspsr": b.dspsr,
|
|
79
|
+
"psrchive": b.psrchive_bin,
|
|
80
|
+
"tempo2": b.tempo2,
|
|
81
|
+
"psrcat": b.psrcat,
|
|
82
|
+
}.get(name)
|
|
83
|
+
|
|
84
|
+
def resolve(self, binary: str) -> str | None:
|
|
85
|
+
"""Resolve a binary name to an executable path, or None."""
|
|
86
|
+
if "/" in binary:
|
|
87
|
+
return binary if Path(binary).exists() else None
|
|
88
|
+
configured = self.configured_path(binary)
|
|
89
|
+
if configured:
|
|
90
|
+
if Path(configured).is_file():
|
|
91
|
+
return str(configured)
|
|
92
|
+
found = shutil.which(configured)
|
|
93
|
+
if found:
|
|
94
|
+
return found
|
|
95
|
+
return shutil.which(binary)
|
|
96
|
+
|
|
97
|
+
def resolve_psrchive_tool(self, tool: str) -> str | None:
|
|
98
|
+
"""Resolve a psrchive suite binary (vap, psrstat, ...)."""
|
|
99
|
+
base = self._cfg.backends.psrchive_bin
|
|
100
|
+
if base:
|
|
101
|
+
candidate = Path(base) / tool
|
|
102
|
+
if candidate.is_file():
|
|
103
|
+
return str(candidate)
|
|
104
|
+
return shutil.which(tool)
|
|
105
|
+
|
|
106
|
+
async def probe_all(self, *, refresh: bool = False) -> dict[str, BackendInfo]:
|
|
107
|
+
if refresh:
|
|
108
|
+
self._cache.clear()
|
|
109
|
+
for name, spec in ALL_SPECS.items():
|
|
110
|
+
if name not in self._cache:
|
|
111
|
+
self._cache[name] = await self._probe(spec)
|
|
112
|
+
return dict(self._cache)
|
|
113
|
+
|
|
114
|
+
async def get(self, name: str, *, refresh: bool = False) -> BackendInfo:
|
|
115
|
+
if refresh or name not in self._cache:
|
|
116
|
+
self._cache[name] = await self._probe(ALL_SPECS[name])
|
|
117
|
+
return self._cache[name]
|
|
118
|
+
|
|
119
|
+
def cached(self, name: str) -> BackendInfo | None:
|
|
120
|
+
return self._cache.get(name)
|
|
121
|
+
|
|
122
|
+
async def require(self, name: str) -> BackendInfo:
|
|
123
|
+
info = await self.get(name)
|
|
124
|
+
if not info.available:
|
|
125
|
+
reason = info.reason or "不可用"
|
|
126
|
+
raise env_missing(name, reason)
|
|
127
|
+
return info
|
|
128
|
+
|
|
129
|
+
async def _probe(self, spec: BackendSpec) -> BackendInfo:
|
|
130
|
+
if spec.python:
|
|
131
|
+
return await self._probe_python(spec)
|
|
132
|
+
for binary, args, regex in spec.probes:
|
|
133
|
+
path = self.resolve_psrchive_tool(binary) if spec.name == "psrchive" else self.resolve(binary)
|
|
134
|
+
if path is None:
|
|
135
|
+
continue
|
|
136
|
+
info = await self._probe_binary(spec, path, list(args), regex)
|
|
137
|
+
if info.available:
|
|
138
|
+
return info
|
|
139
|
+
reasons = {
|
|
140
|
+
"dspsr": "未在 PATH 或配置路径中找到 dspsr",
|
|
141
|
+
"psrchive": "未找到 psrchive 工具链(psrstat/psrchive-config)",
|
|
142
|
+
"tempo2": "未找到 tempo2,或 tempo2 未启用",
|
|
143
|
+
"psrcat": "未找到 psrcat 二进制",
|
|
144
|
+
}
|
|
145
|
+
unavailable_reason = reasons.get(spec.name, f"未找到 {spec.name}")
|
|
146
|
+
if spec.name == "tempo2" and not self._cfg.backends.tempo2_enabled:
|
|
147
|
+
unavailable_reason = "配置中 tempo2_enabled=false"
|
|
148
|
+
return BackendInfo(available=False, reason=unavailable_reason, capabilities=list(spec.capabilities))
|
|
149
|
+
|
|
150
|
+
async def _probe_binary(self, spec: BackendSpec, path: str, args: list[str], regex: str) -> BackendInfo:
|
|
151
|
+
try:
|
|
152
|
+
result = await self._runner.run([path, *args], timeout_s=20.0)
|
|
153
|
+
except PsrmcpError as exc:
|
|
154
|
+
return BackendInfo(available=False, path=path, reason=str(exc.message))
|
|
155
|
+
text = (result.stdout_text + result.stderr_text).strip()
|
|
156
|
+
if result.exit_code != 0 and not text:
|
|
157
|
+
return BackendInfo(
|
|
158
|
+
available=False, path=path, reason=f"版本查询退出码 {result.exit_code}"
|
|
159
|
+
)
|
|
160
|
+
m = re.search(regex, text)
|
|
161
|
+
version = m.group(1) if m else text.splitlines()[0][:80] if text else None
|
|
162
|
+
return BackendInfo(available=True, path=path, version=version, capabilities=list(spec.capabilities))
|
|
163
|
+
|
|
164
|
+
async def _probe_python(self, spec: BackendSpec) -> BackendInfo:
|
|
165
|
+
if spec.name == "pint" and not self._cfg.backends.pint_enabled:
|
|
166
|
+
return BackendInfo(available=False, reason="配置中 pint_enabled=false")
|
|
167
|
+
try:
|
|
168
|
+
result = await self._runner.run(
|
|
169
|
+
[
|
|
170
|
+
self._python_exe,
|
|
171
|
+
"-c",
|
|
172
|
+
"import importlib;m=importlib.import_module('%s');print(getattr(m,'__version__','?'))"
|
|
173
|
+
% ("pint" if spec.name == "pint" else spec.name),
|
|
174
|
+
],
|
|
175
|
+
timeout_s=60.0,
|
|
176
|
+
)
|
|
177
|
+
except PsrmcpError as exc:
|
|
178
|
+
return BackendInfo(available=False, reason=exc.message)
|
|
179
|
+
version = result.stdout_text.strip() or None
|
|
180
|
+
if result.exit_code != 0 or version is None:
|
|
181
|
+
return BackendInfo(
|
|
182
|
+
available=False,
|
|
183
|
+
reason=f"python 包 {spec.name} 导入失败: {result.stderr_tail(400)}",
|
|
184
|
+
)
|
|
185
|
+
return BackendInfo(available=True, version=version, capabilities=list(spec.capabilities))
|
psrmcp/adapters/dspsr.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""dspsr adapter: dedisperse/fold command builder.
|
|
2
|
+
|
|
3
|
+
dspsr applies coherent dedispersion automatically to raw voltage backends when a
|
|
4
|
+
DM is available; filterbank inputs are incoherently dedispersed. The MCP `mode`
|
|
5
|
+
parameter is therefore advisory: it is validated, recorded in provenance, and a
|
|
6
|
+
warning is emitted when it cannot be honoured. TODO(contract) marks flags to
|
|
7
|
+
verify on the installed version.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dedisperse_cmd(
|
|
15
|
+
binary: str,
|
|
16
|
+
source: str,
|
|
17
|
+
*,
|
|
18
|
+
dm: float | None = None,
|
|
19
|
+
nbin: int | None = None,
|
|
20
|
+
nchan: int | None = None,
|
|
21
|
+
nsubint: int | None = None,
|
|
22
|
+
out_prefix: str = "out",
|
|
23
|
+
out_ext: str = "ar",
|
|
24
|
+
threads: int | None = None,
|
|
25
|
+
ephemeris: str | None = None,
|
|
26
|
+
) -> list[str]:
|
|
27
|
+
args = [binary]
|
|
28
|
+
if dm is not None:
|
|
29
|
+
args += ["-D", f"{dm:.12g}"]
|
|
30
|
+
if ephemeris is not None:
|
|
31
|
+
# -E: 用 par 生成相位预测器(输入头内嵌 predictor 缺失时必需,
|
|
32
|
+
# 如 filterbank/无 POLYCO 的 PSRFITS)
|
|
33
|
+
args += ["-E", str(ephemeris)]
|
|
34
|
+
if nbin is not None:
|
|
35
|
+
args += ["-b", str(nbin)]
|
|
36
|
+
if nchan is not None:
|
|
37
|
+
# TODO(contract): "-F" configures output channels (e.g. -F 512:D).
|
|
38
|
+
args += ["-F", str(nchan)]
|
|
39
|
+
if nsubint is not None:
|
|
40
|
+
# TODO(contract): "-L" sets sub-integration length in seconds; deriving
|
|
41
|
+
# it from nsubint requires the observation duration, so pass through as
|
|
42
|
+
# a best-effort "-L" only when the caller computed it.
|
|
43
|
+
args += ["-L", str(nsubint)]
|
|
44
|
+
if threads is not None:
|
|
45
|
+
args += ["-t", str(threads)] # TODO(contract): thread flag spelling.
|
|
46
|
+
# -O 是输出文件名前缀(不是目录):产物 = <prefix>.<out_ext>
|
|
47
|
+
args += ["-O", out_prefix]
|
|
48
|
+
args += ["-e", out_ext]
|
|
49
|
+
args.append(source)
|
|
50
|
+
return args
|
psrmcp/adapters/pint.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Bridge to the PINT worker subprocess (JSON over stdin/stdout, one request)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..errors import ErrorCode, PsrmcpError
|
|
11
|
+
from ..execution.runner import ProcessRunner
|
|
12
|
+
|
|
13
|
+
_WORKER_MODULE = "psrmcp.workers.pint_worker"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def worker_argv(python_exe: str | None = None) -> list[str]:
|
|
17
|
+
return [python_exe or sys.executable, "-m", _WORKER_MODULE]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def call_worker(
|
|
21
|
+
runner: ProcessRunner,
|
|
22
|
+
request: dict[str, Any],
|
|
23
|
+
*,
|
|
24
|
+
cwd: Path,
|
|
25
|
+
python_exe: str | None = None,
|
|
26
|
+
timeout_s: float = 600.0,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Run one worker request; returns the parsed response dict."""
|
|
29
|
+
argv = worker_argv(python_exe)
|
|
30
|
+
result = await runner.run(
|
|
31
|
+
argv,
|
|
32
|
+
cwd=cwd,
|
|
33
|
+
stdin_bytes=json.dumps(request).encode("utf-8"),
|
|
34
|
+
timeout_s=timeout_s,
|
|
35
|
+
)
|
|
36
|
+
return parse_worker_output(result.stdout_text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_worker_output(text: str) -> dict[str, Any]:
|
|
40
|
+
lines = [ln for ln in text.strip().splitlines() if ln.strip()]
|
|
41
|
+
if not lines:
|
|
42
|
+
raise PsrmcpError(ErrorCode.PROCESS_FAILED, "PINT worker 没有输出")
|
|
43
|
+
try:
|
|
44
|
+
resp = json.loads(lines[-1])
|
|
45
|
+
except json.JSONDecodeError as exc:
|
|
46
|
+
raise PsrmcpError(
|
|
47
|
+
ErrorCode.PROCESS_FAILED,
|
|
48
|
+
f"PINT worker 输出不是 JSON: {exc}",
|
|
49
|
+
details={"stdout_tail": text[-2000:]},
|
|
50
|
+
) from exc
|
|
51
|
+
if not resp.get("ok", False):
|
|
52
|
+
code_raw = str(resp.get("code", "E_PROCESS_FAILED"))
|
|
53
|
+
try:
|
|
54
|
+
code = ErrorCode(code_raw)
|
|
55
|
+
except ValueError:
|
|
56
|
+
code = ErrorCode.PROCESS_FAILED
|
|
57
|
+
raise PsrmcpError(code, str(resp.get("message", "PINT worker failed")))
|
|
58
|
+
return resp
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""psrcat adapter: query builders, row parser and ephemeris export.
|
|
2
|
+
|
|
3
|
+
Verified against psrcat 2.6.1 (real install, 2026-10-24): name selection is
|
|
4
|
+
positional (no -name flag); column keywords are raj/decj (ra/dec unknown);
|
|
5
|
+
`-o short` rows carry a leading row-number token; there is no -db flag (the
|
|
6
|
+
catalogue is located via the PSRCAT_FILE environment variable), no -binary
|
|
7
|
+
flag and no cone-search flags — cone and binary filtering happen in Python
|
|
8
|
+
(tools/catalog.py) because -boundary mishandles negative DEC.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..errors import invalid_input
|
|
17
|
+
|
|
18
|
+
# psrcat -c columns -> record fields (TOOLS.md section 2 CatalogRecord).
|
|
19
|
+
# raj/decj are the accepted keywords; ver/assoc(tel) do not exist. assoc is the
|
|
20
|
+
# catalogue association string and is surfaced under the observatory field
|
|
21
|
+
# (closest contract slot; ATNF exposes no per-row observatory).
|
|
22
|
+
# `binary` 列是真正的 BINARY 模型(T2/DD/...;孤立脉冲星为 "*");
|
|
23
|
+
# `bincomp` 是伴星成分描述符(如 "He[vbv+08]"),不能当模型名用。
|
|
24
|
+
PSRCAT_COLUMNS: list[str] = "name raj decj p0 f0 p1 dm binary assoc".split()
|
|
25
|
+
|
|
26
|
+
PSRCAT_FIELD: dict[str, str] = dict(
|
|
27
|
+
kv.split(":", 1)
|
|
28
|
+
for kv in (
|
|
29
|
+
"name:name raj:ra_str decj:dec_str p0:p0_s f0:f0_hz p1:p1 dm:dm "
|
|
30
|
+
"binary:binary_model assoc:observatory"
|
|
31
|
+
).split()
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def psrcat_base(binary: str) -> list[str]:
|
|
36
|
+
# No -db flag exists: psrcat locates the catalogue via $PSRCAT_FILE
|
|
37
|
+
# (callers set it in the subprocess environment).
|
|
38
|
+
return [binary, "-nohead", "-o", "short"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def catalog_get_cmd(binary: str, name: str, db_path: str | None = None) -> list[str]:
|
|
42
|
+
# Name selection is positional; -name is not a psrcat flag.
|
|
43
|
+
return psrcat_base(binary) + [name, "-c", " ".join(PSRCAT_COLUMNS)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def catalog_search_cmd(
|
|
47
|
+
binary: str,
|
|
48
|
+
*,
|
|
49
|
+
conditions: list[str],
|
|
50
|
+
columns: list[str] | None = None,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
# Cone and binary-only filtering are done in Python (no CLI flags; the
|
|
53
|
+
# -boundary alternative fails on negative DEC).
|
|
54
|
+
args = psrcat_base(binary)
|
|
55
|
+
if conditions:
|
|
56
|
+
args += ["-l", " && ".join(conditions)]
|
|
57
|
+
args += ["-c", " ".join(columns or PSRCAT_COLUMNS)]
|
|
58
|
+
return args
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def psrcat_ephem_cmd(binary: str, name: str) -> list[str]:
|
|
62
|
+
"""`psrcat -e`: complete TEMPO-format ephemeris (BINARY/PB/ECC/CLK/EPHEM...)."""
|
|
63
|
+
return [binary, name, "-e"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_psrcat_row(text: str) -> dict[str, Any]:
|
|
67
|
+
"""Parse one whitespace-separated psrcat data row into a record dict."""
|
|
68
|
+
tokens = text.split()
|
|
69
|
+
# -o short prepends a per-row line-number token even with -nohead.
|
|
70
|
+
if tokens and tokens[0].isdigit():
|
|
71
|
+
tokens = tokens[1:]
|
|
72
|
+
if len(tokens) < len(PSRCAT_COLUMNS):
|
|
73
|
+
raise ValueError(f"psrcat row has {len(tokens)} columns, expected {len(PSRCAT_COLUMNS)}")
|
|
74
|
+
# assoc(tel) may contain no spaces in -o short mode; if it does, the row is
|
|
75
|
+
# longer: associate tokens greedily from the right (ver is last).
|
|
76
|
+
if len(tokens) > len(PSRCAT_COLUMNS):
|
|
77
|
+
extra = len(tokens) - len(PSRCAT_COLUMNS)
|
|
78
|
+
observatory = " ".join(tokens[8 : 9 + extra])
|
|
79
|
+
tokens = tokens[:8] + [observatory] + tokens[9 + extra :]
|
|
80
|
+
record: dict[str, Any] = {}
|
|
81
|
+
for col, raw in zip(PSRCAT_COLUMNS, tokens):
|
|
82
|
+
field = PSRCAT_FIELD.get(col, col)
|
|
83
|
+
record[field] = _num(raw)
|
|
84
|
+
return record
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_PSRCAT_NA = {"*", "-", "INVALID"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _num(raw: str) -> Any:
|
|
91
|
+
if raw.strip() in _PSRCAT_NA:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return int(raw)
|
|
95
|
+
except ValueError:
|
|
96
|
+
pass
|
|
97
|
+
try:
|
|
98
|
+
return float(raw)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return raw
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Ephemeris export. 完整星历直接用 `psrcat -e`(实测含 BINARY/PB/ECC/
|
|
105
|
+
# CLK/EPHEM,PINT/tempo2 可直接加载);build_par_text 只服务无本地星表
|
|
106
|
+
# (psrqpy 在线)或带位置 override 的合成回退路径。
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
RA_HOURS_RE = re.compile(r"^(\d+):(\d+)(?::([\d.]+))?$") # h:m[:s] —— 星表里两种都出现
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def hms_to_deg(hms: str) -> float:
|
|
113
|
+
m = RA_HOURS_RE.match(hms.strip())
|
|
114
|
+
if not m:
|
|
115
|
+
raise invalid_input(f"无法解析 RA: {hms!r}")
|
|
116
|
+
h, mi = float(m.group(1)), float(m.group(2))
|
|
117
|
+
sec = float(m.group(3)) if m.group(3) else 0.0
|
|
118
|
+
return (h + mi / 60 + sec / 3600) * 15.0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def hms_to_deg_or_num(hms: str) -> float:
|
|
122
|
+
try:
|
|
123
|
+
return float(hms)
|
|
124
|
+
except ValueError:
|
|
125
|
+
return hms_to_deg(hms)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
DMS_RE = re.compile(r"^([+-]?\d+):(\d+(?:\.\d+)?)(?::([\d.]+))?$") # d:m[.frac][:s]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def dms_to_deg(dms: str) -> float:
|
|
132
|
+
"""Parse sign d:m[:s] (psrcat emits two- or three-part DEC); numeric passthrough."""
|
|
133
|
+
m = DMS_RE.match(dms.strip())
|
|
134
|
+
if not m:
|
|
135
|
+
try:
|
|
136
|
+
return float(dms)
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise invalid_input(f"无法解析 DEC: {dms!r}") from exc
|
|
139
|
+
sign = -1.0 if m.group(1).startswith("-") else 1.0
|
|
140
|
+
d = abs(float(m.group(1)))
|
|
141
|
+
mi = float(m.group(2))
|
|
142
|
+
sec = float(m.group(3)) if m.group(3) else 0.0
|
|
143
|
+
return sign * (d + mi / 60 + sec / 3600)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_par_text(
|
|
147
|
+
*,
|
|
148
|
+
name: str,
|
|
149
|
+
ra_deg: float | None,
|
|
150
|
+
dec_deg: float | None,
|
|
151
|
+
p0_s: float,
|
|
152
|
+
dm: float,
|
|
153
|
+
f0_hz: float | None = None,
|
|
154
|
+
p1: float | None = None,
|
|
155
|
+
peepoch_mjd: float | None = None,
|
|
156
|
+
posepoch_mjd: float | None = None,
|
|
157
|
+
binary_model: str | None = None,
|
|
158
|
+
tempo2: bool = False,
|
|
159
|
+
) -> str:
|
|
160
|
+
lines = [f"PSRJ {name}"]
|
|
161
|
+
if ra_deg is not None:
|
|
162
|
+
lines.append(f"RAJ {_deg_to_hms(ra_deg)}")
|
|
163
|
+
if dec_deg is not None:
|
|
164
|
+
lines.append(f"DECJ {_deg_to_dms(dec_deg)}")
|
|
165
|
+
lines.append(f"P0 {p0_s!r}")
|
|
166
|
+
if f0_hz is not None:
|
|
167
|
+
lines.append(f"F0 {f0_hz!r}")
|
|
168
|
+
if p1 is not None:
|
|
169
|
+
lines.append(f"P1 {p1!r}")
|
|
170
|
+
lines.append(f"DM {dm!r}")
|
|
171
|
+
lines.append("PEPOCH " + (f"{peepoch_mjd!r}" if peepoch_mjd is not None else "50000.0"))
|
|
172
|
+
lines.append("POSEPOCH " + (f"{posepoch_mjd!r}" if posepoch_mjd is not None else "50000.0"))
|
|
173
|
+
if binary_model:
|
|
174
|
+
lines.append(f"BINARY {binary_model}")
|
|
175
|
+
lines.append("CLK " + ("TT(TAI)" if tempo2 else "UTC(NIST)"))
|
|
176
|
+
lines.append("UNITS " + ("TDB" if tempo2 else "TCB"))
|
|
177
|
+
lines.append("TIMEEPH " + ("T2 Barycentric" if tempo2 else "FB90"))
|
|
178
|
+
return "\n".join(lines) + "\n"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _deg_to_hms(deg: float) -> str:
|
|
182
|
+
hours = deg / 15.0
|
|
183
|
+
h = int(hours)
|
|
184
|
+
m_full = (hours - h) * 60
|
|
185
|
+
mi = int(m_full)
|
|
186
|
+
s = (m_full - mi) * 60
|
|
187
|
+
return f"{h:02d}:{mi:02d}:{s:07.4f}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _deg_to_dms(deg: float) -> str:
|
|
191
|
+
sign = "-" if deg < 0 else "+"
|
|
192
|
+
a = abs(deg)
|
|
193
|
+
d = int(a)
|
|
194
|
+
m_full = (a - d) * 60
|
|
195
|
+
mi = int(m_full)
|
|
196
|
+
s = (m_full - mi) * 60
|
|
197
|
+
return f"{sign}{d:02d}:{mi:02d}:{s:07.4f}"
|