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 ADDED
@@ -0,0 +1,5 @@
1
+ """MCP server wrapping pulsar astronomy software (dspsr, psrchive, tempo2, psrcat, PINT)."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ SERVER_NAME = "psrmcp"
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
+ )
@@ -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))
@@ -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
@@ -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}"