Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ src/objects/*/source/science/tess/pdcsap/*.dat
src/objects/*/source/science/k2/pdcsap/fine/*.dat
src/objects/*/source/science/tess/pdcsap/fine/*.dat
src/objects/*/source/science/kepler/kepseismic/fine/*.dat
# A star's surface map fitted to its interferometry of several nights (70 KB a map); built by packages/telescope-cli/src/archives/interferometry/surface-star.mts, restored from the source mirror.
src/objects/*/source/science/interferometry/*.dat
# M1-67's model grid (17 MB); built by packages/bake/authoring/m1-67/shell-grid.mts, restored from the source mirror.
src/objects/m1-67-layers/source/shell-grid.dat
# The Crab's measured filaments (416,573 rows, 11.5 MB); built by packages/bake/authoring/m1/filament-speeds.mts, restored from the source mirror.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 36 additions & 1 deletion docs/interferometric-imaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A star's surface reaches this project as interferometric data, not as a picture.

1. **Calibrate.** Most public data are raw exposures in the ESO archive. The calibration tools plan a night from the archive's raw table, download the public frames and reduce them with ESO's own pipelines: `calibrate-pionier.mts` for VLTI/PIONIER, `calibrate-amber.mts` for VLTI/AMBER, `calibrate-gravity.mts` for VLTI/GRAVITY and `calibrate-matisse.mts` for VLTI/MATISSE. PIONIER and AMBER nights are planned from the raw table. GRAVITY and MATISSE need deeper calibration chains, so their tools follow the calibration tree the archive's calselector service gives for the science frame (`eso-associations.mts`). A table per instrument names the recipe for each kind of association, which children it reads, and which header keywords pick between alternatives, such as the dark with the science frame's integration time or the sky in the same beam-commutation state. An author's calibrated file, when one is public, can be used instead.
2. **Select.** `oifits-select.mts` flags channels outside chosen wavelength windows, data outside an MJD range, or every other exposure (`--half even|odd`), applies a published wavelength-scale correction, and can raise errors to a floor (`--error-floor`). The measured values themselves are never changed.
3. **Reconstruct.** SQUEEZE builds a flat sky-plane image. `surface-reconstruction.mts` runs ROTIR, which fits brightness directly on a sphere of known size and limb darkening.
3. **Reconstruct.** SQUEEZE builds a flat sky-plane image. `surface-reconstruction.mts` runs ROTIR, which fits brightness directly on a sphere of known size and limb darkening. Given several nights and a rotation period, it fits one turning surface to all of them.
4. **Check.** `spotless-disc.mts` simulates a spotless limb-darkened disc on the same sampling and errors, and the same recipe reconstructs it. A reconstruction may be cast only if it passes three conditions:
- it fits its own data to a reduced chi-squared of 3 or better, on squared visibilities and on closure phases;
- its spots are at least twice as strong as those of every spotless twin within 2 percent of the fitted size;
Expand Down Expand Up @@ -87,6 +87,7 @@ Measured on 2026-09-16 with the pinned recipes and the spotless simulations the
| R Dor, SQUEEZE, AMBER continuum | 1.74 and 2.99 | 1.31 | 0.36 | not cast |
| R Aqr, SQUEEZE, PIONIER 2019 from raw, 16 nights, 5% and 2° error floors | 58 and 63 | 15.6 | 0.27 (without floors) | not cast |
| R Aqr, `image-star.mts` on `seasons/r-aqr-pionier-2019`: lost fringes removed, 5%, 5e-6 and 2° floors | 71.8 and 71.9 | 5.76 (10.2 against the full-size twin) | 0.48 | not cast |
| λ Andromedae, ROTIR sphere on six nights, `surface-star.mts` on `seasons/udkadua-mirc-2011-09` (2026-10-08) | 1.72 and 2.30 | 2.14 (2.94 against the twin of the same size; 1.40 against one 2% larger, which does not fit the sphere) | 0.83 | cast: see below |

The spot ratio depends on the twin's size. On one half of Betelgeuse's February 2020 data, a spotless disc leaves patches with rms 0.03 at 42.0 mas, 0.12 at 42.8 mas and 0.05 at 43.8 mas, against 0.13 in that half's image. More iterations (10 000), four chains and half-size pixels (0.39 mas) did not make a spotty twin clean. π¹ Gruis's twins stay at 0.012 to 0.019 over ±2 percent. The rows above measured with one twin are kept as measured. Since the check judges by the spottiest twin within 2 percent, Betelgeuse's image does not pass it. The Betelgeuse dataset is kept by decision and says on the page that its patches are not confirmed.

Expand Down Expand Up @@ -152,6 +153,40 @@ The final image, after the 0.907 flux-scale correction applied to the visibiliti

Two things do not match the paper and are not explained here. Its Table 1 gives 77 by 52 mas for this date where robust 0 in CASA gives 48.2 by 21.4; AIPS and CASA scale the Briggs robust parameter differently, which is a candidate and is not verified. And its text describes the array as reaching about 5 km, where the delivered data hold projected baselines from 14 to 8040 kilo-wavelengths — 18 m to 10.4 km at this frequency — after the delivery's own flag on spacings beyond 10 km. The paper states no brightness temperatures in its text, and the scale of its Figure 1 was not read.

## A star that turns between its nights

A star that rotates during an observing run has no single sky image. `surface-star.mts` fits one surface to all the nights with ROTIR, which turns the sphere between them with the star's rotation period, and ends with a verdict:

```bash
node packages/telescope-cli/src/archives/interferometry/surface-star.mts packages/telescope-cli/src/archives/interferometry/seasons/udkadua-mirc-2011-09 output/interferometry/udkadua-mirc-2011-09
```

A surface season (`cssearth-star-surface-season@1`) names the calibrated files and where they are published, the star's size, limb, tilt, pole direction and period, each with the paper cell it is read from, the ROTIR settings with their source, and the night whose Earth-facing meridian becomes longitude 0. The command fetches the files, makes the spotless twins and the halves night by night, runs ROTIR ten times, applies the three conditions above to the star as ROTIR projects it on the sky on every night, the nights taken together, and writes the map as a table of brightness on the star's own longitudes and latitudes. `telescope new-object` with a spec's `resolvedMaps` puts a cast map on the star's page (`packages/telescope-cli/src/new-object/resolved/`), and gives a page that draws its axis by convention the measured tilt and pole direction.

**λ Andromedae (page `udkadua`), six nights of CHARA/MIRC, 2 to 24 September 2011.** The calibrated files are the authors', shipped with ROTIR.jl at the commit this repository pins; Parks et al. (2021, ApJ 913, 54) imaged the same nights with SQUEEZE and Martinez et al. (2021, ApJ 916, 60) with ROTIR. Measured on 2026-10-08: 2,984 squared visibilities and 3,704 closure phases; a spotless star leaves reduced chi-squared 3.13 and 28.3.

| Run | Reduced chi-squared, V² and closure phase | Spot ratio | Halves correlation |
| --- | --- | --- | --- |
| The season: Martinez et al.'s Table 4 values, the authors' script's settings | 1.72 and 2.30 | 2.94 against the twin of the same size; 2.57 and 2.14 against twins 1% smaller and larger | 0.83 |
| The same with the star values of the authors' script (tilt 78.1°, pole 24.0°, period 54.8 d) | 1.71 and 2.43 | not run | not run |

Verdict: cast. The map of the second row correlates 0.83 with the season's over the surface seen.

The page in the app, headless Chrome, on its three datasets, beside Arcturus with the limb Lacour et al. (2008) fitted. The app's Surface map correlates 0.83 with ROTIR's own sky image of 14 September 2011 once turned by the pole's 26° (the page draws the pole up); the mirrored picture and the other nights fit worse.

![λ Andromedae on Color + brightness, Surface map and Color, and Arcturus on Color](images/stellar-surface-map-lambda-andromedae.webp)

What in this is not printed in a paper:

1. **The three conditions and their limits** are this repository's, as for every star above.
2. **Which spotless twins count.** SQUEEZE is free to draw a disc of any size; ROTIR's sphere has the size it is given. A spotless twin 2% smaller or larger does not fit on the 2.742 mas sphere at all (reduced chi-squared 2.49 and 7.42, 3.70 and 9.40), and nor does the star on a sphere 2% off (3.47 and 4.69, 2.65 and 3.46). Against those two twins the spot ratio is 1.51 and 1.40, under the limit. `surface-star.mts` keeps them in the verdict and lets the spottiest twin that fits decide (2.14): were the star such a disc, the first condition would have refused it. Read as the second condition is written above, with every twin within 2%, this map would not be cast.
3. **The star's values against the script's.** The size, limb, tilt, pole direction and period are Table 4 of Martinez et al. (2021). The script the authors ship for these files holds slightly different ones; the table's are used because they are the published ones.
4. **The regularizer.** The paper printed a total-variation weight of 0.01 for its 2021 code. The pinned code states that its weights do not carry over between regularizers, and its authors' script for these files uses `sobel2` at 10 on HEALPix level 3: those settings are used.
5. **Longitude 0** is the meridian that faced the Earth on 14 September 2011, the night with the most data. The star has no defined prime meridian, and the page does not turn it.
6. **What was never seen.** 34° of longitude faced the Earth on none of the nights. ROTIR fills them from its regularizer; the table holds the mean there, and the dataset draws a black line along the edge (`outlineZeroOf` in `tecplot-lonlat-map.ts`). The authors' plots gray the same tiles.
7. **The table** samples ROTIR's 768 tiles at nodes 5° apart, in percent of the mean of the surface seen. It is built by `surface-star.mts`, kept out of git and restored from the source cache, as the brightness maps' tables are.
8. **Color + brightness** draws the star's visible color darker where the infrared map is darker, with the look chosen for the brightness maps (`tinted`, `LIMB_STRENGTH`). The map is of the H band; how dark the spots are in visible light is not measured.

## Sphere maps on a dataset

`surface.jl` writes ROTIR's map on its own coordinates. `surface-dataset.mts` turns it into body east longitude, with ROTIR longitude equal to body longitude minus 90 degrees, and leaves unobserved cells and cells beyond 70 degrees of emission as no data. `surface-dataset.test.mts` measures that convention. It casts the Polaris map through the production camera and compares it with ROTIR's own sky projection: correlation 0.978 for the derived cast and 0.238 for the mirrored one. The same test reads the written map through the image-map loader and checks that the painter asks for body longitude 0 in the column a camera-cast dataset uses.
Expand Down
16 changes: 16 additions & 0 deletions packages/bake/src/objects/raster/tecplot-lonlat-map.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,19 @@ iota('ι Horologii: every deposited epoch reproduces the field energy of the pap
// The strongest radial or azimuthal field in the 18 maps is 16.4 G; the paper's color bar runs to +/-12 G and saturates beyond.
assert.ok(largest > 12 && largest < 17, `${largest}`);
});

test('a map outlines where a column of its own changes sign: the edge of what was never seen', async () => {
const work = await mkdtemp(resolve(tmpdir(), 'tecplot-'));
try {
// The second column is positive east of longitude 135 and negative before it: it crosses zero between the nodes at 90 and 180.
const rows = []; for (let j = 0; j < 3; j++) for (let i = 0; i < 5; i++) rows.push(`${i * 90} ${-90 + j * 90} 1 ${i * 90 - 135}`);
await writeFile(resolve(work, 'map.dat'), `TITLE = "test"\nVARIABLES = "Longitude [Deg]" "Latitude [Deg]" "B [G]" "Facing"\nZONE I=5, J=3, K=1, ZONETYPE=Ordered\nDATAPACKING=POINT\nDT=(SINGLE SINGLE SINGLE SINGLE)\n${rows.join('\n')}\n`);
const map = await loadTecplotLonLatMap(work, { path: 'map.dat', variable: 'B [G]', outlineZeroOf: 'Facing' });
assert.equal(map.outline!(135, 0, 1), true); assert.equal(map.outline!(133, 0, 1), false); assert.equal(map.outline!(137, 0, 1), false);
// Toward a pole a degree of longitude is shorter, so the line takes more of them and keeps its width on the sphere.
assert.equal(map.outline!(135.8, 0, 1), false); assert.equal(map.outline!(135.8, 60, 1), true);
assert.equal(map.report.outlineZeroOf, 'Facing');
assert.equal((await loadTecplotLonLatMap(work, { path: 'map.dat', variable: 'B [G]' })).outline, undefined);
await assert.rejects(loadTecplotLonLatMap(work, { path: 'map.dat', variable: 'B [G]', outlineZeroOf: 'Seen' }), /no column "Seen"/u);
} finally { await rm(work, { recursive: true, force: true }); }
});
20 changes: 16 additions & 4 deletions packages/bake/src/objects/raster/tecplot-lonlat-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
*
* The nodes are checked to form a regular grid from longitude 0 to 360 and latitude -90 to 90, both ends included; the map is
* bilinear between nodes, so every node keeps its deposited value. `outlineLatitudes` (optional) are drawn as thin black lines, for
* a limit the paper draws on its maps, such as the latitude the star never turns toward us. */
* a limit the paper draws on its maps, such as the latitude the star never turns toward us. `outlineZeroOf` (optional) names
* another column of the table, and the same thin line is drawn where that column changes sign: the edge of what a map's own
* coverage column says was never seen. */
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { requireArray, requireFiniteNumber, requireRecord, requireString } from '@cssearth/core';
Expand Down Expand Up @@ -40,6 +42,12 @@ export async function loadTecplotLonLatMap(root: string, value: unknown) {
const column = table.variables.indexOf(variable);
if (column < 2) throw new TypeError(`${path}: no map variable "${variable}"; it has ${table.variables.slice(2).map(v => `"${v}"`).join(', ')}.`);
const node = (i: number, j: number) => table.values[j * table.columns + i]![column]!;
const edgeColumn = dataset.outlineZeroOf === undefined ? -1 : table.variables.indexOf(requireString(dataset.outlineZeroOf, 'outlineZeroOf'));
if (dataset.outlineZeroOf !== undefined && edgeColumn < 2) throw new TypeError(`${path}: no column "${String(dataset.outlineZeroOf)}" to outline the zero of.`);
// The edge column at a place, bilinear between nodes as the map is.
const edge = (longitude: number, latitude: number) => { const x = (((longitude % 360) + 360) % 360) / table.lonStep, y = (Math.max(-90, Math.min(90, latitude)) + 90) / table.latStep;
const i = Math.min(table.columns - 2, Math.floor(x)), j = Math.min(table.rows - 2, Math.floor(y)), dx = x - i, dy = y - j, at = (a: number, b: number) => table.values[b * table.columns + a]![edgeColumn]!;
return at(i, j) * (1 - dx) * (1 - dy) + at(i + 1, j) * dx * (1 - dy) + at(i, j + 1) * (1 - dx) * dy + at(i + 1, j + 1) * dx * dy; };
let minimum = Infinity, maximum = -Infinity;
for (const row of table.values) { minimum = Math.min(minimum, row[column]!); maximum = Math.max(maximum, row[column]!); }
return {
Expand All @@ -49,9 +57,13 @@ export async function loadTecplotLonLatMap(root: string, value: unknown) {
const i = Math.min(table.columns - 2, Math.floor(x)), j = Math.min(table.rows - 2, Math.floor(y)), dx = x - i, dy = y - j;
return node(i, j) * (1 - dx) * (1 - dy) + node(i + 1, j) * dx * (1 - dy) + node(i, j + 1) * (1 - dx) * dy + node(i + 1, j + 1) * dx * dy;
},
...(outlines.length ? { outline(_longitude: number, latitude: number, pixelDegrees: number) {
return outlines.some(limit => Math.abs(latitude - limit) <= pixelDegrees / 2);
...(outlines.length || edgeColumn >= 2 ? { outline(longitude: number, latitude: number, pixelDegrees: number) {
if (outlines.some(limit => Math.abs(latitude - limit) <= pixelDegrees / 2)) return true;
if (edgeColumn < 2 || !Number.isFinite(longitude) || !Number.isFinite(latitude)) return false;
// A pixel is on the edge when the column's sign differs between its sides, east to west or north to south.
const half = pixelDegrees / 2, across = half / Math.max(0.05, Math.cos(latitude * Math.PI / 180));
return edge(longitude - across, latitude) > 0 !== edge(longitude + across, latitude) > 0 || edge(longitude, latitude - half) > 0 !== edge(longitude, latitude + half) > 0;
} } : {}),
report: { format: 'tecplot-lonlat-map', variable, grid: { columns: table.columns, rows: table.rows, stepDegrees: [table.lonStep, table.latStep] }, minimum, maximum, outlineLatitudes: outlines },
report: { format: 'tecplot-lonlat-map', variable, grid: { columns: table.columns, rows: table.rows, stepDegrees: [table.lonStep, table.latStep] }, minimum, maximum, outlineLatitudes: outlines, ...(edgeColumn >= 2 ? { outlineZeroOf: table.variables[edgeColumn] } : {}) },
};
}
3 changes: 2 additions & 1 deletion packages/telescope-cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This package is the layer above the libraries: it imports `@cssearth/telescope`,
`moving-targets.json`, `horizons-bodies.json`) beside that code, still read through `WORKSPACE`; JWST keeps one `jwst/programs/` root: time-series directories and imaging, cube and KLIP JSON files.
Receipts retain their original bytes and are matched by program id, band and pinned product, never the previous directory. The
interferometry reduction (`src/archives/interferometry/`: ALMA restores, VLTI calibration, star imaging) keeps its
toolchain pins (`toolchains.json`), the ROTIR Julia project (`rotir/`), star seasons (`seasons/`) and test fixtures beside
toolchain pins (`toolchains.json`), the ROTIR Julia project (`rotir/`), star seasons (`seasons/`: a sky-plane image of one epoch with SQUEEZE, `cssearth-star-season@1`, or one surface fitted to several nights of a turning star with ROTIR, `cssearth-star-surface-season@1`) and test fixtures beside
it. Per-body authoring (the HST slit-scan map, the JWST band maps, the NACO body map, the ALMA thermal maps, the circumstellar
discs) is `authoring/<archive>/` (exported by no subpath: it imports this package through its own name, never the
other way), a leaf the architecture check enforces; the Io JIRAM maps moved to `packages/bake/authoring/juno/`. Receipts and ledgers record program paths
Expand Down Expand Up @@ -151,6 +151,7 @@ writes the records any map needs (table, manifest input, raster surface, dataset
`route.mts` reads a spec, writes each star and bakes from a `MapRoute`; `routes.mts` lists the kinds (`MAP_ROUTES`). A kind supplies only what is its own: its column,
units, colors, sentences, how its reduction is read and the catalogue records it is bound to. `magnetic/` is the kind for
maps from polarised spectra (`--from-spectra`, a spec's `magneticMaps`; a convention page takes the maps' tilt) and
`resolved/` the kind for a surface fitted to a star's interferometry of several nights (a spec's `resolvedMaps`; `archives/interferometry/surface-star.mts` reduces a season with ROTIR and decides whether its map is cast; a convention page takes the measured tilt and pole direction the map is fitted with, and the table's own coverage column outlines what never faced the Earth), and
`brightness/` the kind for maps from a star's TESS, K2 or Kepler light curves (`--from-pixels`, a spec's `brightnessMaps`; what differs by
mission is one table, `MISSIONS`, which also says whose light curve is read: the mission's own, or KEPSEISMIC; a map's records name the published method that judged it, in the method's own words, and the paper's own light curve; a map drawn on a paper's own verdict says the rotation and its period are that paper's, names the method that refused the same light, and brings no measured period; the page's axis is never
changed, and a period measured here goes into the star's measurements record; `new-object-cli.mts --pixel-light` writes the
Expand Down
Loading
Loading