astronereus 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astronereus-0.2.1/.gitignore +7 -0
- astronereus-0.2.1/LICENSE +21 -0
- astronereus-0.2.1/PKG-INFO +93 -0
- astronereus-0.2.1/README.md +67 -0
- astronereus-0.2.1/pyproject.toml +48 -0
- astronereus-0.2.1/src/astronereus/__init__.py +42 -0
- astronereus-0.2.1/src/astronereus/_api.py +181 -0
- astronereus-0.2.1/src/astronereus/_daemon.py +197 -0
- astronereus-0.2.1/src/astronereus/_features.py +41 -0
- astronereus-0.2.1/src/astronereus/_fit.py +212 -0
- astronereus-0.2.1/src/astronereus/_result.py +189 -0
- astronereus-0.2.1/src/astronereus/_runtime.py +353 -0
- astronereus-0.2.1/src/astronereus/channels.py +154 -0
- astronereus-0.2.1/src/astronereus/engines.py +408 -0
- astronereus-0.2.1/src/astronereus/julia/daemon.jl +290 -0
- astronereus-0.2.1/src/astronereus/stopping.py +28 -0
- astronereus-0.2.1/tools/build_bundle.sh +165 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Vines
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astronereus
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Python frontend for Nereus.jl — Nested-Evidence Recovery of Exoplanets by Unified Sampling
|
|
5
|
+
Project-URL: Homepage, https://github.com/jvines/Nereus.jl
|
|
6
|
+
Project-URL: Repository, https://github.com/jvines/nereus-py
|
|
7
|
+
Project-URL: Issues, https://github.com/jvines/nereus-py/issues
|
|
8
|
+
Author-email: Jose Vines <jose.vines.l@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: astrometry,bayesian,exoplanets,gaia,mcmc,nested-sampling,radial-velocity
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: zstandard>=0.22; python_version < '3.14'
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: numpy>=1.24; extra == 'test'
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
23
|
+
Provides-Extra: zstd
|
|
24
|
+
Requires-Dist: zstandard>=0.22; extra == 'zstd'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# astronereus
|
|
28
|
+
|
|
29
|
+
Python frontend for [Nereus](https://github.com/jvines/Nereus.jl) — trans-dimensional
|
|
30
|
+
Bayesian inference for exoplanet orbits.
|
|
31
|
+
|
|
32
|
+
**You do not need Julia.** Nereus itself is Julia, but this package fetches a
|
|
33
|
+
prebuilt runtime on first use and runs it out-of-process in a warm daemon.
|
|
34
|
+
Nothing is compiled at install time.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install astronereus
|
|
38
|
+
python -c "import astronereus; astronereus.install()"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The distribution and the import are both `astronereus`. The name `nereus` on
|
|
42
|
+
PyPI belongs to an unrelated geophysics package, and a distribution whose import
|
|
43
|
+
name differs from its install name is a thing every user has to be told, so they
|
|
44
|
+
are deliberately the same string here.
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import astronereus
|
|
50
|
+
|
|
51
|
+
summary = astronereus.run_job(cfg) # one-shot
|
|
52
|
+
|
|
53
|
+
with astronereus.session() as s: # or reuse one warm daemon
|
|
54
|
+
a = s.run_job(cfg_a)
|
|
55
|
+
pg = s.detect.rv_periodogram(t=t, rv=rv, rv_err=err)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Typed entry points per observable — `fit_rv`, `fit_transit`, `fit_astrometry`,
|
|
59
|
+
`fit_rm`, `fit_tomography`, `fit_ttv`, `fit_binary`, `fit_joint` — take channel
|
|
60
|
+
objects (`RV`, `Transit`, `Astrometry`, `RM`, `Night`, `TTV`, `SB2`) and return
|
|
61
|
+
a `JobResult`.
|
|
62
|
+
|
|
63
|
+
The first `install()` unpacks the runtime and warms it once (a few minutes);
|
|
64
|
+
every process after that starts in about twenty seconds. `NEREUS_BUNDLE_URL`
|
|
65
|
+
points the installer at a local file or `file://` URL — that is the intended
|
|
66
|
+
path for a workshop room, where thirty people pulling half a gigabyte over
|
|
67
|
+
conference wifi is not a plan.
|
|
68
|
+
|
|
69
|
+
## Runtime bundles
|
|
70
|
+
|
|
71
|
+
Prebuilt runtimes are attached to the
|
|
72
|
+
[Nereus.jl releases](https://github.com/jvines/Nereus.jl/releases), with
|
|
73
|
+
`SHA256SUMS` alongside:
|
|
74
|
+
|
|
75
|
+
| platform | size |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `macos-arm64` | 465 MiB |
|
|
78
|
+
| `linux-x86_64` | 688 MiB |
|
|
79
|
+
|
|
80
|
+
`install()` picks the one for your platform and verifies it against the
|
|
81
|
+
published checksum. For a classroom, fetch it once and point everyone at the
|
|
82
|
+
local copy instead:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
export NEREUS_BUNDLE_URL=/Volumes/NEREUS/nereus-runtime-1.11.9-macos-arm64.tar.zst
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`NEREUS_HOME` relocates the runtime cache out of the default platform cache
|
|
89
|
+
directory.
|
|
90
|
+
|
|
91
|
+
## Licence
|
|
92
|
+
|
|
93
|
+
MIT — see `LICENSE`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# astronereus
|
|
2
|
+
|
|
3
|
+
Python frontend for [Nereus](https://github.com/jvines/Nereus.jl) — trans-dimensional
|
|
4
|
+
Bayesian inference for exoplanet orbits.
|
|
5
|
+
|
|
6
|
+
**You do not need Julia.** Nereus itself is Julia, but this package fetches a
|
|
7
|
+
prebuilt runtime on first use and runs it out-of-process in a warm daemon.
|
|
8
|
+
Nothing is compiled at install time.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install astronereus
|
|
12
|
+
python -c "import astronereus; astronereus.install()"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The distribution and the import are both `astronereus`. The name `nereus` on
|
|
16
|
+
PyPI belongs to an unrelated geophysics package, and a distribution whose import
|
|
17
|
+
name differs from its install name is a thing every user has to be told, so they
|
|
18
|
+
are deliberately the same string here.
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import astronereus
|
|
24
|
+
|
|
25
|
+
summary = astronereus.run_job(cfg) # one-shot
|
|
26
|
+
|
|
27
|
+
with astronereus.session() as s: # or reuse one warm daemon
|
|
28
|
+
a = s.run_job(cfg_a)
|
|
29
|
+
pg = s.detect.rv_periodogram(t=t, rv=rv, rv_err=err)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Typed entry points per observable — `fit_rv`, `fit_transit`, `fit_astrometry`,
|
|
33
|
+
`fit_rm`, `fit_tomography`, `fit_ttv`, `fit_binary`, `fit_joint` — take channel
|
|
34
|
+
objects (`RV`, `Transit`, `Astrometry`, `RM`, `Night`, `TTV`, `SB2`) and return
|
|
35
|
+
a `JobResult`.
|
|
36
|
+
|
|
37
|
+
The first `install()` unpacks the runtime and warms it once (a few minutes);
|
|
38
|
+
every process after that starts in about twenty seconds. `NEREUS_BUNDLE_URL`
|
|
39
|
+
points the installer at a local file or `file://` URL — that is the intended
|
|
40
|
+
path for a workshop room, where thirty people pulling half a gigabyte over
|
|
41
|
+
conference wifi is not a plan.
|
|
42
|
+
|
|
43
|
+
## Runtime bundles
|
|
44
|
+
|
|
45
|
+
Prebuilt runtimes are attached to the
|
|
46
|
+
[Nereus.jl releases](https://github.com/jvines/Nereus.jl/releases), with
|
|
47
|
+
`SHA256SUMS` alongside:
|
|
48
|
+
|
|
49
|
+
| platform | size |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `macos-arm64` | 465 MiB |
|
|
52
|
+
| `linux-x86_64` | 688 MiB |
|
|
53
|
+
|
|
54
|
+
`install()` picks the one for your platform and verifies it against the
|
|
55
|
+
published checksum. For a classroom, fetch it once and point everyone at the
|
|
56
|
+
local copy instead:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
export NEREUS_BUNDLE_URL=/Volumes/NEREUS/nereus-runtime-1.11.9-macos-arm64.tar.zst
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`NEREUS_HOME` relocates the runtime cache out of the default platform cache
|
|
63
|
+
directory.
|
|
64
|
+
|
|
65
|
+
## Licence
|
|
66
|
+
|
|
67
|
+
MIT — see `LICENSE`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# Hatchling >=1.28 emits Metadata-Version 2.5, which twine 6.2 rejects as
|
|
3
|
+
# invalid and which older installers do not understand. 2.4 is the newest
|
|
4
|
+
# version the whole toolchain agrees on.
|
|
5
|
+
requires = ["hatchling<1.28"]
|
|
6
|
+
build-backend = "hatchling.build"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "astronereus"
|
|
10
|
+
version = "0.2.1"
|
|
11
|
+
description = "Python frontend for Nereus.jl — Nested-Evidence Recovery of Exoplanets by Unified Sampling"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["zstandard>=0.22; python_version < '3.14'"]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
authors = [{name = "Jose Vines", email = "jose.vines.l@gmail.com"}]
|
|
17
|
+
keywords = ["exoplanets", "radial-velocity", "astrometry", "gaia", "mcmc",
|
|
18
|
+
"nested-sampling", "bayesian"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Science/Research",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Astronomy",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Operating System :: MacOS",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/jvines/Nereus.jl"
|
|
30
|
+
Repository = "https://github.com/jvines/nereus-py"
|
|
31
|
+
Issues = "https://github.com/jvines/nereus-py/issues"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
zstd = ["zstandard>=0.22"]
|
|
35
|
+
test = ["pytest>=7", "numpy>=1.24"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/astronereus"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
41
|
+
"src/astronereus/julia/daemon.jl" = "astronereus/julia/daemon.jl"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
# Explicit allow-list. The sdist once shipped DEPLOYMENT.md -- internal
|
|
45
|
+
# topology and fleet hostnames -- and a PyPI upload cannot be undone. That
|
|
46
|
+
# file now lives outside the repo entirely, but the allow-list stays: it is
|
|
47
|
+
# the cheaper guarantee.
|
|
48
|
+
include = ["src", "tools", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Nereus — Nested-Evidence Recovery of Exoplanets by Unified Sampling.
|
|
2
|
+
|
|
3
|
+
Python frontend. Julia runs out-of-process in a warm daemon; you never need
|
|
4
|
+
Julia installed and nothing is compiled at install time.
|
|
5
|
+
|
|
6
|
+
import astronereus
|
|
7
|
+
astronereus.install(url=...) # once: fetch the runtime bundle
|
|
8
|
+
|
|
9
|
+
summary = nereus.run_job(cfg) # one-shot
|
|
10
|
+
|
|
11
|
+
with astronereus.session() as s: # or reuse one warm daemon
|
|
12
|
+
a = s.run_job(cfg_a)
|
|
13
|
+
pg = s.detect.rv_periodogram(t=t, rv=rv, rv_err=err)
|
|
14
|
+
"""
|
|
15
|
+
from ._runtime import (JULIA_VERSION, CPU_TARGETS, BundleError, install,
|
|
16
|
+
find_julia, julia_home,
|
|
17
|
+
is_installed, platform_tag, runtime_dir, cache_root,
|
|
18
|
+
julia_env)
|
|
19
|
+
from ._daemon import JuliaDaemon, DaemonError
|
|
20
|
+
from ._api import Session, session, run_job, ping
|
|
21
|
+
from ._fit import (fit_rv, fit_transit, fit_astrometry, fit_rm,
|
|
22
|
+
fit_tomography, fit_ttv, fit_binary, fit_joint)
|
|
23
|
+
from .channels import RV, Transit, Astrometry, RM, Night, TTV, SB2
|
|
24
|
+
from .stopping import Stopping
|
|
25
|
+
from . import engines, channels
|
|
26
|
+
from ._result import JobResult, JobFailed, Figures
|
|
27
|
+
|
|
28
|
+
__all__ = ["JULIA_VERSION", "CPU_TARGETS", "BundleError", "DaemonError",
|
|
29
|
+
"JuliaDaemon", "Session", "install", "is_installed", "platform_tag",
|
|
30
|
+
"runtime_dir", "cache_root", "julia_env", "find_julia", "julia_home",
|
|
31
|
+
"daemon", "session", "run_job", "ping",
|
|
32
|
+
"JobResult", "JobFailed", "Figures",
|
|
33
|
+
"fit_rv", "fit_transit", "fit_astrometry", "fit_rm",
|
|
34
|
+
"fit_tomography", "fit_ttv", "fit_binary", "fit_joint",
|
|
35
|
+
"RV", "Transit", "Astrometry", "RM", "Night", "TTV", "SB2",
|
|
36
|
+
"Stopping", "engines", "channels"]
|
|
37
|
+
__version__ = "0.2.1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def daemon(**kw) -> JuliaDaemon:
|
|
41
|
+
"""A warm Julia daemon. Use as a context manager."""
|
|
42
|
+
return JuliaDaemon(**kw)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""The user-facing API.
|
|
2
|
+
|
|
3
|
+
`JuliaDaemon.call("run_job", cfg)` is transport plumbing and should never
|
|
4
|
+
appear in user code: it is stringly-typed, gives no completion, no signature,
|
|
5
|
+
no docstring, and any typo becomes a runtime error from Julia. This module is
|
|
6
|
+
the typed facade over it.
|
|
7
|
+
|
|
8
|
+
Three levels, in increasing order of control:
|
|
9
|
+
|
|
10
|
+
import astronereus
|
|
11
|
+
summary = nereus.run_job(cfg) # 1. just do the thing
|
|
12
|
+
|
|
13
|
+
with astronereus.session() as s: # 2. reuse one warm daemon
|
|
14
|
+
a = s.run_job(cfg_a)
|
|
15
|
+
b = s.run_job(cfg_b)
|
|
16
|
+
pg = s.detect.rv_periodogram(t, rv, err)
|
|
17
|
+
|
|
18
|
+
with astronereus.session() as s: # 3. escape hatch
|
|
19
|
+
s.raw("some.new.action", payload)
|
|
20
|
+
|
|
21
|
+
Level 1 lazily starts a process-wide daemon on first use and reuses it, so the
|
|
22
|
+
~20 s `using Nereus` is paid once per interpreter, not per call.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import atexit
|
|
28
|
+
import threading
|
|
29
|
+
from typing import Any, Sequence
|
|
30
|
+
|
|
31
|
+
from ._daemon import JuliaDaemon
|
|
32
|
+
from ._result import JobResult, JobFailed
|
|
33
|
+
from . import _features as _F
|
|
34
|
+
|
|
35
|
+
_shared: "Session | None" = None
|
|
36
|
+
_shared_lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Namespace:
|
|
40
|
+
"""Groups related actions, e.g. ``s.detect.transits`` -> "detection.transits"."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, session: "Session", prefix: str, actions: dict[str, str]):
|
|
43
|
+
self._s, self._prefix, self._actions = session, prefix, actions
|
|
44
|
+
|
|
45
|
+
def __dir__(self):
|
|
46
|
+
return sorted(self._actions)
|
|
47
|
+
|
|
48
|
+
def __getattr__(self, name: str):
|
|
49
|
+
if name not in self._actions:
|
|
50
|
+
raise AttributeError(
|
|
51
|
+
f"{self._prefix}.{name} is not a Nereus action. "
|
|
52
|
+
f"Available: {', '.join(sorted(self._actions))}")
|
|
53
|
+
action = self._actions[name]
|
|
54
|
+
|
|
55
|
+
def _call(**payload):
|
|
56
|
+
return self._s.raw(action, payload)
|
|
57
|
+
|
|
58
|
+
_call.__name__ = name
|
|
59
|
+
_call.__doc__ = f"Nereus compute action {action!r}."
|
|
60
|
+
return _call
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Session:
|
|
65
|
+
"""A warm Julia session. Use as a context manager."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, **daemon_kw):
|
|
68
|
+
self._daemon = JuliaDaemon(**daemon_kw)
|
|
69
|
+
g = lambda: self
|
|
70
|
+
self.detect = _F._Group(g, "detect", _F.DETECT)
|
|
71
|
+
self.detrend = _F._Group(g, "detrend", _F.DETREND)
|
|
72
|
+
self.tomogram = _F._Group(g, "tomogram", _F.TOMOGRAM)
|
|
73
|
+
self.diagnostics = _F._Group(g, "diagnostics", _F.DIAGNOSTICS)
|
|
74
|
+
|
|
75
|
+
# -- lifecycle ---------------------------------------------------------
|
|
76
|
+
def start(self) -> "Session":
|
|
77
|
+
self._daemon.start()
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
self._daemon.stop()
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> "Session":
|
|
84
|
+
return self.start()
|
|
85
|
+
|
|
86
|
+
def __exit__(self, *exc) -> None:
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
# -- the actual API ----------------------------------------------------
|
|
90
|
+
def run_job(self, config: dict[str, Any] | str, *,
|
|
91
|
+
check: bool = True,
|
|
92
|
+
timeout: float | None = None) -> JobResult:
|
|
93
|
+
"""Run a full Nereus job and return its summary.
|
|
94
|
+
|
|
95
|
+
`config` is a JOB_CONFIG dict (or a path to one). Nereus writes
|
|
96
|
+
`summary.json`, `chains.nc` and the plot tree into `config["output_dir"]`
|
|
97
|
+
and returns the summary, which includes a `figures` manifest mapping
|
|
98
|
+
logical names to paths.
|
|
99
|
+
"""
|
|
100
|
+
# Validate client-side: a missing output_dir otherwise surfaces as an
|
|
101
|
+
# opaque Julia error several minutes into a run.
|
|
102
|
+
if isinstance(config, dict) and not config.get("output_dir"):
|
|
103
|
+
raise ValueError("job config needs an 'output_dir' — Nereus writes "
|
|
104
|
+
"summary.json, chains.nc and the plot tree there")
|
|
105
|
+
res = JobResult(self.raw("run_job", config, timeout=timeout))
|
|
106
|
+
return res.check() if check else res
|
|
107
|
+
|
|
108
|
+
def ping(self) -> dict[str, Any]:
|
|
109
|
+
"""Liveness + which Julia/CPU/thread count the daemon actually has."""
|
|
110
|
+
return self.raw("ping", {})
|
|
111
|
+
|
|
112
|
+
def raw(self, action: str, payload: Any = None, *,
|
|
113
|
+
timeout: float | None = None) -> Any:
|
|
114
|
+
"""Escape hatch: call an action by name.
|
|
115
|
+
|
|
116
|
+
Only for actions newer than this client. If you find yourself using it
|
|
117
|
+
routinely, the action belongs in the typed API above.
|
|
118
|
+
"""
|
|
119
|
+
return self._daemon.call(action, payload, timeout=timeout)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def log_path(self):
|
|
123
|
+
"""Where the daemon's stdout/stderr goes — read this when a job dies."""
|
|
124
|
+
return self._daemon.log_path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def session(**daemon_kw) -> Session:
|
|
128
|
+
"""A new warm Julia session (not the process-wide shared one)."""
|
|
129
|
+
return Session(**daemon_kw)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _shared_session(**kw) -> Session:
|
|
133
|
+
global _shared
|
|
134
|
+
with _shared_lock:
|
|
135
|
+
if _shared is None:
|
|
136
|
+
_shared = Session(**kw).start()
|
|
137
|
+
atexit.register(_shared.close)
|
|
138
|
+
return _shared
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_job(config: dict[str, Any] | str, *, check: bool = True,
|
|
142
|
+
timeout: float | None = None, **session_kw) -> JobResult:
|
|
143
|
+
"""Run a job on a lazily-started, process-wide daemon.
|
|
144
|
+
|
|
145
|
+
Convenience for one-shot use. Prefer `with astronereus.session() as s:` when
|
|
146
|
+
making several calls, so the lifetime is explicit.
|
|
147
|
+
"""
|
|
148
|
+
return _shared_session(**session_kw).run_job(config, check=check, timeout=timeout)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def ping(**session_kw) -> dict[str, Any]:
|
|
152
|
+
return _shared_session(**session_kw).ping()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- per-technique entry points, bound to a session -------------------------
|
|
156
|
+
def _bind(name):
|
|
157
|
+
from . import _fit
|
|
158
|
+
fn = getattr(_fit, name)
|
|
159
|
+
def method(self, *a, **kw):
|
|
160
|
+
kw.setdefault("session", self)
|
|
161
|
+
return fn(*a, **kw)
|
|
162
|
+
method.__name__ = name
|
|
163
|
+
method.__doc__ = fn.__doc__
|
|
164
|
+
return method
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
for _n in ("fit_rv", "fit_transit", "fit_astrometry", "fit_rm",
|
|
168
|
+
"fit_tomography", "fit_ttv", "fit_binary", "fit_joint"):
|
|
169
|
+
setattr(Session, _n, _bind(_n))
|
|
170
|
+
|
|
171
|
+
# post-hoc operations that act on a finished run
|
|
172
|
+
for _n, _act in (("evidence", "evidence"),
|
|
173
|
+
("detection_limits", "detection_limits"),
|
|
174
|
+
("select_planets", "select_planets"),
|
|
175
|
+
("select_noise", "select_noise"),
|
|
176
|
+
("pre_white", "pre_white")):
|
|
177
|
+
def _mk(action):
|
|
178
|
+
def m(self, **payload):
|
|
179
|
+
return self.raw(action, payload)
|
|
180
|
+
return m
|
|
181
|
+
setattr(Session, _n, _mk(_act))
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Supervise a warm Julia daemon and talk to it over a unix socket.
|
|
2
|
+
|
|
3
|
+
Deliberately NOT juliacall: embedding deadlocks under Julia's `@threads`, which
|
|
4
|
+
every sampler uses. A separate process also means a crashing job cannot take the
|
|
5
|
+
Python interpreter with it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import struct
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import _runtime
|
|
22
|
+
|
|
23
|
+
_HDR = struct.Struct(">Q")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _enc(o):
|
|
27
|
+
"""JSON fallback for the types a scientific payload actually carries.
|
|
28
|
+
|
|
29
|
+
Channels are built with `dataclasses.asdict`, which leaves numpy arrays
|
|
30
|
+
untouched, so every realistic call ships ndarrays. Non-finite floats pass
|
|
31
|
+
through as NaN/Infinity — `daemon.jl` reads with `allow_inf = true`.
|
|
32
|
+
"""
|
|
33
|
+
if hasattr(o, "tolist"): # ndarray, numpy scalar
|
|
34
|
+
return o.tolist()
|
|
35
|
+
if hasattr(o, "item"): # any remaining 0-d numpy scalar
|
|
36
|
+
return o.item()
|
|
37
|
+
if isinstance(o, (set, frozenset)):
|
|
38
|
+
return sorted(o)
|
|
39
|
+
raise TypeError(f"cannot send {type(o).__name__} to Julia")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DaemonError(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JuliaDaemon:
|
|
47
|
+
def __init__(self, version: str = _runtime.JULIA_VERSION,
|
|
48
|
+
project: str | os.PathLike | None = None,
|
|
49
|
+
threads: str | int = "auto",
|
|
50
|
+
preload: str | None = "Nereus",
|
|
51
|
+
api_file: str | list[str] | None = None,
|
|
52
|
+
startup_timeout: float = 300.0,
|
|
53
|
+
idle_timeout: float = 1800.0):
|
|
54
|
+
self.version = version
|
|
55
|
+
self.project = str(project) if project else None
|
|
56
|
+
self.threads = threads
|
|
57
|
+
self.preload = preload
|
|
58
|
+
# Extra Julia file to include after `using` — the public API surface.
|
|
59
|
+
# In the shipped package this lives inside Nereus itself; during
|
|
60
|
+
# bring-up it is loaded from a path so it can be iterated on.
|
|
61
|
+
self.api_file = api_file
|
|
62
|
+
self.startup_timeout = startup_timeout
|
|
63
|
+
# The daemon exits on its own if we die abnormally (atexit cannot run
|
|
64
|
+
# on SIGKILL/OOM) or if nothing talks to it for this long. Verified:
|
|
65
|
+
# without this a -9'd parent leaves a ~530 MB Julia process behind.
|
|
66
|
+
self.idle_timeout = idle_timeout
|
|
67
|
+
self._proc: subprocess.Popen | None = None
|
|
68
|
+
self._sock: socket.socket | None = None
|
|
69
|
+
self._lock = threading.Lock()
|
|
70
|
+
self._tmp = Path(tempfile.mkdtemp(prefix="nereus-daemon-"))
|
|
71
|
+
self.sock_path = self._tmp / "daemon.sock"
|
|
72
|
+
self.log_path = self._tmp / "daemon.log"
|
|
73
|
+
|
|
74
|
+
# -- lifecycle ---------------------------------------------------------
|
|
75
|
+
def start(self) -> "JuliaDaemon":
|
|
76
|
+
if self._proc is not None:
|
|
77
|
+
return self
|
|
78
|
+
julia, env = _runtime.julia_env(self.version)
|
|
79
|
+
entry = Path(__file__).parent / "julia" / "daemon.jl"
|
|
80
|
+
|
|
81
|
+
cmd = [str(julia), f"-t{self.threads}", "--startup-file=no"]
|
|
82
|
+
if self.project:
|
|
83
|
+
cmd.append(f"--project={self.project}")
|
|
84
|
+
# Preload inside the daemon so the ~20 s `using` cost is paid once, at
|
|
85
|
+
# boot, rather than on the first job.
|
|
86
|
+
ppid = os.getpid()
|
|
87
|
+
if self.preload:
|
|
88
|
+
_files = ([self.api_file] if isinstance(self.api_file, str)
|
|
89
|
+
else list(self.api_file or []))
|
|
90
|
+
api_inc = "".join(f'include("{p}"); ' for p in _files)
|
|
91
|
+
cmd += ["-e", f"@eval using {self.preload}; "
|
|
92
|
+
f"{api_inc}"
|
|
93
|
+
f"include(\"{entry}\"); "
|
|
94
|
+
f"serve(ARGS[1]; parent_pid=parse(Int,ARGS[2]), "
|
|
95
|
+
f"idle=parse(Float64,ARGS[3]))",
|
|
96
|
+
str(self.sock_path), str(ppid), str(self.idle_timeout)]
|
|
97
|
+
else:
|
|
98
|
+
cmd += [str(entry), str(self.sock_path), str(ppid),
|
|
99
|
+
str(self.idle_timeout)]
|
|
100
|
+
|
|
101
|
+
log = self.log_path.open("wb")
|
|
102
|
+
self._proc = subprocess.Popen(cmd, env=env, stdout=log, stderr=log)
|
|
103
|
+
atexit.register(self.stop)
|
|
104
|
+
|
|
105
|
+
deadline = time.time() + self.startup_timeout
|
|
106
|
+
while time.time() < deadline:
|
|
107
|
+
if self._proc.poll() is not None:
|
|
108
|
+
raise DaemonError(
|
|
109
|
+
f"daemon exited with {self._proc.returncode}\n"
|
|
110
|
+
f"--- log ---\n{self.log_path.read_text(errors='replace')[-4000:]}")
|
|
111
|
+
if self.sock_path.exists():
|
|
112
|
+
try:
|
|
113
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
114
|
+
s.connect(str(self.sock_path))
|
|
115
|
+
self._sock = s
|
|
116
|
+
return self
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
time.sleep(0.1)
|
|
120
|
+
self.stop()
|
|
121
|
+
raise DaemonError(
|
|
122
|
+
f"daemon did not become ready within {self.startup_timeout}s\n"
|
|
123
|
+
f"--- log ---\n{self.log_path.read_text(errors='replace')[-4000:]}")
|
|
124
|
+
|
|
125
|
+
def stop(self) -> None:
|
|
126
|
+
if self._sock is not None:
|
|
127
|
+
try:
|
|
128
|
+
self._send({"action": "shutdown"})
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
try:
|
|
132
|
+
self._sock.close()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
self._sock = None
|
|
136
|
+
if self._proc is not None:
|
|
137
|
+
try:
|
|
138
|
+
self._proc.wait(timeout=10)
|
|
139
|
+
except Exception:
|
|
140
|
+
self._proc.kill()
|
|
141
|
+
self._proc = None
|
|
142
|
+
|
|
143
|
+
# -- transport ---------------------------------------------------------
|
|
144
|
+
def _send(self, obj) -> None:
|
|
145
|
+
body = json.dumps(obj, default=_enc).encode()
|
|
146
|
+
self._sock.sendall(_HDR.pack(len(body)) + body)
|
|
147
|
+
|
|
148
|
+
def _recvn(self, n: int) -> bytes:
|
|
149
|
+
buf = bytearray()
|
|
150
|
+
while len(buf) < n:
|
|
151
|
+
chunk = self._sock.recv(n - len(buf))
|
|
152
|
+
if not chunk:
|
|
153
|
+
raise DaemonError("daemon closed the connection")
|
|
154
|
+
buf += chunk
|
|
155
|
+
return bytes(buf)
|
|
156
|
+
|
|
157
|
+
def call(self, action: str, payload=None, timeout: float | None = None):
|
|
158
|
+
with self._lock:
|
|
159
|
+
# start() INSIDE the lock: two threads that both saw _sock is None
|
|
160
|
+
# would otherwise each spawn a Julia process.
|
|
161
|
+
if self._sock is None:
|
|
162
|
+
self.start()
|
|
163
|
+
try:
|
|
164
|
+
self._sock.settimeout(timeout)
|
|
165
|
+
self._send({"action": action, "payload": payload or {}})
|
|
166
|
+
(n,) = _HDR.unpack(self._recvn(8))
|
|
167
|
+
resp = json.loads(self._recvn(n))
|
|
168
|
+
except Exception:
|
|
169
|
+
# Framing is length-prefixed, so a half-read response leaves
|
|
170
|
+
# the stream desynchronised — the next call would read the
|
|
171
|
+
# tail of this one as an 8-byte header. There is no
|
|
172
|
+
# resynchronisation point, so drop the connection; the next
|
|
173
|
+
# call reconnects.
|
|
174
|
+
#
|
|
175
|
+
# NOTE: a timeout here does NOT stop the Julia job. It keeps
|
|
176
|
+
# running in the daemon. Call stop() if you need it dead.
|
|
177
|
+
self._drop()
|
|
178
|
+
raise
|
|
179
|
+
if not resp.get("ok"):
|
|
180
|
+
raise DaemonError(resp.get("error", "unknown error") + "\n"
|
|
181
|
+
+ resp.get("backtrace", ""))
|
|
182
|
+
return resp.get("result")
|
|
183
|
+
|
|
184
|
+
def _drop(self) -> None:
|
|
185
|
+
"""Close the socket and forget it, so the next call reconnects."""
|
|
186
|
+
if self._sock is not None:
|
|
187
|
+
try:
|
|
188
|
+
self._sock.close()
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
self._sock = None
|
|
192
|
+
|
|
193
|
+
def __enter__(self):
|
|
194
|
+
return self.start()
|
|
195
|
+
|
|
196
|
+
def __exit__(self, *exc):
|
|
197
|
+
self.stop()
|