rvcadence 0.1.0__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.
- rvcadence-0.1.0/.github/workflows/publish.yml +23 -0
- rvcadence-0.1.0/.github/workflows/tests.yml +18 -0
- rvcadence-0.1.0/.gitignore +8 -0
- rvcadence-0.1.0/CITATION.cff +10 -0
- rvcadence-0.1.0/LICENSE +21 -0
- rvcadence-0.1.0/PKG-INFO +131 -0
- rvcadence-0.1.0/README.md +110 -0
- rvcadence-0.1.0/docs/cadence_greedy_explainer.gif +0 -0
- rvcadence-0.1.0/examples/explainer/CadenceGreedyExplainer.mp4 +0 -0
- rvcadence-0.1.0/examples/explainer/cadence_greedy_explainer.py +358 -0
- rvcadence-0.1.0/pyproject.toml +28 -0
- rvcadence-0.1.0/src/rvcadence/__init__.py +30 -0
- rvcadence-0.1.0/src/rvcadence/_phase.py +21 -0
- rvcadence-0.1.0/src/rvcadence/moon.py +58 -0
- rvcadence-0.1.0/src/rvcadence/py.typed +0 -0
- rvcadence-0.1.0/src/rvcadence/schedule.py +196 -0
- rvcadence-0.1.0/src/rvcadence/windows.py +84 -0
- rvcadence-0.1.0/tests/test_explainer_example.py +26 -0
- rvcadence-0.1.0/tests/test_moon.py +56 -0
- rvcadence-0.1.0/tests/test_phase.py +27 -0
- rvcadence-0.1.0/tests/test_public_api.py +18 -0
- rvcadence-0.1.0/tests/test_schedule.py +229 -0
- rvcadence-0.1.0/tests/test_windows.py +56 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build-and-publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.11"
|
|
18
|
+
- name: Build sdist and wheel
|
|
19
|
+
run: |
|
|
20
|
+
pip install build
|
|
21
|
+
python -m build
|
|
22
|
+
- name: Publish to PyPI
|
|
23
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.11"
|
|
15
|
+
- name: Install core + dev + moon extras
|
|
16
|
+
run: pip install -e ".[dev,moon]"
|
|
17
|
+
- name: Run tests
|
|
18
|
+
run: pytest -v
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
title: "rvcadence"
|
|
4
|
+
authors:
|
|
5
|
+
- family-names: "Lamontagne"
|
|
6
|
+
given-names: "Pierrot"
|
|
7
|
+
version: 0.1.0
|
|
8
|
+
date-released: 2026-07-09
|
|
9
|
+
license: MIT
|
|
10
|
+
repository-code: "https://github.com/pierrotlamontagne/rvcadence"
|
rvcadence-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pierrot Lamontagne
|
|
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.
|
rvcadence-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rvcadence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Greedy observing-calendar scheduler for radial-velocity follow-up campaigns
|
|
5
|
+
Author-email: Pierrot Lamontagne <pierrotlamontagne31415@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: astronomy,exoplanets,observation-scheduling,radial-velocity
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: examples
|
|
17
|
+
Requires-Dist: manim>=0.18; extra == 'examples'
|
|
18
|
+
Provides-Extra: moon
|
|
19
|
+
Requires-Dist: astropy>=5.0; extra == 'moon'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# rvcadence
|
|
23
|
+
|
|
24
|
+
Greedy observing-calendar scheduler for radial-velocity follow-up campaigns.
|
|
25
|
+
|
|
26
|
+
Given a number of observations to schedule, one or more planets' orbital
|
|
27
|
+
periods (and, optionally, the star's rotation period), and a set of
|
|
28
|
+
visibility windows, `rvcadence` picks the calendar dates that best cover all
|
|
29
|
+
phase cycles while keeping observations temporally spread out. Optionally
|
|
30
|
+
excludes nights where the Moon is too close to the target for
|
|
31
|
+
high-resolution spectroscopy.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
Full-quality version: [`examples/explainer/CadenceGreedyExplainer.mp4`](examples/explainer/CadenceGreedyExplainer.mp4)
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install rvcadence # core scheduler, zero dependencies
|
|
41
|
+
pip install rvcadence[moon] # + lunar pollution avoidance (astropy)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from datetime import date
|
|
48
|
+
from rvcadence import plan_calendar
|
|
49
|
+
|
|
50
|
+
result = plan_calendar(
|
|
51
|
+
n_obs=20,
|
|
52
|
+
periods_d=9.53,
|
|
53
|
+
season_start=date(2026, 5, 1),
|
|
54
|
+
season_end=date(2027, 4, 30),
|
|
55
|
+
rotation_period_d=12.45,
|
|
56
|
+
windows="2026-05-01 to 2026-05-19; 2026-07-29 to 2027-04-30",
|
|
57
|
+
)
|
|
58
|
+
print(result.dates)
|
|
59
|
+
print(f"median gap: {result.median_gap_d} d, mean gap: {result.mean_gap_d} d")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For a multi-planet system, pass a list of periods — coverage is optimized
|
|
63
|
+
for the *worst-covered* planet at each step, not an average:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
result = plan_calendar(
|
|
67
|
+
n_obs=20,
|
|
68
|
+
periods_d=[9.53, 21.7], # two known/candidate planets
|
|
69
|
+
season_start=date(2026, 5, 1),
|
|
70
|
+
season_end=date(2027, 4, 30),
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
With lunar avoidance (requires `pip install rvcadence[moon]`):
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import astropy.units as u
|
|
78
|
+
from astropy.coordinates import EarthLocation, SkyCoord
|
|
79
|
+
|
|
80
|
+
paranal = EarthLocation(lat=-24.6272 * u.deg, lon=-70.4039 * u.deg, height=2635 * u.m)
|
|
81
|
+
target = SkyCoord(ra=123.45 * u.deg, dec=-12.3 * u.deg)
|
|
82
|
+
|
|
83
|
+
result = plan_calendar(
|
|
84
|
+
n_obs=20,
|
|
85
|
+
periods_d=9.53,
|
|
86
|
+
season_start=date(2026, 5, 1),
|
|
87
|
+
season_end=date(2027, 4, 30),
|
|
88
|
+
target_coord=target,
|
|
89
|
+
observer_location=paranal,
|
|
90
|
+
min_moon_sep_deg=30.0,
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The default 30° threshold is a widely-used rule-of-thumb avoidance radius
|
|
95
|
+
against lunar scattered-light contamination in high-resolution spectroscopy;
|
|
96
|
+
override `min_moon_sep_deg` for a different instrument or tolerance. Nights
|
|
97
|
+
are evaluated at local solar midnight, sunset-labeled (the night of date `D`
|
|
98
|
+
runs from sunset on `D` to sunrise on `D+1`).
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
Starting from the first and last available dates, the algorithm repeatedly
|
|
103
|
+
adds the candidate date that best fills gaps in planet-orbital-phase coverage
|
|
104
|
+
(and stellar-rotation-phase coverage, if known), weighted against how far it
|
|
105
|
+
is in time from already-selected dates. For multiple planets, phase coverage
|
|
106
|
+
is the *worst-case* across all periods — a candidate only scores well if it
|
|
107
|
+
improves coverage for whichever planet is currently least-covered, so one
|
|
108
|
+
well-phased planet can't mask a poorly-phased one:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
score = 0.55 · d_planet_phase + 0.30 · d_rotation_phase + 0.15 · d_time_spread (rotation period known)
|
|
112
|
+
score = 0.80 · d_planet_phase + 0.20 · d_time_spread (rotation period unknown)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
See `examples/explainer/` for the full animated walkthrough (manim source +
|
|
116
|
+
rendered mp4) — the example imports its scoring logic directly from this
|
|
117
|
+
package (see `tests/test_explainer_example.py`), so it can't drift out of
|
|
118
|
+
sync with the real algorithm.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
git clone <repo-url>
|
|
124
|
+
cd rvcadence
|
|
125
|
+
pip install -e ".[dev,moon]"
|
|
126
|
+
pytest
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# rvcadence
|
|
2
|
+
|
|
3
|
+
Greedy observing-calendar scheduler for radial-velocity follow-up campaigns.
|
|
4
|
+
|
|
5
|
+
Given a number of observations to schedule, one or more planets' orbital
|
|
6
|
+
periods (and, optionally, the star's rotation period), and a set of
|
|
7
|
+
visibility windows, `rvcadence` picks the calendar dates that best cover all
|
|
8
|
+
phase cycles while keeping observations temporally spread out. Optionally
|
|
9
|
+
excludes nights where the Moon is too close to the target for
|
|
10
|
+
high-resolution spectroscopy.
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
Full-quality version: [`examples/explainer/CadenceGreedyExplainer.mp4`](examples/explainer/CadenceGreedyExplainer.mp4)
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install rvcadence # core scheduler, zero dependencies
|
|
20
|
+
pip install rvcadence[moon] # + lunar pollution avoidance (astropy)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from datetime import date
|
|
27
|
+
from rvcadence import plan_calendar
|
|
28
|
+
|
|
29
|
+
result = plan_calendar(
|
|
30
|
+
n_obs=20,
|
|
31
|
+
periods_d=9.53,
|
|
32
|
+
season_start=date(2026, 5, 1),
|
|
33
|
+
season_end=date(2027, 4, 30),
|
|
34
|
+
rotation_period_d=12.45,
|
|
35
|
+
windows="2026-05-01 to 2026-05-19; 2026-07-29 to 2027-04-30",
|
|
36
|
+
)
|
|
37
|
+
print(result.dates)
|
|
38
|
+
print(f"median gap: {result.median_gap_d} d, mean gap: {result.mean_gap_d} d")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For a multi-planet system, pass a list of periods — coverage is optimized
|
|
42
|
+
for the *worst-covered* planet at each step, not an average:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
result = plan_calendar(
|
|
46
|
+
n_obs=20,
|
|
47
|
+
periods_d=[9.53, 21.7], # two known/candidate planets
|
|
48
|
+
season_start=date(2026, 5, 1),
|
|
49
|
+
season_end=date(2027, 4, 30),
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
With lunar avoidance (requires `pip install rvcadence[moon]`):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import astropy.units as u
|
|
57
|
+
from astropy.coordinates import EarthLocation, SkyCoord
|
|
58
|
+
|
|
59
|
+
paranal = EarthLocation(lat=-24.6272 * u.deg, lon=-70.4039 * u.deg, height=2635 * u.m)
|
|
60
|
+
target = SkyCoord(ra=123.45 * u.deg, dec=-12.3 * u.deg)
|
|
61
|
+
|
|
62
|
+
result = plan_calendar(
|
|
63
|
+
n_obs=20,
|
|
64
|
+
periods_d=9.53,
|
|
65
|
+
season_start=date(2026, 5, 1),
|
|
66
|
+
season_end=date(2027, 4, 30),
|
|
67
|
+
target_coord=target,
|
|
68
|
+
observer_location=paranal,
|
|
69
|
+
min_moon_sep_deg=30.0,
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The default 30° threshold is a widely-used rule-of-thumb avoidance radius
|
|
74
|
+
against lunar scattered-light contamination in high-resolution spectroscopy;
|
|
75
|
+
override `min_moon_sep_deg` for a different instrument or tolerance. Nights
|
|
76
|
+
are evaluated at local solar midnight, sunset-labeled (the night of date `D`
|
|
77
|
+
runs from sunset on `D` to sunrise on `D+1`).
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
Starting from the first and last available dates, the algorithm repeatedly
|
|
82
|
+
adds the candidate date that best fills gaps in planet-orbital-phase coverage
|
|
83
|
+
(and stellar-rotation-phase coverage, if known), weighted against how far it
|
|
84
|
+
is in time from already-selected dates. For multiple planets, phase coverage
|
|
85
|
+
is the *worst-case* across all periods — a candidate only scores well if it
|
|
86
|
+
improves coverage for whichever planet is currently least-covered, so one
|
|
87
|
+
well-phased planet can't mask a poorly-phased one:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
score = 0.55 · d_planet_phase + 0.30 · d_rotation_phase + 0.15 · d_time_spread (rotation period known)
|
|
91
|
+
score = 0.80 · d_planet_phase + 0.20 · d_time_spread (rotation period unknown)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
See `examples/explainer/` for the full animated walkthrough (manim source +
|
|
95
|
+
rendered mp4) — the example imports its scoring logic directly from this
|
|
96
|
+
package (see `tests/test_explainer_example.py`), so it can't drift out of
|
|
97
|
+
sync with the real algorithm.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone <repo-url>
|
|
103
|
+
cd rvcadence
|
|
104
|
+
pip install -e ".[dev,moon]"
|
|
105
|
+
pytest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import math
|
|
5
|
+
from datetime import date
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from manim import *
|
|
9
|
+
|
|
10
|
+
from rvcadence import (
|
|
11
|
+
build_allowed_offsets,
|
|
12
|
+
contiguous_ranges,
|
|
13
|
+
evaluate_candidate,
|
|
14
|
+
parse_obs_windows,
|
|
15
|
+
best_candidate as rvcadence_best_candidate,
|
|
16
|
+
)
|
|
17
|
+
from rvcadence._phase import circ_dist_01
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def phase_to_point(center: np.ndarray, radius: float, phase: float) -> np.ndarray:
|
|
21
|
+
theta = TAU * phase
|
|
22
|
+
return center + radius * np.array([math.cos(theta), math.sin(theta), 0.0])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_optional_float(value: str) -> float:
|
|
26
|
+
text = str(value).strip().lower()
|
|
27
|
+
if text in {"", "nan", "n/a", "na", "none"}:
|
|
28
|
+
return math.nan
|
|
29
|
+
return float(text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_target_row(table_path: Path, target_name: str) -> dict[str, str]:
|
|
33
|
+
with table_path.open("r", newline="", encoding="utf-8") as f:
|
|
34
|
+
rows = list(csv.DictReader(f))
|
|
35
|
+
|
|
36
|
+
for row in rows:
|
|
37
|
+
if (row.get("Planet") or "").strip() == target_name:
|
|
38
|
+
return row
|
|
39
|
+
raise ValueError(f"Target '{target_name}' not found in {table_path}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CadenceGreedyExplainer(Scene):
|
|
43
|
+
def construct(self):
|
|
44
|
+
table_path = Path(__file__).resolve().parents[2] / "ESPRESSO_large_program" / "planet_properties_table.csv"
|
|
45
|
+
target_name = "HIP 113103 b"
|
|
46
|
+
target = load_target_row(table_path, target_name)
|
|
47
|
+
|
|
48
|
+
p_planet_d = float(target["P_d"])
|
|
49
|
+
p_rot_d = parse_optional_float(target.get("Prot_lit_d", "nan"))
|
|
50
|
+
n_obs = int(float(target["N_obs"]))
|
|
51
|
+
|
|
52
|
+
season_start = date(2026, 5, 1)
|
|
53
|
+
season_end = date(2027, 4, 30)
|
|
54
|
+
baseline_days = (season_end - season_start).days
|
|
55
|
+
min_gap_days = 1
|
|
56
|
+
windows = parse_obs_windows(target.get("obs_windows_2026_2027", ""))
|
|
57
|
+
allowed_offsets = build_allowed_offsets(season_start, season_end, windows)
|
|
58
|
+
if len(allowed_offsets) < 2:
|
|
59
|
+
raise ValueError(f"Target '{target_name}' has <2 allowed dates in obs_windows_2026_2027.")
|
|
60
|
+
allowed_set = set(allowed_offsets)
|
|
61
|
+
unavailable_offsets = [t for t in range(0, baseline_days + 1) if t not in allowed_set]
|
|
62
|
+
iterations_to_show = min(10, max(0, n_obs - 2), max(0, len(allowed_offsets) - 2))
|
|
63
|
+
|
|
64
|
+
left_center = np.array([-4.2, 0.78, 0.0])
|
|
65
|
+
right_center = np.array([0.0, 0.78, 0.0])
|
|
66
|
+
meter_center = np.array([4.2, 0.78, 0.0])
|
|
67
|
+
radius = 1.30
|
|
68
|
+
|
|
69
|
+
eq = MathTex(
|
|
70
|
+
r"s(t)=",
|
|
71
|
+
r"0.55",
|
|
72
|
+
r"d_p",
|
|
73
|
+
r"+",
|
|
74
|
+
r"0.30",
|
|
75
|
+
r"d_s",
|
|
76
|
+
r"+",
|
|
77
|
+
r"0.15",
|
|
78
|
+
r"d_t",
|
|
79
|
+
font_size=42,
|
|
80
|
+
color=GRAY_A,
|
|
81
|
+
).to_edge(UP, buff=0.2)
|
|
82
|
+
dp_term = eq[2]
|
|
83
|
+
dr_term = eq[5]
|
|
84
|
+
dt_term = eq[8]
|
|
85
|
+
dp_term.set_color(BLUE_B)
|
|
86
|
+
dr_term.set_color(GREEN_B)
|
|
87
|
+
dt_term.set_color(YELLOW_B)
|
|
88
|
+
|
|
89
|
+
planet_circle = Circle(radius=radius, color=BLUE_D, stroke_width=4).move_to(left_center)
|
|
90
|
+
rot_circle = Circle(radius=radius, color=GREEN_D, stroke_width=4).move_to(right_center)
|
|
91
|
+
planet_title = Text("Planet phase", font_size=26, color=BLUE_B).next_to(planet_circle, DOWN, buff=0.18)
|
|
92
|
+
rot_title = Text("Stellar phase", font_size=26, color=GREEN_B).next_to(rot_circle, DOWN, buff=0.18)
|
|
93
|
+
|
|
94
|
+
# "Clock hands" to indicate current candidate phase.
|
|
95
|
+
planet_hand = Line(left_center, left_center + RIGHT * radius, color=BLUE_B, stroke_width=4)
|
|
96
|
+
rot_hand = Line(right_center, right_center + RIGHT * radius, color=GREEN_B, stroke_width=4)
|
|
97
|
+
|
|
98
|
+
timeline = NumberLine(
|
|
99
|
+
x_range=[0, baseline_days, 50],
|
|
100
|
+
length=12.2,
|
|
101
|
+
include_numbers=False,
|
|
102
|
+
include_ticks=False,
|
|
103
|
+
label_direction=DOWN,
|
|
104
|
+
font_size=18,
|
|
105
|
+
color=GRAY_B,
|
|
106
|
+
).to_edge(DOWN, buff=1.05)
|
|
107
|
+
month_ticks = VGroup()
|
|
108
|
+
month_labels = VGroup()
|
|
109
|
+
for month in range(5, 13):
|
|
110
|
+
d = date(2026, month, 1)
|
|
111
|
+
offset = (d - season_start).days
|
|
112
|
+
x = timeline.n2p(offset)[0]
|
|
113
|
+
tick = Line(np.array([x, timeline.get_y() - 0.10, 0.0]), np.array([x, timeline.get_y() + 0.10, 0.0]), color=GRAY_B, stroke_width=2)
|
|
114
|
+
month_ticks.add(tick)
|
|
115
|
+
month_labels.add(Text(d.strftime("%b"), font_size=18, color=GRAY_B).next_to(tick, DOWN, buff=0.12))
|
|
116
|
+
for month in range(1, 5):
|
|
117
|
+
d = date(2027, month, 1)
|
|
118
|
+
offset = (d - season_start).days
|
|
119
|
+
x = timeline.n2p(offset)[0]
|
|
120
|
+
tick = Line(np.array([x, timeline.get_y() - 0.10, 0.0]), np.array([x, timeline.get_y() + 0.10, 0.0]), color=GRAY_B, stroke_width=2)
|
|
121
|
+
month_ticks.add(tick)
|
|
122
|
+
month_labels.add(Text(d.strftime("%b"), font_size=18, color=GRAY_B).next_to(tick, DOWN, buff=0.12))
|
|
123
|
+
unavailable_lines = VGroup()
|
|
124
|
+
for a, b in contiguous_ranges(unavailable_offsets):
|
|
125
|
+
seg = Line(
|
|
126
|
+
timeline.n2p(a),
|
|
127
|
+
timeline.n2p(b),
|
|
128
|
+
color=RED_E,
|
|
129
|
+
stroke_width=9,
|
|
130
|
+
)
|
|
131
|
+
seg.set_z_index(1)
|
|
132
|
+
unavailable_lines.add(seg)
|
|
133
|
+
|
|
134
|
+
time_meter = RoundedRectangle(width=2.8, height=0.35, corner_radius=0.1, color=YELLOW_E, stroke_width=2)
|
|
135
|
+
time_meter.move_to(meter_center)
|
|
136
|
+
right_panel_title = Text("Time spacing", font_size=24, color=YELLOW_B)
|
|
137
|
+
right_panel_title.move_to(np.array([meter_center[0], planet_title.get_y(), 0.0]))
|
|
138
|
+
meter_fill = Rectangle(width=0.01, height=0.28, stroke_width=0, fill_color=YELLOW_D, fill_opacity=0.9)
|
|
139
|
+
meter_fill.move_to(time_meter.get_left() + RIGHT * 0.005)
|
|
140
|
+
meter_fill.set_z_index(3)
|
|
141
|
+
target_label = Text(target_name, font_size=18, color=GRAY_C).set_opacity(0.65)
|
|
142
|
+
target_label.to_corner(UL, buff=0.25)
|
|
143
|
+
credit = Text("Credit: Pierrot Lamontagne & GPT", font_size=18, color=GRAY_C).set_opacity(0.65)
|
|
144
|
+
credit.to_corner(DR, buff=0.25)
|
|
145
|
+
|
|
146
|
+
self.play(
|
|
147
|
+
FadeIn(eq, shift=UP * 0.1),
|
|
148
|
+
Create(planet_circle),
|
|
149
|
+
Create(rot_circle),
|
|
150
|
+
Write(planet_title),
|
|
151
|
+
Write(rot_title),
|
|
152
|
+
Create(timeline),
|
|
153
|
+
FadeIn(unavailable_lines),
|
|
154
|
+
FadeIn(month_ticks),
|
|
155
|
+
FadeIn(month_labels, lag_ratio=0.05),
|
|
156
|
+
FadeIn(planet_hand),
|
|
157
|
+
FadeIn(rot_hand),
|
|
158
|
+
FadeIn(right_panel_title, shift=UP * 0.1),
|
|
159
|
+
Create(time_meter),
|
|
160
|
+
FadeIn(meter_fill),
|
|
161
|
+
FadeIn(target_label),
|
|
162
|
+
FadeIn(credit),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
selected: list[int] = [allowed_offsets[0], allowed_offsets[-1]]
|
|
166
|
+
planet_points = VGroup()
|
|
167
|
+
rot_points = VGroup()
|
|
168
|
+
time_points = VGroup()
|
|
169
|
+
|
|
170
|
+
def add_selected_point(day: int, color: ManimColor = WHITE):
|
|
171
|
+
p_phase = (day / p_planet_d) % 1.0
|
|
172
|
+
r_phase = (day / p_rot_d) % 1.0
|
|
173
|
+
p_dot = Dot(phase_to_point(left_center, radius, p_phase), radius=0.055, color=color)
|
|
174
|
+
r_dot = Dot(phase_to_point(right_center, radius, r_phase), radius=0.055, color=color)
|
|
175
|
+
t_dot = Dot(timeline.n2p(day), radius=0.06, color=color)
|
|
176
|
+
planet_points.add(p_dot)
|
|
177
|
+
rot_points.add(r_dot)
|
|
178
|
+
time_points.add(t_dot)
|
|
179
|
+
self.play(FadeIn(p_dot, scale=0.6), FadeIn(r_dot, scale=0.6), FadeIn(t_dot, scale=0.6), run_time=0.35)
|
|
180
|
+
|
|
181
|
+
add_selected_point(allowed_offsets[0], color=GRAY_B)
|
|
182
|
+
add_selected_point(allowed_offsets[-1], color=GRAY_B)
|
|
183
|
+
self.wait(0.4)
|
|
184
|
+
|
|
185
|
+
max_iter = iterations_to_show
|
|
186
|
+
fast_forward_pause_s = 4.0
|
|
187
|
+
|
|
188
|
+
def best_candidate_for_current_selection(current_selected: list[int]) -> "CandidateScore":
|
|
189
|
+
return rvcadence_best_candidate(
|
|
190
|
+
allowed_offsets, current_selected, p_planet_d, p_rot_d, baseline_days
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def add_selected_point_fast(day: int):
|
|
194
|
+
p_phase = (day / p_planet_d) % 1.0
|
|
195
|
+
r_phase = (day / p_rot_d) % 1.0
|
|
196
|
+
p_dot = Dot(phase_to_point(left_center, radius, p_phase), radius=0.055, color=RED_D).set_z_index(6)
|
|
197
|
+
r_dot = Dot(phase_to_point(right_center, radius, r_phase), radius=0.055, color=RED_D).set_z_index(6)
|
|
198
|
+
t_dot = Dot(timeline.n2p(day), radius=0.06, color=RED_D).set_z_index(6)
|
|
199
|
+
self.play(FadeIn(p_dot, scale=0.6), FadeIn(r_dot, scale=0.6), FadeIn(t_dot, scale=0.6), run_time=0.06)
|
|
200
|
+
self.play(
|
|
201
|
+
p_dot.animate.set_color(WHITE),
|
|
202
|
+
r_dot.animate.set_color(WHITE),
|
|
203
|
+
t_dot.animate.set_color(WHITE),
|
|
204
|
+
run_time=0.06,
|
|
205
|
+
)
|
|
206
|
+
planet_points.add(p_dot)
|
|
207
|
+
rot_points.add(r_dot)
|
|
208
|
+
time_points.add(t_dot)
|
|
209
|
+
|
|
210
|
+
for k in range(max_iter):
|
|
211
|
+
best = best_candidate_for_current_selection(selected)
|
|
212
|
+
p_phase_best = (best.t / p_planet_d) % 1.0
|
|
213
|
+
r_phase_best = (best.t / p_rot_d) % 1.0
|
|
214
|
+
|
|
215
|
+
p_phases = [((x / p_planet_d) % 1.0) for x in selected]
|
|
216
|
+
r_phases = [((x / p_rot_d) % 1.0) for x in selected]
|
|
217
|
+
|
|
218
|
+
p_near = min(p_phases, key=lambda x: circ_dist_01(x, p_phase_best))
|
|
219
|
+
r_near = min(r_phases, key=lambda x: circ_dist_01(x, r_phase_best))
|
|
220
|
+
t_near = min(selected, key=lambda x: abs(x - best.t))
|
|
221
|
+
|
|
222
|
+
p_angle = TAU * ((p_phase_best - p_near + 0.5) % 1.0 - 0.5)
|
|
223
|
+
r_angle = TAU * ((r_phase_best - r_near + 0.5) % 1.0 - 0.5)
|
|
224
|
+
|
|
225
|
+
p_arc = Arc(
|
|
226
|
+
radius=radius * 1.04,
|
|
227
|
+
start_angle=TAU * p_near,
|
|
228
|
+
angle=p_angle,
|
|
229
|
+
color=BLUE_C,
|
|
230
|
+
stroke_width=6,
|
|
231
|
+
).move_arc_center_to(left_center)
|
|
232
|
+
p_arc.set_z_index(2)
|
|
233
|
+
r_arc = Arc(
|
|
234
|
+
radius=radius * 1.04,
|
|
235
|
+
start_angle=TAU * r_near,
|
|
236
|
+
angle=r_angle,
|
|
237
|
+
color=GREEN_C,
|
|
238
|
+
stroke_width=6,
|
|
239
|
+
).move_arc_center_to(right_center)
|
|
240
|
+
r_arc.set_z_index(2)
|
|
241
|
+
|
|
242
|
+
t_line = Line(
|
|
243
|
+
timeline.n2p(best.t),
|
|
244
|
+
timeline.n2p(t_near),
|
|
245
|
+
color=YELLOW_D,
|
|
246
|
+
stroke_width=7,
|
|
247
|
+
)
|
|
248
|
+
t_line.set_z_index(2)
|
|
249
|
+
|
|
250
|
+
best_time_dot = Dot(timeline.n2p(best.t), radius=0.075, color=RED_D)
|
|
251
|
+
best_p_dot = Dot(phase_to_point(left_center, radius, p_phase_best), radius=0.07, color=RED_D)
|
|
252
|
+
best_r_dot = Dot(phase_to_point(right_center, radius, r_phase_best), radius=0.07, color=RED_D)
|
|
253
|
+
best_time_dot.set_z_index(6)
|
|
254
|
+
best_p_dot.set_z_index(6)
|
|
255
|
+
best_r_dot.set_z_index(6)
|
|
256
|
+
pulse_t = Circle(radius=0.12, color=RED_D, stroke_width=3).move_to(best_time_dot).set_z_index(7)
|
|
257
|
+
pulse_p = Circle(radius=0.11, color=RED_D, stroke_width=3).move_to(best_p_dot).set_z_index(7)
|
|
258
|
+
pulse_r = Circle(radius=0.11, color=RED_D, stroke_width=3).move_to(best_r_dot).set_z_index(7)
|
|
259
|
+
dt_frac = max(0.0, min(1.0, best.d_t))
|
|
260
|
+
new_width = max(0.01, 2.76 * dt_frac)
|
|
261
|
+
new_fill = Rectangle(
|
|
262
|
+
width=new_width,
|
|
263
|
+
height=0.28,
|
|
264
|
+
stroke_width=0,
|
|
265
|
+
fill_color=YELLOW_D,
|
|
266
|
+
fill_opacity=0.9,
|
|
267
|
+
)
|
|
268
|
+
new_fill.move_to(time_meter.get_left() + RIGHT * (new_width / 2 + 0.02))
|
|
269
|
+
new_fill.set_z_index(3)
|
|
270
|
+
self.play(
|
|
271
|
+
Rotate(
|
|
272
|
+
planet_hand,
|
|
273
|
+
angle=TAU * p_phase_best - planet_hand.get_angle(),
|
|
274
|
+
about_point=left_center,
|
|
275
|
+
),
|
|
276
|
+
Rotate(
|
|
277
|
+
rot_hand,
|
|
278
|
+
angle=TAU * r_phase_best - rot_hand.get_angle(),
|
|
279
|
+
about_point=right_center,
|
|
280
|
+
),
|
|
281
|
+
FadeIn(best_time_dot, scale=0.7),
|
|
282
|
+
FadeIn(best_p_dot, scale=0.7),
|
|
283
|
+
FadeIn(best_r_dot, scale=0.7),
|
|
284
|
+
Create(pulse_t),
|
|
285
|
+
Create(pulse_p),
|
|
286
|
+
Create(pulse_r),
|
|
287
|
+
Create(p_arc),
|
|
288
|
+
Create(r_arc),
|
|
289
|
+
Create(t_line),
|
|
290
|
+
ReplacementTransform(meter_fill, new_fill),
|
|
291
|
+
run_time=1.2,
|
|
292
|
+
)
|
|
293
|
+
meter_fill = new_fill
|
|
294
|
+
p_link = Arrow(
|
|
295
|
+
p_arc.point_from_proportion(0.5),
|
|
296
|
+
dp_term.get_center(),
|
|
297
|
+
buff=0.06,
|
|
298
|
+
color=BLUE_C,
|
|
299
|
+
stroke_width=3.2,
|
|
300
|
+
max_tip_length_to_length_ratio=0.14,
|
|
301
|
+
)
|
|
302
|
+
r_link = Arrow(
|
|
303
|
+
r_arc.point_from_proportion(0.5),
|
|
304
|
+
dr_term.get_center(),
|
|
305
|
+
buff=0.06,
|
|
306
|
+
color=GREEN_C,
|
|
307
|
+
stroke_width=3.2,
|
|
308
|
+
max_tip_length_to_length_ratio=0.14,
|
|
309
|
+
)
|
|
310
|
+
t_link = Arrow(
|
|
311
|
+
meter_fill.get_right(),
|
|
312
|
+
dt_term.get_center(),
|
|
313
|
+
buff=0.06,
|
|
314
|
+
color=YELLOW_D,
|
|
315
|
+
stroke_width=3.2,
|
|
316
|
+
max_tip_length_to_length_ratio=0.14,
|
|
317
|
+
)
|
|
318
|
+
self.play(
|
|
319
|
+
Create(p_link),
|
|
320
|
+
Create(r_link),
|
|
321
|
+
Create(t_link),
|
|
322
|
+
Indicate(p_arc, color=BLUE_C, scale_factor=1.06),
|
|
323
|
+
Indicate(r_arc, color=GREEN_C, scale_factor=1.06),
|
|
324
|
+
Indicate(t_line, color=YELLOW_D, scale_factor=1.06),
|
|
325
|
+
Indicate(meter_fill, color=YELLOW_D, scale_factor=1.06),
|
|
326
|
+
Indicate(dp_term, color=BLUE_C, scale_factor=1.12),
|
|
327
|
+
Indicate(dr_term, color=GREEN_C, scale_factor=1.12),
|
|
328
|
+
Indicate(dt_term, color=YELLOW_D, scale_factor=1.12),
|
|
329
|
+
run_time=0.75,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
self.play(
|
|
333
|
+
best_time_dot.animate.set_color(WHITE).scale(0.8),
|
|
334
|
+
best_p_dot.animate.set_color(WHITE).scale(0.8),
|
|
335
|
+
best_r_dot.animate.set_color(WHITE).scale(0.8),
|
|
336
|
+
FadeOut(pulse_t, scale=1.8),
|
|
337
|
+
FadeOut(pulse_p, scale=1.8),
|
|
338
|
+
FadeOut(pulse_r, scale=1.8),
|
|
339
|
+
FadeOut(p_arc),
|
|
340
|
+
FadeOut(r_arc),
|
|
341
|
+
FadeOut(t_line),
|
|
342
|
+
FadeOut(p_link),
|
|
343
|
+
FadeOut(r_link),
|
|
344
|
+
FadeOut(t_link),
|
|
345
|
+
run_time=0.35,
|
|
346
|
+
)
|
|
347
|
+
planet_points.add(best_p_dot)
|
|
348
|
+
rot_points.add(best_r_dot)
|
|
349
|
+
time_points.add(best_time_dot)
|
|
350
|
+
selected.append(best.t)
|
|
351
|
+
|
|
352
|
+
# Fast-forward through the remaining iterations to reach full N_obs coverage.
|
|
353
|
+
while len(selected) < n_obs:
|
|
354
|
+
best = best_candidate_for_current_selection(selected)
|
|
355
|
+
add_selected_point_fast(best.t)
|
|
356
|
+
selected.append(best.t)
|
|
357
|
+
|
|
358
|
+
self.wait(fast_forward_pause_s)
|