Skip to content

Commit b9a6c8c

Browse files
committed
Add JPL Horizons RA/Dec helper and integrate
Adds a CLI helper (scripts/horizons_radec.php) that queries JPL Horizons and returns structured JSON so authoritative apparent RA/Dec can be obtained for observer-based ephemerides. Integrates a Horizons path into the elliptic target: enables fetching authoritative coordinates when toggled and accepts an explicit Horizons designation. Improves orbital-element canonicalisation (angle wrapping and inclination normalization) to reduce propagation ambiguity. Hardens Horizons parsing and resolution heuristics by preferring JSON, robustly extracting $$SOE..$$EOE blocks, and retrying record-id resolution for index-search responses. Adds documentation and PHPUnit integration tests exercising the helper and the Elliptic integration. Notes non-deterministic nature of live tests and plans for a small alias/cache to stabilize id resolution.
1 parent 33faab4 commit b9a6c8c

13 files changed

Lines changed: 775 additions & 6 deletions

changelog.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
All notable changes to `laravel-astronomy-library` will be documented in this file.
44

5+
## Version 6.6.0
6+
7+
Added:
8+
- `scripts/horizons_radec.php` — a small CLI helper that queries the JPL Horizons ephemeris service and returns structured JSON (apparent RA/Dec) for observer-based ephemerides. The helper writes optional debug artifacts to `scripts/horizons_raw.txt`, `scripts/horizons_block.txt` and `scripts/horizons_resp.json` to aid troubleshooting.
9+
- PHPUnit integration tests that exercise the Horizons helper and library integration (unit/integration tests under `tests/Unit/*Horizons*`).
10+
- `docs/getting_radec.md` — documentation covering how to obtain authoritative RA/Dec for small bodies using the Horizons helper and how to enable Horizons mode in `Elliptic` targets.
11+
12+
Changed:
13+
- `Elliptic` target: added a Horizons integration path — when enabled the library will invoke the helper to obtain authoritative apparent RA/Dec for observer-based ephemerides instead of using only internal propagation. Also added setters to enable Horizons mode and to provide an explicit Horizons designation.
14+
- Improved canonicalisation of orbital elements in `Elliptic::setOrbitalElements()` (angle wrapping and inclination handling) to reduce ambiguity when propagating elements.
15+
16+
Fixed:
17+
- Hardened parsing and record-resolution heuristics for Horizons responses (helper now prefers JSON output, extracts $$SOE..$$EOE blocks robustly, and retries record-id resolution when an index search is returned).
18+
19+
Notes:
20+
- A lightweight cache/alias mechanism for Horizons id resolution is planned to make repetitive integration tests more deterministic (not yet added in this release).
21+
22+
523
## Version 6.5.1
624

725
Changed:

docs/getting_radec.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
2+
# Getting RA/Dec for a comet (JPL Horizons)
3+
4+
This document shows the supported ways to obtain authoritative apparent Right
5+
Ascension (RA) and Declination (Dec) for a comet using this project. It
6+
describes: (A) calling the bundled Horizons helper, and (B) asking the
7+
library's `Elliptic` class to use JPL Horizons.
8+
9+
Prerequisites
10+
11+
- Network access to query JPL Horizons (required for live integration).
12+
- PHP CLI available on PATH (used to run the helper script).
13+
- The helper script: `scripts/horizons_radec.php` (should exist in the project root).
14+
15+
1) Using the Horizons helper script (quick, direct)
16+
17+
From the project root run:
18+
19+
```bash
20+
php scripts/horizons_radec.php '12P' '2025-11-18 16:08' 4.84457 49.3447 130
21+
```
22+
23+
- Argument 1: Horizons designation or object name (for example: `12P`, `1P`, `103P/Hartley`).
24+
- Argument 2: UTC datetime in `YYYY-MM-DD HH:MM` format.
25+
- Argument 3/4: observer longitude and latitude in decimal degrees (signed).
26+
- Argument 5: observer height in metres.
27+
28+
The helper prints JSON to stdout similar to:
29+
30+
```json
31+
{
32+
"ra_hours": 16.327372222222223,
33+
"dec_deg": -36.33230555555556,
34+
"raw_ra": "16 19 38.54",
35+
"raw_dec": "-36 19 56.3"
36+
}
37+
```
38+
39+
Fields:
40+
41+
- `ra_hours`: RA in decimal hours (0..24).
42+
- `dec_deg`: Declination in decimal degrees (-90..90).
43+
- `raw_ra`, `raw_dec`: the raw HMS/DMS strings parsed from Horizons (for debugging).
44+
45+
2) Using the library: `Elliptic` (ask the class to call Horizons)
46+
47+
If you want the library to fetch authoritative apparent coordinates from
48+
Horizons at runtime, create an `Elliptic` instance and enable Horizons mode.
49+
Important: call `setHorizonsDesignation(...)` with a valid designation before
50+
calculating coordinates.
51+
52+
Example (PHP):
53+
54+
```php
55+
use deepskylog\AstronomyLibrary\Targets\Elliptic;
56+
use deepskylog\AstronomyLibrary\Coordinates\GeographicalCoordinates;
57+
use Carbon\Carbon;
58+
59+
$ell = new Elliptic();
60+
$ell->setUseHorizons(true);
61+
$ell->setHorizonsDesignation('12P');
62+
63+
$date = Carbon::createFromFormat('Y-m-d H:i', '2025-11-18 16:08', 'UTC');
64+
$geo = new GeographicalCoordinates(4.84457, 49.3447);
65+
66+
$ell->calculateEquatorialCoordinates($date, $geo, 2451545.0, 130.0);
67+
68+
$today = $ell->getEquatorialCoordinatesToday();
69+
echo "RA (hours): " . $today->getRA()->getCoordinate() . "\n";
70+
echo "Dec (deg): " . $today->getDeclination()->getCoordinate() . "\n";
71+
```
72+
73+
Notes and troubleshooting
74+
75+
- Helper not found: when PHPUnit or a different working directory runs tests,
76+
the library resolves the helper relative to the project root. Ensure
77+
`scripts/horizons_radec.php` exists and is readable.
78+
- Designation required: `setUseHorizons(true)` requires a prior call to
79+
`setHorizonsDesignation(...)`. The library avoids accessing uninitialised
80+
orbital-element properties when Horizons mode is used.
81+
- Ambiguous names: some comets require a specific alias or numeric record id
82+
to be resolved by Horizons (examples: `103P/Hartley 2` variants). The
83+
helper implements heuristics to handle index-search results but may still
84+
need aliases.
85+
86+
Making tests deterministic
87+
88+
- Integration tests that call Horizons require network access and may be
89+
non-deterministic. Options:
90+
- Keep them as integration tests and skip on offline CI.
91+
- Mock the helper (return canned JSON) for unit tests.
92+
- Use a small alias cache (`scripts/horizons_aliases.json`) to remember
93+
successful record ids and consult the cache first. This project can
94+
be extended to write and read this cache.
95+
96+
Developer / debugging commands
97+
98+
Run a single helper query:
99+
100+
```bash
101+
php scripts/horizons_radec.php '12P' '2025-11-18 16:08' 4.84457 49.3447 130
102+
```
103+
104+
Run the helper-based comet integration tests:
105+
106+
```bash
107+
./vendor/bin/phpunit --testdox tests/Unit/CometsHorizonsTest.php
108+
./vendor/bin/phpunit --testdox tests/Unit/EllipticHorizonsIntegrationTest.php
109+
```
110+
111+
If a test fails with an ambiguous Horizons response, inspect debug files
112+
written by the helper in the project `scripts/` directory:
113+
114+
- `scripts/horizons_raw.txt` — full raw response from Horizons.
115+
- `scripts/horizons_block.txt` — extracted $$SOE..$$EOE block (if present).
116+
- `scripts/horizons_resp.json` — last structured JSON output the helper wrote.
117+
118+
If you'd like
119+
120+
- I can add a small alias/cache file (`scripts/horizons_aliases.json`) and
121+
update the helper to populate it when it resolves a record id successfully.
122+
- I can also add a small example script that queries a list of objects and
123+
writes a CSV of RA/Dec for batch processing.

scripts/horizons_block.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
2025-Nov-17 16:08,C, , 16 17 15.24, -36 16 21.3, 16 18 56.14, -36 20 10.4, 21.24189, 0.716907, 224.630836, -10.360794, 597.95, -412.85, 39129.704, -61859.93, 151.884, 20 15 15.6137, n.a., n.a., 21.625, 23.540, 99.93394, n.a., 71771.31, *, n.a., n.a., n.a., n.a., n.a., 326.39, 0.00, n.a., n.a., 250.9448,-16.8236, 6.561283975919, 13.6953818, 7.48206004052461, 20.5468903, 62.22639589, 14.7999669, 38.0585432, 19.9365,/T, 2.9493, 42.9, 6.2656, 157.3569, 146.610, 214.076, -2.55757, Sco, 69.182801, 249.1775010,-14.7064640, n.a., n.a., 342.772457, 10.219469, 16 42 20.9764, 0.000354, 0.143, 0.133, 0.143, 0.133, -13.800, 0.0597639, 0.195, 98.6062, 0.0000037, 0.32, 1.16, 0.000658, 143.4720, 03 56 19.470, 2.9457, 249.8773, -15.7663, n.a., 16 17 13.58, -36 16 19.0, 21.24382, 0.657412, 0.3542331, 88.067020, 32.669667, n.a., n.a., 0.08378,
3+
2025-Nov-17 16:09,C, , 16 17 15.26, -36 16 21.3, 16 18 56.17, -36 20 10.4, 21.24265, 0.717131, 224.799597, -10.475647, 597.10, -414.08, 39127.640, -61859.32, 151.885, 20 16 15.7780, n.a., n.a., 21.625, 23.540, 99.93394, n.a., 71769.82, *, n.a., n.a., n.a., n.a., n.a., 326.39, 0.00, n.a., n.a., 250.9448,-16.8236, 6.561289468420, 13.6953754, 7.48206828104412, 20.5471415, 62.22646442, 14.7999593, 38.0596760, 19.9361,/T, 2.9492, 42.9, 6.2624, 157.3573, 146.611, 214.075, -2.55748, Sco, 69.182801, 249.1776003,-14.7064436, n.a., n.a., 342.772530, 10.219401, 16 43 20.9685, 0.000354, 0.143, 0.133, 0.143, 0.133, -13.800, 0.0597638, 0.195, 98.6064, 0.0000037, 0.32, 1.16, 0.000658, 143.4720, 03 57 19.605, 2.9456, 249.8773, -15.7663, n.a., 16 17 13.61, -36 16 19.0, 21.24457, 0.657633, 0.3542458, 88.066486, 32.669022, n.a., n.a., 0.08377,

scripts/horizons_radec.php

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<?php
2+
// Usage: php horizons_radec.php <designation> <YYYY-MM-DD HH:MM> <lon> <lat> <alt_m>
3+
if ($argc < 6) {
4+
echo json_encode(['error' => 'usage: designation datetime lon lat alt_m']);
5+
exit(1);
6+
}
7+
$des = $argv[1];
8+
$dt = $argv[2];
9+
$lon = $argv[3];
10+
$lat = $argv[4];
11+
$alt_m = floatval($argv[5]);
12+
$alt_km = $alt_m / 1000.0;
13+
$start = $dt;
14+
$stop = date('Y-m-d H:i', strtotime($dt . ' +1 minute'));
15+
16+
function doQuery($command, $site, $start, $stop)
17+
{
18+
// Request JSON format when possible for more reliable parsing.
19+
$post = [
20+
'format' => 'json',
21+
'COMMAND' => $command,
22+
'EPHEM_TYPE' => 'OBSERVER',
23+
'CENTER' => 'coord@399',
24+
'SITE_COORD' => $site,
25+
'START_TIME' => "'{$start}'",
26+
'STOP_TIME' => "'{$stop}'",
27+
'STEP_SIZE' => "'1 m'",
28+
'CSV_FORMAT' => 'YES'
29+
];
30+
$ch = curl_init('https://ssd.jpl.nasa.gov/api/horizons.api');
31+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
32+
curl_setopt($ch, CURLOPT_POST, true);
33+
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post));
34+
curl_setopt($ch, CURLOPT_USERAGENT, 'Deepsky-AstronomyLibrary/1.0');
35+
$resp = curl_exec($ch);
36+
$err = curl_error($ch);
37+
curl_close($ch);
38+
// If the API returned JSON, try to extract a textual result block to keep
39+
// downstream parsing compatible with existing logic.
40+
$decoded = @json_decode($resp, true);
41+
if (is_array($decoded)) {
42+
// Helper: recursively search for any string that contains $$SOE..$$EOE
43+
$findBlock = function ($item) use (&$findBlock) {
44+
if (is_string($item)) {
45+
if (strpos($item, '$$SOE') !== false) return $item;
46+
return null;
47+
}
48+
if (is_array($item)) {
49+
foreach ($item as $v) {
50+
$res = $findBlock($v);
51+
if ($res !== null) return $res;
52+
}
53+
}
54+
return null;
55+
};
56+
57+
$block = $findBlock($decoded);
58+
if ($block !== null) {
59+
return [$block, $err];
60+
}
61+
62+
// Some API responses include a 'result' string or 'data' key containing
63+
// the textual output — try those as fallbacks.
64+
if (isset($decoded['result']) && is_string($decoded['result'])) return [$decoded['result'], $err];
65+
if (isset($decoded['data']) && is_string($decoded['data'])) return [$decoded['data'], $err];
66+
67+
// Otherwise return the original raw response so existing fallback parsing
68+
// can still attempt to find numeric ids or text blocks.
69+
}
70+
71+
return [$resp, $err];
72+
}
73+
74+
$site = "'{$lon},{$lat},{$alt_km}'";
75+
$command = "'{$des}'";
76+
list($resp, $err) = doQuery($command, $site, $start, $stop);
77+
if ($resp === false || empty($resp)) {
78+
echo json_encode(['error' => 'horizons empty', 'curl' => $err]);
79+
exit(1);
80+
}
81+
82+
// Save full raw response inside the workspace for debugging
83+
@file_put_contents(__DIR__ . '/horizons_raw.txt', $resp);
84+
85+
// If no $$SOE block, attempt to resolve an index-search result and re-query.
86+
if (!preg_match('/\$\$SOE([\s\S]*?)\$\$EOE/', $resp, $m)) {
87+
$rec = null;
88+
89+
// 1) Common Horizons numeric IDs often start with 9 and are 6+ digits.
90+
if (preg_match_all('/\b(9\d{5,9})\b/', $resp, $mrec)) {
91+
$matches = $mrec[1];
92+
$rec = end($matches);
93+
}
94+
95+
// 2) If not found, try to parse index-like lines beginning with '1) ...', '2) ...'
96+
if ($rec === null) {
97+
if (preg_match_all('/^\s*\d+\)\s*(.+)$/m', $resp, $mlines)) {
98+
foreach ($mlines[1] as $ln) {
99+
if (preg_match_all('/\b(\d{4,9})\b/', $ln, $mt)) {
100+
// pick the first candidate token that doesn't look like a year
101+
foreach ($mt[1] as $tok) {
102+
$intval = intval($tok);
103+
if ($intval < 1800 || $intval > 2200) {
104+
$rec = $tok;
105+
break 2;
106+
}
107+
}
108+
}
109+
}
110+
}
111+
}
112+
113+
// 3) Fallback: search all numeric tokens (4-9 digits) and exclude year-like values
114+
if ($rec === null) {
115+
if (preg_match_all('/\b(\d{4,9})\b/', $resp, $mall)) {
116+
foreach ($mall[1] as $tok) {
117+
$intval = intval($tok);
118+
if ($intval < 1800 || $intval > 2200) {
119+
$rec = $tok;
120+
break;
121+
}
122+
}
123+
}
124+
}
125+
126+
if ($rec !== null) {
127+
$command = "'{$rec}'";
128+
list($resp2, $err2) = doQuery($command, $site, $start, $stop);
129+
if ($resp2 === false || empty($resp2)) {
130+
echo json_encode(['error' => 'requery failed', 'curl' => $err2]);
131+
exit(1);
132+
}
133+
$resp = $resp2;
134+
} else {
135+
echo json_encode(['error' => 'no data block and no record id']);
136+
exit(1);
137+
}
138+
}
139+
if (!preg_match('/\$\$SOE([\s\S]*?)\$\$EOE/', $resp, $mblock)) {
140+
echo json_encode(['error' => 'no block after requery']);
141+
exit(1);
142+
}
143+
$block = $mblock[1];
144+
145+
// Save raw block into workspace for quick inspection
146+
@file_put_contents(__DIR__ . '/horizons_block.txt', $block);
147+
148+
// Split block into lines and choose the best data line (first non-empty, non-header line)
149+
$lines = preg_split('/\r?\n/', trim($block));
150+
$dataLine = null;
151+
foreach ($lines as $ln) {
152+
$ln = trim($ln);
153+
if ($ln === '') continue;
154+
if (strpos($ln, '***') === 0) continue;
155+
if (strpos($ln, ',') !== false && preg_match('/\d/', $ln)) {
156+
$dataLine = $ln;
157+
break;
158+
}
159+
}
160+
if ($dataLine === null) {
161+
echo json_encode(['error' => 'no data line in block']);
162+
exit(1);
163+
}
164+
165+
// Split fields on commas and trim
166+
$fields = array_map('trim', preg_split('/\s*,\s*/', $dataLine));
167+
168+
// Helper to find index matching regex
169+
function findFieldIndex(array $fields, string $regex)
170+
{
171+
foreach ($fields as $i => $f) {
172+
if (preg_match($regex, $f)) return $i;
173+
}
174+
return -1;
175+
}
176+
177+
// RA formats: 'HH MM SS.ss' or 'HH:MM:SS'
178+
$raIdx = findFieldIndex($fields, '/^\s*\d{1,2}[:\s]\d{1,2}[:\s]\d{1,2}(\.\d+)?/');
179+
// DEC formats: prefer signed degrees (leading + or -) for unambiguous detection
180+
$decIdx = findFieldIndex($fields, '/^[\+\-]\d{1,3}[:\s]\d{1,2}[:\s]\d{1,2}(\.\d+)?/');
181+
182+
// Fallback to legacy indices if regex search failed
183+
if ($raIdx === -1) $raIdx = isset($fields[5]) ? 5 : (isset($fields[3]) ? 3 : 0);
184+
if ($decIdx === -1) $decIdx = isset($fields[6]) ? 6 : (isset($fields[4]) ? 4 : 1);
185+
186+
$raStr = $fields[$raIdx] ?? '';
187+
$decStr = $fields[$decIdx] ?? '';
188+
189+
// Prefer the a-app (apparent) RA/Dec pair if present (usually columns 5 and 6)
190+
if (isset($fields[5]) && isset($fields[6])) {
191+
$maybeRa = $fields[5];
192+
$maybeDec = $fields[6];
193+
if (
194+
preg_match('/^\s*\d{1,2}[:\s]\d{1,2}[:\s]\d{1,2}(\.\d+)?/', $maybeRa)
195+
&& preg_match('/^[\+\-]\d{1,3}[:\s]\d{1,2}[:\s]\d{1,2}(\.\d+)?/', $maybeDec)
196+
) {
197+
$raStr = $maybeRa;
198+
$decStr = $maybeDec;
199+
}
200+
}
201+
202+
function hmsToHours($s)
203+
{
204+
$s = trim($s);
205+
if (strpos($s, ':') !== false) {
206+
list($h, $m, $sec) = explode(':', $s) + [0, 0, 0];
207+
return intval($h) + intval($m) / 60.0 + floatval($sec) / 3600.0;
208+
}
209+
$parts = preg_split('/\s+/', $s);
210+
if (count($parts) >= 3) return intval($parts[0]) + intval($parts[1]) / 60.0 + floatval($parts[2]) / 3600.0;
211+
return floatval($s);
212+
}
213+
function dmsToDeg($s)
214+
{
215+
$s = trim($s);
216+
$sign = 1;
217+
if ($s[0] === '+' || $s[0] === '-') {
218+
if ($s[0] === '-') $sign = -1;
219+
$s = substr($s, 1);
220+
}
221+
if (strpos($s, ':') !== false) {
222+
list($d, $m, $sec) = explode(':', $s) + [0, 0, 0];
223+
return $sign * (abs(intval($d)) + intval($m) / 60.0 + floatval($sec) / 3600.0);
224+
}
225+
$parts = preg_split('/\s+/', $s);
226+
if (count($parts) >= 3) return $sign * (abs(intval($parts[0])) + intval($parts[1]) / 60.0 + floatval($parts[2]) / 3600.0);
227+
return $sign * floatval($s);
228+
}
229+
$raH = hmsToHours($raStr);
230+
$decD = dmsToDeg($decStr);
231+
$out = ['ra_hours' => $raH, 'dec_deg' => $decD, 'raw_ra' => $raStr, 'raw_dec' => $decStr];
232+
// Save structured JSON output for inspection
233+
@file_put_contents(__DIR__ . '/horizons_resp.json', json_encode($out));
234+
echo json_encode($out);

0 commit comments

Comments
 (0)