diff --git a/.gitignore b/.gitignore index c4e537b4078..5178ee901b2 100644 --- a/.gitignore +++ b/.gitignore @@ -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. diff --git a/docs/images/stellar-surface-map-lambda-andromedae.webp b/docs/images/stellar-surface-map-lambda-andromedae.webp new file mode 100644 index 00000000000..bba09848c89 Binary files /dev/null and b/docs/images/stellar-surface-map-lambda-andromedae.webp differ diff --git a/docs/interferometric-imaging.md b/docs/interferometric-imaging.md index 394fe3a4014..b903498fbf5 100644 --- a/docs/interferometric-imaging.md +++ b/docs/interferometric-imaging.md @@ -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; @@ -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. @@ -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. diff --git a/packages/bake/src/objects/raster/tecplot-lonlat-map.test.mts b/packages/bake/src/objects/raster/tecplot-lonlat-map.test.mts index 6968cfab9b4..08271d08afa 100644 --- a/packages/bake/src/objects/raster/tecplot-lonlat-map.test.mts +++ b/packages/bake/src/objects/raster/tecplot-lonlat-map.test.mts @@ -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 }); } +}); diff --git a/packages/bake/src/objects/raster/tecplot-lonlat-map.ts b/packages/bake/src/objects/raster/tecplot-lonlat-map.ts index 54ab64fb08c..a2af948f1b3 100644 --- a/packages/bake/src/objects/raster/tecplot-lonlat-map.ts +++ b/packages/bake/src/objects/raster/tecplot-lonlat-map.ts @@ -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'; @@ -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 { @@ -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] } : {}) }, }; } diff --git a/packages/telescope-cli/AGENTS.md b/packages/telescope-cli/AGENTS.md index d8f4f8457b7..902b9c5c97e 100644 --- a/packages/telescope-cli/AGENTS.md +++ b/packages/telescope-cli/AGENTS.md @@ -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//` (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 @@ -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 diff --git a/packages/telescope-cli/README.md b/packages/telescope-cli/README.md index a67f5a1847a..10999992f87 100644 --- a/packages/telescope-cli/README.md +++ b/packages/telescope-cli/README.md @@ -99,6 +99,20 @@ target whose pixels the TESS Input Catalog gives too much of other stars' light not read. `telescope new-object --from-pixels all --out SPEC.json` then drafts every reduced star's maps as datasets of its page. +A star an interferometer resolved on several nights while it turned gets one surface fitted to all of them with ROTIR +(`src/archives/interferometry/surface-star.mts`), from a season record that pins the calibrated files, the star's published +size, limb, axis and period, and the settings. +[Interferometric imaging](../../docs/interferometric-imaging.md#a-star-that-turns-between-its-nights) gives the checks that +decide whether the map is cast and what in them is this repository's reading. + +```sh +node packages/telescope-cli/src/archives/interferometry/toolchain.mts install rotir +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 spec's `resolvedMaps` (a host, and for each map the season's id, a dataset id and a label) then puts a cast map on the +star's page with `telescope new-object SPEC.json --bake`. + A pulsating star's light through one cycle is not reduced here: it is the model its source publishes. A Cepheid's package keeps its row of Gaia DR3's `vari_cepheid` table, and `telescope new-object --from-pulsation all --out SPEC.json` drafts ten steps of that model, a tenth of a period apart, as one dataset group of the star's page diff --git a/packages/telescope-cli/src/archives/interferometry/rotir/surface.jl b/packages/telescope-cli/src/archives/interferometry/rotir/surface.jl index 9a3fbbf0ef5..d3b46b73ddf 100644 --- a/packages/telescope-cli/src/archives/interferometry/rotir/surface.jl +++ b/packages/telescope-cli/src/archives/interferometry/rotir/surface.jl @@ -1,6 +1,9 @@ # Surface reconstruction of a star on a HEALPix sphere with ROTIR, from key=value arguments. # -# julia --project=packages/telescope-cli/src/archives/interferometry/rotir surface.jl oifits= radius_mas= ld1= map= ... +# julia --project=packages/telescope-cli/src/archives/interferometry/rotir surface.jl oifits=[,...] radius_mas= ld1= map= ... +# +# Several files are several epochs of one rotating star: each is placed at its own mean time after the first, and the star +# turns between them with `rotation_period_days`. One file is one epoch at time 0. # # packages/telescope-cli/src/archives/interferometry/surface-reconstruction.mts writes the arguments and documents each one. This script only runs # the pinned code and writes what it computed: ROTIR's surface map (its own FITS), the same map on a grid of ROTIR's own @@ -17,30 +20,32 @@ need(key) = haskey(args, key) ? args[key] : error("missing argument $key") number(key, default=nothing) = haskey(args, key) ? parse(Float64, args[key]) : (default === nothing ? error("missing argument $key") : default) const T = Float64 -data = readoifits_multiepochs([String(need("oifits"))], warn=false, verbose=false, T=T)[1, :] +data = readoifits_multiepochs(String.(split(need("oifits"), ",")), warn=false, verbose=false, T=T)[1, :] # Squared visibilities and closure phases only: an Inf error is OITOOLS' own way of leaving an observable out. -for field in (:t3amp_err, :visamp_err, :visphi_err) - hasproperty(data[1], field) && (getproperty(data[1], field) .= T(Inf)) +for epoch in data, field in (:t3amp_err, :visamp_err, :visphi_err) + hasproperty(epoch, field) && (getproperty(epoch, field) .= T(Inf)) end +# Days after the first epoch, from each file's own mean time. +tepochs = length(data) == 1 ? [zero(T)] : T.([epoch.mean_mjd - data[1].mean_mjd for epoch in data]) params = (surface_type = 0, radius = number("radius_mas"), tpole = 6000.0, ldtype = Int(number("ld_law", 1)), ld1 = number("ld1"), ld2 = number("ld2", 0.0), inclination = number("inclination"), position_angle = number("position_angle"), rotation_period = number("rotation_period_days", 1.0)) level = Int(number("level", 4)) tessels = tessellation_healpix(level; T=T) -stars = create_star_multiepochs(tessels, params, [zero(T)]) +stars = create_star_multiepochs(tessels, params, tepochs) setup_oi!(data, stars) x0 = parametric_temperature_map(params, stars[1]) npix = length(x0) regtype = String(need("regularizer")) operator = regtype in ("sobel", "sobel2") ? sobel_gradient_healpix(level; T=T) : regtype in ("tv", "tv2") ? tv_neighbors_healpix(level; T=T) : nothing regularizers = regtype == "none" ? [] : [[regtype, number("weight"), operator, 1:npix]] -start = chi2_breakdown(x0, stars[1], data[1]) +start = chi2_breakdown(x0, stars, data) x = image_reconstruct_oi(x0, data, stars; maxiter=Int(number("maxiter", 500)), regularizers=regularizers, verbose=false) -final = chi2_breakdown(x, stars[1], data[1]) +final = chi2_breakdown(x, stars, data) star = stars[1] -save_surface_map(String(need("map")), x, params; nside_exp=level, tepochs=[zero(T)], field=:temperature, +save_surface_map(String(need("map")), x, params; nside_exp=level, tepochs=tepochs, field=:temperature, chi2=final.v2 + final.t3phi, ndata=final.nv2 + final.nt3phi, comment="ROTIR $(regtype) $(get(args, "weight", "0")); V2 and T3PHI only") # The map on ROTIR's own coordinates: row r holds colatitude (r - 1/2) * 180 / rows from the pole, column c longitude @@ -59,15 +64,45 @@ end # The sky as ROTIR sees it: surface brightness times limb darkening, rasterized on its own projection. Columns run East to # West and rows South to North, so the header states CDELT1 negative like an interferometric reconstruction. +# A later epoch's sky goes beside the first, numbered: -2.fits is the second. pixel = number("sky_pixel_mas"); pixels = Int(number("sky_pixels")) -sky = rasterize_polygon_image(star.proj_west, star.proj_north, x .* star.ldmap .* star.vis_weights, pixel, pixels) -FITS(String(need("sky")), "w") do io - # rasterize_polygon_image indexes [north, west]; FITS wants the first axis along columns. - write(io, permutedims(Float64.(sky), (2, 1)); header=FITSHeader(["CDELT1", "CDELT2", "CUNIT1", "CUNIT2"], [-pixel, pixel, "mas", "mas"], ["east left", "north up", "", ""])) +for (k, epoch) in enumerate(stars) + sky = rasterize_polygon_image(epoch.proj_west, epoch.proj_north, x .* epoch.ldmap .* epoch.vis_weights, pixel, pixels) + FITS(k == 1 ? String(need("sky")) : replace(String(need("sky")), r"\.fits$" => "-$k.fits"), "w") do io + # rasterize_polygon_image indexes [north, west]; FITS wants the first axis along columns. + write(io, permutedims(Float64.(sky), (2, 1)); header=FITSHeader(["CDELT1", "CDELT2", "CUNIT1", "CUNIT2"], [-pixel, pixel, "mas", "mas"], ["east left", "north up", "", ""])) + end +end + +# How squarely each tile ever faced the observer: the largest cosine of its emission angle over the epochs, on the map's grid. +facing = vec(maximum(reduce(hcat, [epoch.normals[:, 3] for epoch in stars]), dims=2)) +if haskey(args, "coverage") + seen = Array{Float32}(undef, grid_columns, grid_rows) + for r in 1:grid_rows, c in 1:grid_columns + seen[c, r] = Float32(facing[argmax(tile_vectors' * direction((r - 0.5) / grid_rows * π, (c - 0.5) / grid_columns * 2π))]) + end + FITS(String(args["coverage"]), "w") do io + write(io, seen; header=FITSHeader(["CTYPE1", "CTYPE2", "ROWORDER"], ["ROTIR-LONGITUDE", "ROTIR-COLATITUDE", "pole-first"], ["right-handed about the pole", "from the pole", "FITS row 1 is colatitude 0"])) + end end +# Where the observer and the sky's North and West lie in the star's own frame at each epoch, solved from the tiles ROTIR +# projected (a tile's normal toward the observer, and its centre's place on the sky, are its body direction times each). +body(values) = (tile_vectors * tile_vectors') \ (tile_vectors * values) +# The same for a quantity ROTIR keeps at the four corners of each tile: column k of the corners, tile by tile. +corner_vectors = reduce(hcat, [direction(tessels.unit_spherical[i, k, 2], tessels.unit_spherical[i, k, 3]) for k in 1:4 for i in 1:npix]) +corners(values) = (corner_vectors * corner_vectors') \ (corner_vectors * vec(values)) +angles(v) = (acosd(clamp(v[3] / norm(v), -1, 1)), mod(atand(v[2], v[1]), 360)) -visible = findall(star.normals[:, 3] .> 0) +visible = findall(facing .> 0) open(String(need("summary")), "w") do io @printf(io, "tiles=%d\nvisible_tiles=%d\nvis2=%d\nt3phi=%d\nstart_chi2r_vis2=%.6f\nstart_chi2r_t3phi=%.6f\nchi2r_vis2=%.6f\nchi2r_t3phi=%.6f\ncontrast=%.6f\n", npix, length(visible), final.nv2, final.nt3phi, start.v2r, start.t3phir, final.v2r, final.t3phir, std(x[visible]) / mean(x[visible])) + @printf(io, "epochs=%d\n", length(stars)) + for (k, epoch) in enumerate(stars) + observer = angles(body(epoch.normals[:, 3])); north = corners(epoch.proj_north); west = corners(epoch.proj_west) + # The pole on the sky: its angle from North through East (East is minus West), and how far it leans toward the observer. + @printf(io, "epoch_days_%d=%.6f\nobserver_colatitude_%d=%.6f\nobserver_longitude_%d=%.6f\npole_position_angle_%d=%.6f\n", + k, tepochs[k], k, observer[1], k, observer[2], k, mod(atand(-west[3], north[3]), 360)) + @printf(io, "epoch_chi2r_vis2_%d=%.6f\nepoch_chi2r_t3phi_%d=%.6f\n", k, final.epochs[k].v2r, k, final.epochs[k].t3phir) + end end diff --git a/packages/telescope-cli/src/archives/interferometry/seasons/udkadua-mirc-2011-09/season.json b/packages/telescope-cli/src/archives/interferometry/seasons/udkadua-mirc-2011-09/season.json new file mode 100644 index 00000000000..892af564b2f --- /dev/null +++ b/packages/telescope-cli/src/archives/interferometry/seasons/udkadua-mirc-2011-09/season.json @@ -0,0 +1,134 @@ +{ + "schema": "cssearth-star-surface-season@1", + "id": "udkadua-mirc-2011-09", + "object": "udkadua", + "title": "λ Andromedae, CHARA/MIRC, six nights from 2 to 24 September 2011 (Parks et al. 2021; Martinez et al. 2021)", + "target": "λ Andromedae", + "instrument": "CHARA/MIRC", + "band": "infrared H band (1.49-1.73 um, eight channels)", + "papers": [ + { + "record": "arxiv-2107-06366", + "citation": "Martinez et al. (2021), ApJ 916, 60", + "title": "Martinez et al. (2021): Dynamical Surface Imaging of λ Andromedae", + "arxiv": "2107.06366", + "doi": "10.3847/1538-4357/ac06a5", + "creators": [ + "A. O. Martinez", + "F. R. Baron", + "J. D. Monnier", + "R. M. Roettenbacher", + "J. R. Parks" + ], + "year": "2021", + "locator": "arXiv listing and Crossref: title, authors, DOI. Table 4 gives the star's angular radius, limb-darkening coefficient, inclination, position angle and rotation period; section V describes the ROTIR reconstruction of the 2010 and 2011 nights." + }, + { + "record": "arxiv-1508-04755", + "citation": "Parks et al. (2021), ApJ 913, 54", + "title": "Parks et al. (2021): Interferometric Imaging of λ Andromedae: Evidence of Starspots and Rotation", + "arxiv": "1508.04755", + "doi": "10.3847/1538-4357/abb670", + "creators": [ + "J. R. Parks", + "R. J. White", + "F. Baron", + "J. D. Monnier", + "B. Kloppenborg", + "G. W. Henry", + "et al." + ], + "year": "2021", + "locator": "arXiv listing and Crossref: title, authors, DOI. Table 1 lists the six nights of September 2011 and their numbers of points; section III.2 describes the SQUEEZE images." + } + ], + "data": { + "record": "rotir-demos-lambda-andromedae-mirc-2011", + "title": "λ Andromedae, CHARA/MIRC calibrated visibilities of six nights of September 2011, as shipped with ROTIR.jl", + "url": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data", + "publisher": "F. Baron, ROTIR.jl (GitHub)", + "license": "Shipped in the public ROTIR.jl repository (GPL-3.0); CHARA Array data, acknowledge the array and the papers", + "locator": "Six files 2011Sep02 to 2011Sep24 .lam_And_prepped.oifits under demos/data at the pinned commit, read by demos/lam_and_reconstruction.jl there; their numbers of squared visibilities match Parks et al. (2021), Table 1.", + "credit": "CHARA Array/MIRC, September 2011, as calibrated by the authors of Parks et al. (2021) and Martinez et al. (2021) and shipped with ROTIR.jl (F. Baron)" + }, + "filesSource": "The calibrated files the authors ship with ROTIR.jl at the commit this repository pins (demos/data), the six nights of 2011 both papers image.", + "nights": [ + { + "night": "2011-09-02", + "file": "2011Sep02.lam_And_prepped.oifits", + "origin": "https://raw.githubusercontent.com/fabienbaron/ROTIR.jl/ad308759b741b861b6c19fedd001c323a3a64479/demos/data/2011Sep02.lam_And_prepped.oifits", + "bytes": 141120 + }, + { + "night": "2011-09-06", + "file": "2011Sep06.lam_And_prepped.oifits", + "origin": "https://raw.githubusercontent.com/fabienbaron/ROTIR.jl/ad308759b741b861b6c19fedd001c323a3a64479/demos/data/2011Sep06.lam_And_prepped.oifits", + "bytes": 167040 + }, + { + "night": "2011-09-10", + "file": "2011Sep10.lam_And_prepped.oifits", + "origin": "https://raw.githubusercontent.com/fabienbaron/ROTIR.jl/ad308759b741b861b6c19fedd001c323a3a64479/demos/data/2011Sep10.lam_And_prepped.oifits", + "bytes": 112320 + }, + { + "night": "2011-09-14", + "file": "2011Sep14.lam_And_prepped.oifits", + "origin": "https://raw.githubusercontent.com/fabienbaron/ROTIR.jl/ad308759b741b861b6c19fedd001c323a3a64479/demos/data/2011Sep14.lam_And_prepped.oifits", + "bytes": 279360 + }, + { + "night": "2011-09-19", + "file": "2011Sep19.lam_And_prepped.oifits", + "origin": "https://raw.githubusercontent.com/fabienbaron/ROTIR.jl/ad308759b741b861b6c19fedd001c323a3a64479/demos/data/2011Sep19.lam_And_prepped.oifits", + "bytes": 282240 + }, + { + "night": "2011-09-24", + "file": "2011Sep24.lam_And_prepped.oifits", + "origin": "https://raw.githubusercontent.com/fabienbaron/ROTIR.jl/ad308759b741b861b6c19fedd001c323a3a64479/demos/data/2011Sep24.lam_And_prepped.oifits", + "bytes": 77760 + } + ], + "star": { + "diameterMas": { + "value": 2.742, + "source": "Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): angular radius 1.371 +/- 0.005 mas" + }, + "limbPowerLaw": { + "value": 0.231, + "source": "Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): limb-darkening coefficient 0.231 +/- 0.024, power law" + }, + "inclinationDegrees": { + "value": 85.63, + "source": "Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): inclination 85.63 +/- 2.32 degrees" + }, + "positionAngleDegrees": { + "value": 26.09, + "source": "Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): position angle 26.09 +/- 0.82 degrees" + }, + "rotationPeriodDays": { + "value": 54.2, + "source": "Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): rotation period 54.2 d, held fixed in the fit" + } + }, + "reconstruction": { + "code": "rotir", + "level": 3, + "regularizer": "sobel2", + "weight": 10, + "iterations": 1000, + "source": "demos/lam_and_reconstruction.jl in ROTIR.jl at the pinned commit, the authors script for these six files: HEALPix level 3, sobel2 at weight 10, 1000 iterations. The paper printed a total-variation weight of 0.01 for its 2021 code; the pinned code states that its regularizer weights do not carry over." + }, + "referenceNight": "2011-09-14", + "referenceNightSource": "A display choice of this repository: the night with the most squared visibilities (864).", + "check": { + "twinScales": [ + 0.98, + 0.99, + 1, + 1.01, + 1.02 + ] + } +} diff --git a/packages/telescope-cli/src/archives/interferometry/spotless-disc.mts b/packages/telescope-cli/src/archives/interferometry/spotless-disc.mts index b18471f22eb..631eff81802 100644 --- a/packages/telescope-cli/src/archives/interferometry/spotless-disc.mts +++ b/packages/telescope-cli/src/archives/interferometry/spotless-disc.mts @@ -18,6 +18,7 @@ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { convolveGaussian, readReconstruction } from '@cssearth/bake/objects/layers/observation'; import { binaryTable, numbers, readFitsHdus, tableColumn, writeCell, writeComplexCell, type BinaryTable, type TableColumn } from '@cssearth/bake/objects/raster'; +import { limbDarkenedDiscVisibility } from './alma-disc-selfcal.mts'; const MAS_RAD = Math.PI / 180 / 3.6e6; @@ -46,15 +47,16 @@ export function normalStream(seed: number) { return () => Math.sqrt(-2 * Math.log(uniform())) * Math.cos(2 * Math.PI * uniform()); } -export interface SpotlessOptions { readonly diameterMas: number; readonly limbDarkening?: number; readonly seed?: number } +export interface SpotlessOptions { readonly diameterMas: number; readonly limbDarkening?: number; readonly seed?: number; + /** The exponent of a power-law limb, I(mu) = mu^alpha (Hestroffer 1997), in place of the linear law. */ readonly powerLaw?: number } /** The OIFITS bytes with every squared visibility, closure phase and OI_VIS amplitude and phase replaced by the spotless * disc's, noise included. Flags, errors, (u, v) and every other table are kept, so the reconstruction sees the same sampling and * weights. OI_VIS follows its table's declared types: an absolute amplitude is the disc's |V|, a differential or unstated one is * |V| over the row's mean, and a correlated flux is left as measured; the phase is the disc's own 0 or 180 degrees, which a * differential phase's offset and slope do not change. Complex VISDATA and GRAVITY's RVIS and IVIS follow the same values. */ -export function simulateSpotlessDisc(input: Buffer, { diameterMas, limbDarkening = 0, seed = 1 }: SpotlessOptions) { - if (!(diameterMas > 0) || !(limbDarkening >= 0 && limbDarkening <= 1)) throw new TypeError('A spotless disc needs a positive diameter and a limb-darkening coefficient between 0 and 1.'); +export function simulateSpotlessDisc(input: Buffer, { diameterMas, limbDarkening = 0, seed = 1, powerLaw }: SpotlessOptions) { + if (!(diameterMas > 0) || !(limbDarkening >= 0 && limbDarkening <= 1) || (powerLaw !== undefined && !(powerLaw >= 0))) throw new TypeError('A spotless disc needs a positive diameter and a limb-darkening coefficient between 0 and 1, or a power-law exponent that is not negative.'); const bytes = Buffer.from(input), hdus = readFitsHdus(bytes), noise = normalStream(seed); const wavelengths = new Map(); for (const hdu of hdus.filter(hdu => hdu.extname === 'OI_WAVELENGTH')) { @@ -62,7 +64,8 @@ export function simulateSpotlessDisc(input: Buffer, { diameterMas, limbDarkening wavelengths.set(String(hdu.header.INSNAME), Array.from({ length: table.rows }, (_, row) => numbers(bytes, table, row, column)[0]!)); } const channels = (table: BinaryTable) => { const list = wavelengths.get(String(table.hdu.header.INSNAME)); if (!list) throw new Error(`No OI_WAVELENGTH for ${String(table.hdu.header.INSNAME)}.`); return list; }; - const visibility = (u: number, v: number, wavelength: number) => limbDarkenedVisibility(Math.hypot(u, v), wavelength, diameterMas, limbDarkening); + const visibility = (u: number, v: number, wavelength: number) => powerLaw === undefined ? limbDarkenedVisibility(Math.hypot(u, v), wavelength, diameterMas, limbDarkening) + : limbDarkenedDiscVisibility(Math.hypot(u, v) / wavelength, diameterMas * MAS_RAD, powerLaw, 200); let vis2 = 0, t3 = 0; for (const hdu of hdus.filter(hdu => hdu.extname === 'OI_VIS2')) { const table = binaryTable(hdu), list = channels(table); diff --git a/packages/telescope-cli/src/archives/interferometry/surface-reconstruction.mts b/packages/telescope-cli/src/archives/interferometry/surface-reconstruction.mts index 64010d7ef8d..55761a12013 100644 --- a/packages/telescope-cli/src/archives/interferometry/surface-reconstruction.mts +++ b/packages/telescope-cli/src/archives/interferometry/surface-reconstruction.mts @@ -2,9 +2,12 @@ /** Reconstruct a star's surface on a sphere with the pinned ROTIR toolchain, and write the star as ROTIR projects it on the sky * so the result can be checked with the same tools as a sky-plane image (spotless-disc.mts). * - * node packages/telescope-cli/src/archives/interferometry/surface-reconstruction.mts --diameter-mas - * [--limb-darkening ] [--inclination ] [--position-angle ] [--regularizer tv|sobel2|none] [--weight ] - * [--level ] [--iterations ] [--sky-pixel-mas

] [--sky-pixels ] + * node packages/telescope-cli/src/archives/interferometry/surface-reconstruction.mts [,...] --diameter-mas + * [--limb-darkening ] [--limb-law linear|power] [--inclination ] [--position-angle ] [--rotation-period-days

] + * [--regularizer tv|sobel2|none] [--weight ] [--level ] [--iterations ] [--sky-pixel-mas

] [--sky-pixels ] + * + * Several files are several nights of one turning star: ROTIR places each at its own mean time and turns the star between + * them with the rotation period, so one surface is fitted to all of them (the way Martinez et al. 2021 mapped λ Andromedae). * * Why a sphere: a flat reconstruction may put light anywhere in its field, so a star a few beams across (Polaris, 3.1 mas at * 0.55 mas) has too many unknowns for its data. On a sphere of known size and limb darkening only the surface brightness of the @@ -23,24 +26,34 @@ import { requireString } from '@cssearth/core'; export interface SurfaceOptions { readonly diameterMas: number; readonly limbDarkening?: number; readonly inclinationDegrees?: number; readonly positionAngleDegrees?: number; + /** The law `limbDarkening` is the coefficient of: linear, I = 1 - u (1 - mu), or the power law I = mu^u (Hestroffer 1997). */ + readonly limbLaw?: 'linear' | 'power'; + /** The rotation period that turns the star between several files' nights, and whether to write how squarely each cell ever + * faced the observer (surface-coverage.fits). */ + readonly rotationPeriodDays?: number; readonly coverage?: boolean; readonly threads?: number; readonly regularizer?: 'tv' | 'sobel2' | 'none'; readonly weight?: number; readonly level?: number; readonly iterations?: number; readonly skyPixelMas?: number; readonly skyPixels?: number; readonly gridColumns?: number; readonly gridRows?: number; } /** The key=value arguments surface.jl reads, in a fixed order so a run is reproducible from its record. */ -export function surfaceArguments(oifits: string, directory: string, options: SurfaceOptions) { +export function surfaceArguments(oifits: string | readonly string[], directory: string, options: SurfaceOptions) { const { diameterMas, limbDarkening = 0, inclinationDegrees = 90, positionAngleDegrees = 0, regularizer = 'tv', weight = 0.05, level = 4, iterations = 500 } = options; + const files = typeof oifits === 'string' ? [oifits] : oifits; + if (!files.length || files.some(file => file.includes(','))) throw new TypeError('A surface reconstruction reads one or more files, none with a comma in its path.'); + if (files.length > 1 && !(options.rotationPeriodDays! > 0)) throw new RangeError('Several nights of one star need its rotation period.'); if (!(diameterMas > 0) || !(limbDarkening >= 0 && limbDarkening <= 1) || !Number.isInteger(level) || level < 2 || level > 7 || !Number.isInteger(iterations) || iterations < 1) { throw new RangeError('A surface reconstruction needs a positive diameter, a linear limb darkening between 0 and 1, a HEALPix level 2-7 and whole iterations.'); } // The sky render spans twice the disc by default, at a tenth of the diameter per pixel or finer. const skyPixelMas = options.skyPixelMas ?? diameterMas / 64, skyPixels = options.skyPixels ?? 128; return [ - `oifits=${resolve(oifits)}`, `radius_mas=${diameterMas / 2}`, 'ld_law=1', `ld1=${limbDarkening}`, `inclination=${inclinationDegrees}`, `position_angle=${positionAngleDegrees}`, + `oifits=${files.map(file => resolve(file)).join(',')}`, `radius_mas=${diameterMas / 2}`, `ld_law=${options.limbLaw === 'power' ? 3 : 1}`, `ld1=${limbDarkening}`, `inclination=${inclinationDegrees}`, `position_angle=${positionAngleDegrees}`, + ...(options.rotationPeriodDays === undefined ? [] : [`rotation_period_days=${options.rotationPeriodDays}`]), `regularizer=${regularizer}`, `weight=${weight}`, `level=${level}`, `maxiter=${iterations}`, `map=${resolve(directory, 'surface-map.fits')}`, `grid=${resolve(directory, 'surface-grid.fits')}`, `grid_columns=${options.gridColumns ?? 360}`, `grid_rows=${options.gridRows ?? 180}`, `sky=${resolve(directory, 'surface-sky.fits')}`, `sky_pixel_mas=${skyPixelMas}`, `sky_pixels=${skyPixels}`, `summary=${resolve(directory, 'surface-summary.txt')}`, + ...(options.coverage ? [`coverage=${resolve(directory, 'surface-coverage.fits')}`] : []), ]; } @@ -56,11 +69,11 @@ export function parseSurfaceSummary(text: string) { return values; } -export async function reconstructSurface(oifits: string, directory: string, options: SurfaceOptions) { +export async function reconstructSurface(oifits: string | readonly string[], directory: string, options: SurfaceOptions) { const root = await toolchainPath('rotir'), { entry } = await toolchainDescriptor('rotir'); await mkdir(directory, { recursive: true }); const script = resolve(import.meta.dirname, 'rotir/surface.jl'); - const run = spawnSync(resolve(root, requireString(entry.executable)), ['--threads=8', `--project=${toolchainEnvironment(entry)}`, script, ...surfaceArguments(oifits, directory, options)], + const run = spawnSync(resolve(root, requireString(entry.executable)), [`--threads=${options.threads ?? 8}`, `--project=${toolchainEnvironment(entry)}`, script, ...surfaceArguments(oifits, directory, options)], { env: { ...process.env, JULIA_DEPOT_PATH: resolve(root, 'depot') }, stdio: ['ignore', 'inherit', 'pipe'], encoding: 'utf8' }); // ROTIR prints five warnings about OITOOLS bindings at load; they are known and harmless on the pinned commits. const errors = String(run.stderr).split('\n').filter(line => line.trim() && !/Imported binding OITOOLS\.visibility_|^\s*[└│┌]\s*$/u.test(line)); @@ -75,7 +88,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1] const diameterMas = number('--diameter-mas'); if (!oifits || !directory || diameterMas === undefined) throw new TypeError('Usage: surface-reconstruction --diameter-mas [options]'); const pick = (key: string, value: T | undefined) => value === undefined ? {} : { [key]: value }; - const result = await reconstructSurface(oifits, directory, { diameterMas, ...pick('limbDarkening', number('--limb-darkening')), ...pick('inclinationDegrees', number('--inclination')), + const result = await reconstructSurface(oifits.split(','), directory, { diameterMas, ...pick('limbDarkening', number('--limb-darkening')), ...pick('limbLaw', option('--limb-law') as SurfaceOptions['limbLaw']), ...pick('rotationPeriodDays', number('--rotation-period-days')), ...pick('inclinationDegrees', number('--inclination')), ...pick('positionAngleDegrees', number('--position-angle')), ...pick('regularizer', option('--regularizer') as SurfaceOptions['regularizer']), ...pick('weight', number('--weight')), ...pick('level', number('--level')), ...pick('iterations', number('--iterations')), ...pick('skyPixelMas', number('--sky-pixel-mas')), ...pick('skyPixels', number('--sky-pixels')) }); console.log(`${result.directory}: reduced chi-squared ${result.summary.chi2r_vis2!.toFixed(2)} on squared visibilities and ${result.summary.chi2r_t3phi!.toFixed(2)} on closure phases; surface contrast ${(result.summary.contrast! * 100).toFixed(2)}%.`); diff --git a/packages/telescope-cli/src/archives/interferometry/surface-star.mts b/packages/telescope-cli/src/archives/interferometry/surface-star.mts new file mode 100644 index 00000000000..cea6d27599f --- /dev/null +++ b/packages/telescope-cli/src/archives/interferometry/surface-star.mts @@ -0,0 +1,207 @@ +#!/usr/bin/env node +/** One command from a star's calibrated interferometry over several nights to a verdict on its surface map. + * + * node packages/telescope-cli/src/archives/interferometry/surface-star.mts + * + * image-star.mts makes a sky-plane image of one epoch with SQUEEZE. A star that turns between its nights has no single sky + * image: this command fits one surface to all the nights with ROTIR, the way the star's authors did. The season + * (seasons//season.json, schema cssearth-star-surface-season@1) pins everything a person chose: the calibrated files and + * where they are published, the star's size, limb, axis and period, each with the paper cell it is read from, and the + * reconstruction settings with their source. The run: + * + * 1. Fetch the calibrated files a checkout lacks, each checked against its recorded size. + * 2. Twins. A spotless limb-darkened disc on every night's own sampling and errors (spotless-disc.mts) at the season's twin + * sizes, the two interleaved halves of every night, and the same halves of the spotless disc. + * 3. Reconstruct. ROTIR with the season's settings on the nights, on each twin and on each half: ten runs, one at a time. + * 4. Check, with image-star's three checks and limits, on the star as ROTIR projects it on the sky at every night, the nights + * taken together: the fit, the spots against the spottiest spotless twin, and the two halves once each half's twin is + * subtracted. One thing differs from a sky-plane image, and is this repository's reading, not a paper's: SQUEEZE is free + * to draw a disc of any size, while ROTIR's sphere has the size it is given, so a spotless twin of another size may not fit + * on it at all. A twin whose own reconstruction fails the fit limit is kept in the verdict and does not decide the spots: + * were the star that disc, the first check would have refused the star. + * 5. Write the map as a table on the star's own longitudes and latitudes, longitude 0 on the meridian that faced the Earth on + * the season's reference night, with how squarely each node ever faced the observer (from where ROTIR put the observer + * on each night); a node that never did holds the mean. + * + * The result is verdict.json and, whatever the verdict, .dat in the work directory. */ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { requireArray, requireFiniteNumber, requireRecord, requireString } from '@cssearth/core'; +import { readChannelRows } from '@cssearth/bake/objects/layers/observation'; +import { readFitsImage } from '@cssearth/fits'; +import { TWIN_SCALES } from './image-star.mts'; +import { selectOifits } from './oifits-select.mts'; +import { compareSpotMaps, RECONSTRUCTION_CHI2_LIMIT, reconstructionVerdict, reproducibility, simulateSpotlessDisc, spotMap, type ReconstructionPlane } from './spotless-disc.mts'; +import { readSurfaceGrid, sampleSurfaceGrid } from './surface-dataset.mts'; +import { reconstructSurface, type SurfaceOptions } from './surface-reconstruction.mts'; +import { toolchainDescriptor } from './toolchain.mts'; + +export const SURFACE_SEASON_SCHEMA = 'cssearth-star-surface-season@1'; +export const SURFACE_MAP_SCHEMA = 'cssearth-star-surface-map@1'; + +export interface Cited { readonly value: number; readonly source: string } +/** A paper the season rests on, and where the calibrated files are published: what the source catalogue records of each. */ +export interface SeasonPaper { readonly record: string; readonly citation: string; readonly title: string; readonly arxiv: string; readonly doi: string; readonly creators: readonly string[]; readonly year: string; readonly locator: string } +export interface SeasonData { readonly record: string; readonly title: string; readonly url: string; readonly publisher: string; readonly license: string; readonly locator: string; readonly credit: string } +export interface SurfaceNight { readonly night: string; readonly file: string; readonly origin: string; readonly bytes: number } +export interface SurfaceSeason { + readonly id: string; readonly object: string; readonly title: string; readonly target: string; readonly instrument: string; readonly band: string; readonly nights: readonly SurfaceNight[]; + readonly papers: readonly SeasonPaper[]; readonly data: SeasonData; + readonly star: { readonly diameterMas: Cited; readonly limbPowerLaw: Cited; readonly inclinationDegrees: Cited; readonly positionAngleDegrees: Cited; readonly rotationPeriodDays: Cited }; + readonly recipe: { readonly level: number; readonly regularizer: 'sobel2' | 'tv'; readonly weight: number; readonly iterations: number; readonly source: string }; + /** The night whose Earth-facing meridian is the map's longitude 0. */ + readonly referenceNight: string; readonly twinScales: readonly number[]; +} + +const NIGHT = /^\d{4}-\d\d-\d\d$/u; + +export function parseSurfaceSeason(value: unknown): SurfaceSeason { + const record = requireRecord(value, 'surface season'); + if (record.schema !== SURFACE_SEASON_SCHEMA) throw new TypeError('Unexpected surface season schema.'); + const cited = (entry: unknown, name: string): Cited => { const one = requireRecord(entry, name); return { value: requireFiniteNumber(one.value, `${name} value`), source: requireString(one.source, `${name} source`) }; }; + const nights = requireArray(record.nights, 'nights').map((entry, i): SurfaceNight => { const night = requireRecord(entry, `nights[${i}]`); + const one = { night: requireString(night.night, 'night'), file: requireString(night.file, 'file'), origin: requireString(night.origin, 'origin'), bytes: requireFiniteNumber(night.bytes, 'bytes') }; + if (!NIGHT.test(one.night) || one.file.includes('/') || one.file.includes(',') || !/^https:\/\//u.test(one.origin) || !(one.bytes > 0)) throw new TypeError(`Night ${one.night}: a date, a plain file name, an https origin and its size.`); + return one; }); + if (nights.length < 2 || new Set(nights.map(night => night.night)).size !== nights.length || nights.some((night, i) => i > 0 && night.night <= nights[i - 1]!.night)) throw new TypeError('A surface season is two or more nights in order, each once.'); + const star = requireRecord(record.star, 'star'), recipe = requireRecord(record.reconstruction, 'reconstruction'), check = requireRecord(record.check ?? {}, 'check'); + if (recipe.code !== 'rotir') throw new TypeError('A surface season reconstructs with ROTIR.'); + const regularizer = requireString(recipe.regularizer, 'regularizer'); + if (regularizer !== 'sobel2' && regularizer !== 'tv') throw new TypeError(`No regularizer ${regularizer}.`); + const referenceNight = requireString(record.referenceNight, 'referenceNight'); + if (!nights.some(night => night.night === referenceNight)) throw new TypeError(`The reference night ${referenceNight} is not one of the season's.`); + const text = (entry: Record, keys: readonly string[], what: string) => Object.fromEntries(keys.map(key => [key, requireString(entry[key], `${what} ${key}`)])); + const papers = requireArray(record.papers, 'papers').map((entry, i) => { const paper = requireRecord(entry, `papers[${i}]`); + return { ...text(paper, ['record', 'citation', 'title', 'arxiv', 'doi', 'year', 'locator'], `papers[${i}]`), creators: requireArray(paper.creators, 'creators').map(name => requireString(name, 'creator')) } as unknown as SeasonPaper; }); + if (!papers.length) throw new TypeError('A surface season names the papers whose data and method it follows.'); + const data = text(requireRecord(record.data, 'data'), ['record', 'title', 'url', 'publisher', 'license', 'locator', 'credit'], 'data') as unknown as SeasonData; + const parsed: SurfaceSeason = { id: requireString(record.id, 'id'), object: requireString(record.object, 'object'), title: requireString(record.title, 'title'), target: requireString(record.target, 'target'), instrument: requireString(record.instrument, 'instrument'), band: requireString(record.band, 'band'), nights, papers, data, + star: { diameterMas: cited(star.diameterMas, 'diameterMas'), limbPowerLaw: cited(star.limbPowerLaw, 'limbPowerLaw'), inclinationDegrees: cited(star.inclinationDegrees, 'inclinationDegrees'), positionAngleDegrees: cited(star.positionAngleDegrees, 'positionAngleDegrees'), rotationPeriodDays: cited(star.rotationPeriodDays, 'rotationPeriodDays') }, + recipe: { level: requireFiniteNumber(recipe.level, 'level'), regularizer, weight: requireFiniteNumber(recipe.weight, 'weight'), iterations: requireFiniteNumber(recipe.iterations, 'iterations'), source: requireString(recipe.source, 'reconstruction source') }, + referenceNight, twinScales: check.twinScales === undefined ? TWIN_SCALES : requireArray(check.twinScales, 'twinScales').map(scale => requireFiniteNumber(scale, 'twin scale')) }; + if (!(parsed.star.diameterMas.value > 0) || !(parsed.star.limbPowerLaw.value >= 0) || !(parsed.star.rotationPeriodDays.value > 0) || !(parsed.star.inclinationDegrees.value > 0 && parsed.star.inclinationDegrees.value <= 90)) throw new RangeError('The star needs a positive diameter and period, a limb exponent that is not negative and an inclination up to 90 degrees.'); + return parsed; +} + +/** The step of the table's nodes, in degrees: finer than a level-3 HEALPix tile (7.3 degrees), as the magnetic maps' tables are. */ +export const TABLE_STEP_DEGREES = 5; +export const BRIGHTNESS_VARIABLE = 'Brightness [%]', FACING_VARIABLE = 'Facing'; + +/** How squarely a place on the star ever faced the observer: the largest cosine of its emission angle over the nights, from + * where ROTIR put the observer on each (its colatitude and longitude in the star's frame). Negative: never seen. */ +export function facingOf(observers: readonly { readonly colatitude: number; readonly longitude: number }[]) { + const radians = Math.PI / 180; + return (longitude: number, latitude: number) => Math.max(...observers.map(observer => Math.sin(latitude * radians) * Math.cos(observer.colatitude * radians) + Math.cos(latitude * radians) * Math.sin(observer.colatitude * radians) * Math.cos((longitude - observer.longitude) * radians))); +} + +/** The map as a Tecplot table the raster recipe reads (tecplot-lonlat-map.ts). `grid` is surface.jl's, on ROTIR's longitudes, + * and `facing` says how squarely a place on them ever faced the observer; `observerLongitude` is ROTIR's longitude of the + * meridian facing the observer on the reference night, which becomes longitude 0. Brightness is in percent of the mean over + * the nodes that ever faced the observer, each weighted by its area; a node that never did holds 100. */ +export function surfaceTable(grid: { readonly columns: number; readonly rows: number; readonly values: ArrayLike }, facing: (longitude: number, latitude: number) => number, observerLongitude: number, title: string) { + const step = TABLE_STEP_DEGREES, columns = 360 / step + 1, rows = 180 / step + 1, nodes: { longitude: number; latitude: number; value: number; facing: number }[] = []; + // A node on a pole or on the closing meridian reads the cell just inside the grid. + const inside = (latitude: number) => Math.min(180 - 1e-6, Math.max(1e-6, 90 - latitude)); + for (let j = 0; j < rows; j++) for (let i = 0; i < columns; i++) { const longitude = i * step, latitude = -90 + j * step, rotir = longitude % 360 + observerLongitude; + nodes.push({ longitude, latitude, value: sampleSurfaceGrid(grid, inside(latitude), rotir), facing: facing(rotir, latitude) }); } + let sum = 0, weight = 0; + for (const node of nodes) if (node.facing > 0 && node.longitude < 360) { const area = Math.cos(node.latitude * Math.PI / 180); sum += node.value * area; weight += area; } + if (!(weight > 0) || !(sum > 0)) throw new Error('No node of the map ever faced the observer.'); + const mean = sum / weight, lines = [`TITLE = "${title.replaceAll('"', "'")}"`, `VARIABLES = "Longitude [Deg]" "Latitude [Deg]" "${BRIGHTNESS_VARIABLE}" "${FACING_VARIABLE}"`, `ZONE I=${columns}, J=${rows}, K=1, ZONETYPE=Ordered`, 'DATAPACKING=POINT', 'DT=(SINGLE SINGLE SINGLE SINGLE)']; + let minimum = Infinity, maximum = -Infinity, seen = 0; + for (const node of nodes) { const percent = node.facing > 0 ? node.value / mean * 100 : 100; if (node.facing > 0) { minimum = Math.min(minimum, percent); maximum = Math.max(maximum, percent); seen++; } + lines.push(`${node.longitude.toFixed(1)} ${node.latitude.toFixed(1)} ${percent.toFixed(3)} ${node.facing.toFixed(4)}`); } + return { text: `${lines.join('\n')}\n`, minimumPercent: Number(minimum.toFixed(3)), maximumPercent: Number(maximum.toFixed(3)), nodes: nodes.length, seenNodes: seen }; +} + +/** The spotless twin the spot check is taken against: the spottiest of those whose own reconstruction fits on the sphere. The + * twin of the sphere's own size must be one of them, or the run checks nothing. */ +export function decidingTwin(twins: readonly T[]): T { + const fitting = twins.filter(twin => twin.fits); + if (!fitting.some(twin => twin.scale === 1)) throw new Error('The spotless twin of the season\'s own size does not fit on its sphere: the run is not a check.'); + return fitting.reduce((worst, twin) => twin.ratio < worst.ratio ? twin : worst); +} + +const exists = (path: string) => stat(path).then(() => true, () => false); +/** The star as surface.jl rasterised it on the sky: a plain image with its pixel size in CDELT2, in milliarcseconds. */ +export function readSurfaceSky(bytes: Buffer): ReconstructionPlane { + const image = readFitsImage(bytes), pixelMas = Math.abs(requireFiniteNumber(image.header.CDELT2, 'CDELT2')); + if (image.header.CUNIT2 !== 'mas' || !(pixelMas > 0)) throw new TypeError('Not a sky image written by surface.jl.'); + return { width: image.width, height: image.height, values: image.values, pixelMas }; +} + +export async function surfaceStar(seasonDirectory: string, work: string, { threads = 4 }: { threads?: number } = {}) { + const season = parseSurfaceSeason(JSON.parse(await readFile(resolve(seasonDirectory, 'season.json'), 'utf8')) as unknown); + await mkdir(resolve(work, 'data'), { recursive: true }); + + // 1. Fetch. + for (const night of season.nights) { const path = resolve(work, 'data', night.file); + if ((await stat(path).catch(() => null))?.size !== night.bytes) { const response = await fetch(night.origin); + if (!response.ok) throw new Error(`${night.origin} answered ${response.status}.`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length !== night.bytes) throw new Error(`${night.file} is ${bytes.length} bytes at its origin, not the recorded ${night.bytes}.`); + await writeFile(path, bytes); } } + const real = await Promise.all(season.nights.map(night => readFile(resolve(work, 'data', night.file)))); + const rows = real.map(bytes => readChannelRows(bytes)); + let longest = 0, wavelengthSum = 0, wavelengthCount = 0; + for (const night of rows) { for (const row of night.vis2) longest = Math.max(longest, Math.hypot(row.u, row.v)); for (const wavelength of night.wavelengthsMetres) { wavelengthSum += wavelength; wavelengthCount++; } } + const beamMas = wavelengthSum / wavelengthCount / (2 * longest) * 206264806.247, diameterMas = season.star.diameterMas.value, alpha = season.star.limbPowerLaw.value; + + // 2. Twins and halves. Each night's twin has its own noise draw; a half's twin is that half of the night's twin. + const twin = (scale: number) => real.map((bytes, i) => simulateSpotlessDisc(bytes, { diameterMas: diameterMas * scale, powerLaw: alpha, seed: i + 1 }).bytes); + const twinName = (scale: number) => scale === 1 ? 'spotless' : `spotless-${scale}`, scales = [...new Set([1, ...season.twinScales])]; + const sets: Record = { season: real }; + for (const scale of scales) sets[twinName(scale)] = twin(scale); + for (const half of ['even', 'odd'] as const) { sets[half] = real.map(bytes => selectOifits(bytes, { half }).bytes); sets[`${half}-spotless`] = sets.spotless!.map(bytes => selectOifits(bytes, { half }).bytes); } + + // 3. Reconstruct, one run at a time. A run whose inputs and settings are the ones it was made from is kept. + const options: SurfaceOptions = { diameterMas, limbDarkening: alpha, limbLaw: 'power', inclinationDegrees: season.star.inclinationDegrees.value, positionAngleDegrees: season.star.positionAngleDegrees.value, rotationPeriodDays: season.star.rotationPeriodDays.value, + regularizer: season.recipe.regularizer, weight: season.recipe.weight, level: season.recipe.level, iterations: season.recipe.iterations, coverage: true, threads, skyPixelMas: diameterMas / 64, skyPixels: 128, gridColumns: 720, gridRows: 360 }; + const summaries: Record> = {}; + for (const [name, files] of Object.entries(sets)) { const directory = resolve(work, name), paths: string[] = [], stamp = JSON.stringify(options); + await mkdir(directory, { recursive: true }); + let same = await readFile(resolve(directory, 'settings.json'), 'utf8').catch(() => '') === stamp && await exists(resolve(directory, 'surface-summary.txt')); + for (const [i, bytes] of files.entries()) { const path = resolve(directory, season.nights[i]!.file); if (!(await readFile(path).catch(() => null))?.equals(bytes)) { same = false; await writeFile(path, bytes); } paths.push(path); } + if (!same) { await reconstructSurface(paths, directory, options); await writeFile(resolve(directory, 'settings.json'), stamp); } + summaries[name] = Object.fromEntries((await readFile(resolve(directory, 'surface-summary.txt'), 'utf8')).split('\n').filter(Boolean).map(line => { const [key, number] = line.split('='); return [key!, Number(number)]; })); } + + // 4. Check: the spot maps of every night's sky, end to end. + const skies = async (name: string) => { const maps: Float64Array[] = []; + for (let k = 1; k <= season.nights.length; k++) maps.push(spotMap(readSurfaceSky(await readFile(resolve(work, name, k === 1 ? 'surface-sky.fits' : `surface-sky-${k}.fits`))), diameterMas, beamMas)); + const joined = new Float64Array(maps.reduce((sum, map) => sum + map.length, 0)); let offset = 0; for (const map of maps) { joined.set(map, offset); offset += map.length; } return joined; }; + const seasonSpots = await skies('season'), twins = []; + for (const scale of scales) { const own = summaries[twinName(scale)]!, chi2 = { vis2: own.chi2r_vis2!, closurePhase: own.chi2r_t3phi! }; + twins.push({ scale, diameterMas: diameterMas * scale, chi2, fits: chi2.vis2 <= RECONSTRUCTION_CHI2_LIMIT && chi2.closurePhase <= RECONSTRUCTION_CHI2_LIMIT, ...compareSpotMaps(seasonSpots, await skies(twinName(scale))) }); } + const spots = { ...decidingTwin(twins), twins: twins.map(({ scale, spotlessRms, ratio, chi2, fits }) => ({ scale, spotlessRms, ratio, chi2, fits })) }; + const halves = reproducibility(await skies('even'), await skies('even-spotless'), await skies('odd'), await skies('odd-spotless')); + const fit = summaries.season!, verdict = reconstructionVerdict({ vis2: fit.chi2r_vis2!, closurePhase: fit.chi2r_t3phi! }, spots, halves); + + // 5. The table, on the meridian that faced the Earth on the reference night. + const reference = season.nights.findIndex(night => night.night === season.referenceNight) + 1, observerLongitude = fit[`observer_longitude_${reference}`]!; + const observers = season.nights.map((_night, i) => ({ colatitude: fit[`observer_colatitude_${i + 1}`]!, longitude: fit[`observer_longitude_${i + 1}`]! })); + const table = surfaceTable(readSurfaceGrid(await readFile(resolve(work, 'season/surface-grid.fits'))), facingOf(observers), observerLongitude, + `Surface brightness of ${season.title}, ROTIR on ${season.nights.length} nights (season ${season.id})`); + await writeFile(resolve(work, `${season.id}.dat`), table.text); + const { entry } = await toolchainDescriptor('rotir'), packages = requireRecord(entry.packages, 'rotir packages'); + const commit = (name: string) => requireString(requireRecord(packages[name], name).commit, `${name} commit`); + // Longitude 0 of the table minus each night's Earth-facing meridian: where on the map the observer stood that night. + const nights = season.nights.map((night, i) => ({ night: night.night, file: night.file, origin: night.origin, bytes: night.bytes, vis2: rows[i]!.vis2.length, closurePhases: rows[i]!.t3.length, daysAfterFirst: fit[`epoch_days_${i + 1}`]!, + subObserverLongitude: Number((((fit[`observer_longitude_${i + 1}`]! - observerLongitude) % 360 + 540) % 360 - 180).toFixed(3)), subObserverLatitude: Number((90 - fit[`observer_colatitude_${i + 1}`]!).toFixed(3)), + chi2: { vis2: fit[`epoch_chi2r_vis2_${i + 1}`]!, closurePhase: fit[`epoch_chi2r_t3phi_${i + 1}`]! } })); + const result = { schema: SURFACE_MAP_SCHEMA, season: season.id, object: season.object, title: season.title, target: season.target, instrument: season.instrument, band: season.band, papers: season.papers, data: season.data, star: season.star, recipe: season.recipe, referenceNight: season.referenceNight, + codes: { rotir: commit('ROTIR'), oitools: commit('OITOOLS') }, beamMas: Number(beamMas.toFixed(4)), longestBaselineMetres: Number(longest.toFixed(2)), nights, + points: { vis2: fit.vis2!, closurePhases: fit.t3phi! }, tiles: fit.tiles!, tilesSeen: fit.visible_tiles!, + fit: { vis2: fit.chi2r_vis2!, closurePhase: fit.chi2r_t3phi!, spotlessVis2: fit.start_chi2r_vis2!, spotlessClosurePhase: fit.start_chi2r_t3phi!, contrast: fit.contrast! }, + spots, halves, verdict, table: { file: `${season.id}.dat`, stepDegrees: TABLE_STEP_DEGREES, ...table, text: undefined } }; + await writeFile(resolve(work, 'verdict.json'), `${JSON.stringify(result, null, 2)}\n`); + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const [seasonDirectory, work] = process.argv.slice(2); + if (!seasonDirectory || !work) throw new TypeError('Usage: surface-star '); + const result = await surfaceStar(resolve(seasonDirectory), resolve(work)); + console.log(result.verdict.cast ? `${result.season}: cast.` : `${result.season}: not cast, because ${result.verdict.reasons.join('; ')}.`); + console.log(JSON.stringify({ fit: result.fit, spotRatio: result.spots.ratio, twins: result.spots.twins, halves: result.halves, table: result.table }, null, 2)); +} diff --git a/packages/telescope-cli/src/archives/interferometry/surface-star.test.mts b/packages/telescope-cli/src/archives/interferometry/surface-star.test.mts new file mode 100644 index 00000000000..01dea93508f --- /dev/null +++ b/packages/telescope-cli/src/archives/interferometry/surface-star.test.mts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { sourceTest } from '@cssearth/objects/node/source-test'; +import { parseTecplotLonLat } from '@cssearth/bake/objects/raster'; +import { surfaceArguments } from './surface-reconstruction.mts'; +import { BRIGHTNESS_VARIABLE, decidingTwin, FACING_VARIABLE, facingOf, parseSurfaceSeason, surfaceTable, TABLE_STEP_DEGREES } from './surface-star.mts'; +const test = sourceTest(); + +const SEASON = resolve(import.meta.dirname, 'seasons/udkadua-mirc-2011-09/season.json'); + +test('the λ Andromedae season parses: six nights in order, every value of the star with its paper cell, and malformed seasons fail', async () => { + const record = JSON.parse(await readFile(SEASON, 'utf8')) as Record, season = parseSurfaceSeason(record); + assert.equal(season.object, 'udkadua'); assert.equal(season.nights.length, 6); assert.equal(season.referenceNight, '2011-09-14'); + assert.deepEqual([season.star.diameterMas.value, season.star.limbPowerLaw.value, season.star.inclinationDegrees.value, season.star.positionAngleDegrees.value, season.star.rotationPeriodDays.value], [2.742, 0.231, 85.63, 26.09, 54.2]); + for (const cited of Object.values(season.star)) assert.match(cited.source, /Martinez et al\. \(2021\), ApJ 916, 60, Table 4/u); + assert.deepEqual([season.recipe.level, season.recipe.regularizer, season.recipe.weight, season.recipe.iterations], [3, 'sobel2', 10, 1000]); + for (const night of season.nights) assert.match(night.origin, /^https:\/\/raw\.githubusercontent\.com\/fabienbaron\/ROTIR\.jl\/[0-9a-f]{40}\/demos\/data\//u); + assert.throws(() => parseSurfaceSeason({ ...record, referenceNight: '2011-09-15' }), /not one of the season's/u); + assert.throws(() => parseSurfaceSeason({ ...record, nights: (record.nights as unknown[]).slice(0, 1) }), /two or more nights/u); + assert.throws(() => parseSurfaceSeason({ ...record, reconstruction: { ...(record.reconstruction as object), code: 'squeeze' } }), /ROTIR/u); + assert.throws(() => parseSurfaceSeason({ ...record, papers: [] }), /names the papers/u); +}); + +test('several nights are one run: every file, the power law, the period that turns the star between them, and the coverage', () => { + const args = surfaceArguments(['a.oifits', 'b.oifits'], '/work', { diameterMas: 2.742, limbDarkening: 0.231, limbLaw: 'power', inclinationDegrees: 85.63, positionAngleDegrees: 26.09, rotationPeriodDays: 54.2, regularizer: 'sobel2', weight: 10, level: 3, iterations: 1000, coverage: true }); + const values = Object.fromEntries(args.map(arg => arg.split('=') as [string, string])); + assert.match(values.oifits!, /a\.oifits,.*b\.oifits$/u); + assert.deepEqual([values.ld_law, values.ld1, values.rotation_period_days, values.regularizer, values.weight, values.level, values.coverage], ['3', '0.231', '54.2', 'sobel2', '10', '3', '/work/surface-coverage.fits']); + assert.throws(() => surfaceArguments(['a.oifits', 'b.oifits'], '/work', { diameterMas: 2.7 }), /rotation period/u); + // One file, as Polaris was run, states no period and writes no coverage. + assert.ok(!surfaceArguments('a.oifits', '/work', { diameterMas: 3.16 }).some(arg => /^(?:rotation_period_days|coverage)=/u.test(arg))); +}); + +test('the table puts longitude 0 on the meridian that faced the observer, and a node never seen holds the mean', () => { + // ROTIR's grid, 36 x 18 cells: brightness 2 where its longitude is under 180 and 1 beyond; the observer stood at 270 on the + // reference night, and nothing east of ROTIR longitude 90 to 180 was ever seen. + const columns = 36, rows = 18, values = new Float32Array(columns * rows); + for (let r = 0; r < rows; r++) for (let c = 0; c < columns; c++) values[r * columns + c] = (c + 0.5) * 10 < 180 ? 2 : 1; + const table = surfaceTable({ columns, rows, values }, longitude => { const rotir = (longitude % 360 + 360) % 360; return rotir > 90 && rotir < 180 ? -0.5 : 0.5; }, 270, 'a "test" star'); + const parsed = parseTecplotLonLat(table.text, 'test.dat'), brightness = parsed.variables.indexOf(BRIGHTNESS_VARIABLE), seen = parsed.variables.indexOf(FACING_VARIABLE); + assert.deepEqual([parsed.columns, parsed.rows, parsed.lonStep], [360 / TABLE_STEP_DEGREES + 1, 180 / TABLE_STEP_DEGREES + 1, TABLE_STEP_DEGREES]); + const at = (longitude: number, latitude: number) => parsed.values[(latitude + 90) / TABLE_STEP_DEGREES * parsed.columns + longitude / TABLE_STEP_DEGREES]!; + // Table longitude 0 is ROTIR 270 (brightness 1); table 100 is ROTIR 10 (brightness 2); table 200 is ROTIR 110, never seen. + assert.ok(at(100, 0)[brightness]! / at(0, 0)[brightness]! > 1.99 && at(100, 0)[brightness]! / at(0, 0)[brightness]! < 2.01); + assert.equal(at(200, 0)[brightness], 100); assert.ok(at(200, 0)[seen]! < 0 && at(0, 0)[seen]! > 0); + // The seen surface is two thirds at 1 and one third at 2: its mean is 4/3, so the two values are 75% and 150%. + assert.ok(Math.abs(table.minimumPercent - 75) < 1.5 && Math.abs(table.maximumPercent - 150) < 3, `${table.minimumPercent} ${table.maximumPercent}`); + assert.doesNotMatch(table.text.split('\n')[0]!, /"test"/u); +}); + +test('a place faced the observer as squarely as on its best night, and one behind the limb on every night never did', () => { + // The observer on the equator at longitude 270 on one night and at 180 on another. + const facing = facingOf([{ colatitude: 90, longitude: 270 }, { colatitude: 90, longitude: 180 }]); + assert.ok(Math.abs(facing(270, 0) - 1) < 1e-12 && Math.abs(facing(180, 0) - 1) < 1e-12); + assert.ok(Math.abs(facing(225, 0) - Math.SQRT1_2) < 1e-12); assert.ok(facing(30, 0) < 0); assert.ok(Math.abs(facing(0, 90)) < 1e-12); +}); + +test('the spots are taken against the spottiest spotless twin that fits on the sphere', () => { + const twins = [{ scale: 1, ratio: 2.94, fits: true }, { scale: 0.98, ratio: 1.51, fits: false }, { scale: 0.99, ratio: 2.57, fits: true }, { scale: 1.01, ratio: 2.14, fits: true }, { scale: 1.02, ratio: 1.4, fits: false }]; + assert.equal(decidingTwin(twins).scale, 1.01); + // Where the data do not hold the size, every twin fits and the spottiest decides, as for a sky-plane image. + assert.equal(decidingTwin(twins.map(twin => ({ ...twin, fits: true }))).scale, 1.02); + assert.throws(() => decidingTwin(twins.map(twin => ({ ...twin, fits: twin.scale !== 1 }))), /not a check/u); +}); diff --git a/packages/telescope-cli/src/new-object/maps/routes.mts b/packages/telescope-cli/src/new-object/maps/routes.mts index 22c1589d4ea..baae270ce12 100644 --- a/packages/telescope-cli/src/new-object/maps/routes.mts +++ b/packages/telescope-cli/src/new-object/maps/routes.mts @@ -7,6 +7,8 @@ export const MAP_ROUTES: Readonly runSurfaceMaps((await import('../magnetic/maps.mts')).MAGNETIC_ROUTE, spec, context) }, // Brightness maps made from a star's TESS, K2 or Kepler light curves (brightness/brightness.mts). brightnessMaps: { name: 'brightness maps', run: async (spec, context) => runSurfaceMaps((await import('../brightness/brightness.mts')).BRIGHTNESS_ROUTE, spec, context) }, + // Surface maps fitted to a star's interferometry of several nights (resolved/resolved.mts). + resolvedMaps: { name: 'surface maps', run: async (spec, context) => runSurfaceMaps((await import('../resolved/resolved.mts')).RESOLVED_ROUTE, spec, context) }, // A pulsating star's light at ten phases of one cycle, from its published light-curve model (pulsation/pulsation.mts). pulsations: { name: 'pulsation steps', run: async (spec, context) => runSurfaceMaps((await import('../pulsation/pulsation.mts')).PULSATION_ROUTE, spec, context) }, }; diff --git a/packages/telescope-cli/src/new-object/maps/surface-maps.mts b/packages/telescope-cli/src/new-object/maps/surface-maps.mts index a7176a81a18..6b66a08f53c 100644 --- a/packages/telescope-cli/src/new-object/maps/surface-maps.mts +++ b/packages/telescope-cli/src/new-object/maps/surface-maps.mts @@ -39,6 +39,8 @@ export interface MapKind { report(maps: readonly M[], scale: MapScale): string; /** Whether a map's page draws the star at the map's tilt, so the latitude the star never shows may be outlined. Left out: it does. */ outlines?(map: M): boolean; + /** A column of the map's own table whose change of sign is outlined: the edge of what the table says was never seen. */ + readonly outlineZeroOf?: string; /** Where one map's table goes, the archive it is read from and the records it is bound to, when they differ from map to map * (a kind fed by several missions). Left out: the kind's own `directory`, `archiveUrl` and `references`. */ directoryOf?(map: M): string; archiveOf?(map: M): string; referencesOf?(map: M): readonly { readonly catalogueId: string; readonly role: string; readonly evidence: string }[]; @@ -112,7 +114,7 @@ export function surfaceMapFiles(kind: MapKind, entry: S redistribution: words.redistribution, consumers: [consumerOf(map)], sourceBinding: { kind: 'catalogued', references: (kind.referencesOf?.(map) ?? kind.references).map(reference => ({ ...reference })) } }); newSurfaces.push({ id: choice.id, output: first.output, thumbnail: first.thumbnail, source: path, falseColor: !look, science: { kind: 'terrestrial-scientific', id: choice.id, label: choice.label, format: 'tecplot-lonlat-map', path, variable: variableOf(map), - ...(outlined ? { outlineLatitudes: [-tilt] } : {}), consumer: consumerOf(map), sampling: 'bilinear', displaySampling: kind.displaySampling ?? 'bilinear', outputLongitudeOrigin: 0, units: kind.units, minimum: scale.minimum, maximum: scale.maximum, colors: look ?? [...kind.colors], labels, + ...(outlined ? { outlineLatitudes: [-tilt] } : {}), ...(kind.outlineZeroOf ? { outlineZeroOf: kind.outlineZeroOf } : {}), consumer: consumerOf(map), sampling: 'bilinear', displaySampling: kind.displaySampling ?? 'bilinear', outputLongitudeOrigin: 0, units: kind.units, minimum: scale.minimum, maximum: scale.maximum, colors: look ?? [...kind.colors], labels, ...(look ? { limbOf: first.id } : {}), description: words.description, title: words.surfaceTitle, sourceUrl: archive } }); newControls.push({ id: choice.id, label: kind.controlLabel, qualification: words.qualification, thumbnail: `${star.id}-dataset-${choice.id}.webp`, surface: `${star.id}-surface-${choice.id}@2x.webp`, poles: `${star.id}-poles-${choice.id}@2x.webp`, diff --git a/packages/telescope-cli/src/new-object/resolved/resolved-maps.mts b/packages/telescope-cli/src/new-object/resolved/resolved-maps.mts new file mode 100644 index 00000000000..935b5965632 --- /dev/null +++ b/packages/telescope-cli/src/new-object/resolved/resolved-maps.mts @@ -0,0 +1,160 @@ +/** A star's surface resolved by an interferometer over several nights, as datasets of the star's page: the map kind of + * maps/surface-maps.mts for the reconstructions `archives/interferometry/surface-star.mts` makes with ROTIR. + * + * A map is drawn only when its reduction passed the three checks there. Every sentence says it is this project's + * reduction of the authors' calibrated nights with their code and settings, not their published map, and gives the fit's + * own numbers. The star's tilt, the direction of its pole on the sky and its period are the paper's, cited in the season; + * a page that draws its axis by convention takes them. Nothing here names a star: the season says whose nights they are. */ +import { inclinedPoleOrientation } from '@cssearth/bake/objects/stellar'; +import { requireArray, requireFiniteNumber, requireRecord, requireString } from '@cssearth/core'; +import { DISPLAY_ORIENTATION_SCHEMA, INVESTIGATION_LEDGER_SCHEMA } from '@cssearth/objects'; +import { RECONSTRUCTION_CHI2_LIMIT, REPRODUCIBILITY_CORRELATION, SPOT_CONTRAST_RATIO } from '../../archives/interferometry/spotless-disc.mts'; +import { BRIGHTNESS_VARIABLE, FACING_VARIABLE, SURFACE_MAP_SCHEMA, type SeasonData, type SeasonPaper } from '../../archives/interferometry/surface-star.mts'; +import { LIMB_STRENGTH, tinted } from '../brightness/brightness-maps.mts'; +import { scaleEnd, type MapKind, type SurfaceMap, type SurfaceMapChoice } from '../maps/surface-maps.mts'; + +type Json = Record; +const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; +export const RESOLVED_GENERATOR = 'packages/telescope-cli/src/archives/interferometry/surface-star.mts', RESOLVED_CONSUMER = 'rotir-surface', RESOLVED_DIRECTORY = 'science/interferometry'; +const ROTIR_RECORD = 'rotir-jl', ROTIR_URL = 'https://github.com/fabienbaron/ROTIR.jl'; +const COLORS = ['#1a1a1a', '#5c5c5c', '#9c9c9c', '#d6d6d6', '#ffffff'] as const, PALETTE = [[26, 26, 26], [92, 92, 92], [156, 156, 156], [214, 214, 214], [255, 255, 255]] as const; + +export interface ResolvedNight { readonly night: string; readonly vis2: number; readonly closurePhases: number; readonly subObserverLongitude: number } +export interface ResolvedSurfaceMap extends SurfaceMap { + readonly season: string; readonly instrument: string; readonly band: string; readonly papers: readonly SeasonPaper[]; readonly data: SeasonData; + readonly diameterMas: number; readonly diameterSource: string; readonly limbPowerLaw: number; readonly positionAngleDegrees: number; readonly positionAngleSource: string; + readonly nights: readonly ResolvedNight[]; readonly referenceNight: string; readonly beamMas: number; readonly vis2: number; readonly closurePhases: number; + readonly fit: { readonly vis2: number; readonly closurePhase: number; readonly spotlessVis2: number; readonly spotlessClosurePhase: number }; + /** The deciding twin's ratio and size, the twins that did not fit on the sphere, and the halves' correlation. */ + readonly spotRatio: number; readonly spotScale: number; readonly unfitTwins: readonly { readonly scale: number; readonly closurePhase: number }[]; readonly halves: number; + readonly darkestPercent: number; readonly brightestPercent: number; readonly recipe: string; readonly codes: readonly string[]; +} + +/** What a season's receipt says of its map, read once and checked. A reduction that did not pass its checks is no map. */ +export function reducedSurface(choice: SurfaceMapChoice, receipt: unknown, table: string): ResolvedSurfaceMap { + const record = requireRecord(receipt, `${choice.program} receipt`); + if (record.schema !== SURFACE_MAP_SCHEMA || record.season !== choice.program) throw new TypeError(`${choice.program}: its receipt is not this season's surface map.`); + const verdict = requireRecord(record.verdict, 'verdict'); + if (verdict.cast !== true) throw new Error(`${choice.program} is not cast: ${requireArray(verdict.reasons, 'reasons').join('; ')}.`); + const star = requireRecord(record.star, 'star'), cited = (name: string) => { const one = requireRecord(star[name], name); return { value: requireFiniteNumber(one.value, name), source: requireString(one.source, `${name} source`) }; }; + const fit = requireRecord(record.fit, 'fit'), spots = requireRecord(record.spots, 'spots'), about = requireRecord(record.table, 'table'), recipe = requireRecord(record.recipe, 'recipe'), codes = requireRecord(record.codes, 'codes'); + const nights = requireArray(record.nights, 'nights').map((entry): ResolvedNight => { const night = requireRecord(entry, 'night'); return { night: requireString(night.night, 'night'), vis2: requireFiniteNumber(night.vis2, 'vis2'), closurePhases: requireFiniteNumber(night.closurePhases, 'closurePhases'), subObserverLongitude: requireFiniteNumber(night.subObserverLongitude, 'subObserverLongitude') }; }); + const points = requireRecord(record.points, 'points'); + if (!table.includes(`"${BRIGHTNESS_VARIABLE}"`) || !table.includes(`"${FACING_VARIABLE}"`) || !table.includes('ZONE I=')) throw new TypeError(`${choice.program}: the table is not a surface map of ${RESOLVED_GENERATOR}.`); + return { choice, table, targetName: requireString(record.target, 'target'), season: choice.program, instrument: requireString(record.instrument, 'instrument'), band: requireString(record.band, 'band'), + papers: requireArray(record.papers, 'papers') as SeasonPaper[], data: requireRecord(record.data, 'data') as unknown as SeasonData, + inclinationDegrees: cited('inclinationDegrees').value, inclinationSource: cited('inclinationDegrees').source, periodDays: cited('rotationPeriodDays').value, periodSource: cited('rotationPeriodDays').source, + diameterMas: cited('diameterMas').value, diameterSource: cited('diameterMas').source, limbPowerLaw: cited('limbPowerLaw').value, positionAngleDegrees: cited('positionAngleDegrees').value, positionAngleSource: cited('positionAngleDegrees').source, + nights, referenceNight: requireString(record.referenceNight, 'referenceNight'), beamMas: requireFiniteNumber(record.beamMas, 'beamMas'), vis2: requireFiniteNumber(points.vis2, 'vis2'), closurePhases: requireFiniteNumber(points.closurePhases, 'closurePhases'), + fit: { vis2: requireFiniteNumber(fit.vis2, 'fit vis2'), closurePhase: requireFiniteNumber(fit.closurePhase, 'fit closure phase'), spotlessVis2: requireFiniteNumber(fit.spotlessVis2, 'spotless vis2'), spotlessClosurePhase: requireFiniteNumber(fit.spotlessClosurePhase, 'spotless closure phase') }, + spotRatio: requireFiniteNumber(spots.ratio, 'spot ratio'), spotScale: requireFiniteNumber(spots.scale, 'spot scale'), + unfitTwins: requireArray(spots.twins, 'twins').map(entry => requireRecord(entry, 'twin')).filter(twin => twin.fits === false).map(twin => ({ scale: requireFiniteNumber(twin.scale, 'twin scale'), closurePhase: requireFiniteNumber(requireRecord(twin.chi2, 'twin chi2').closurePhase, 'twin closure phase') })), + halves: requireFiniteNumber(requireRecord(record.halves, 'halves').correlation, 'halves'), darkestPercent: requireFiniteNumber(about.minimumPercent, 'minimumPercent'), brightestPercent: requireFiniteNumber(about.maximumPercent, 'maximumPercent'), + recipe: `HEALPix level ${requireFiniteNumber(recipe.level, 'level')}, ${requireString(recipe.regularizer, 'regularizer')} at weight ${requireFiniteNumber(recipe.weight, 'weight')}, ${requireFiniteNumber(recipe.iterations, 'iterations')} iterations`, + codes: [`ROTIR.jl ${requireString(codes.rotir, 'rotir').slice(0, 8)}`, `OITOOLS.jl ${requireString(codes.oitools, 'oitools').slice(0, 8)}`] }; +} + +const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] as const; +const day = (night: string) => `${Number(night.slice(8, 10))} ${MONTHS[Number(night.slice(5, 7)) - 1]!} ${night.slice(0, 4)}`; +/** "2 to 24 September 2011" for nights of one month, else both days in full. */ +export const span = (nights: readonly { readonly night: string }[]) => { const first = nights[0]!.night, last = nights.at(-1)!.night; + return first.slice(0, 7) === last.slice(0, 7) ? `${Number(first.slice(8, 10))} to ${day(last)}` : `${day(first)} to ${day(last)}`; }; +const fixed = (value: number) => value.toFixed(2); +/** A cited value's paper, without the cell after it: "Author (2021), Table 4 (url): value" gives the part before the last colon. */ +const paperOf = (source: string) => source.replace(/: [^:]*$/u, ''); +const cite = (map: ResolvedSurfaceMap) => map.papers.map(paper => paper.citation).join('; '); +/** The degrees of longitude that faced the Earth on no night: the nights' sub-Earth meridians span a range, and a hemisphere is seen from each. */ +export const unseenDegrees = (map: Pick) => { const at = map.nights.map(night => night.subObserverLongitude); return Math.max(0, 180 - (Math.max(...at) - Math.min(...at))); }; +/** The smallest detail the nights resolve, in degrees of the star's surface at the middle of its disc. */ +const resolved = (map: ResolvedSurfaceMap) => Math.round(2 * Math.asin(Math.min(1, map.beamMas / map.diameterMas)) * 180 / Math.PI); +const scaleOf = (maps: readonly ResolvedSurfaceMap[]) => { const reach = scaleEnd(Math.max(...maps.flatMap(map => [100 - map.darkestPercent, map.brightestPercent - 100]))); return { minimum: 100 - reach, maximum: 100 + reach, labels: [`${100 - reach}%`, '100%', `${100 + reach}%`] }; }; +const checks = (map: ResolvedSurfaceMap) => `The fit leaves a reduced chi-squared of ${fixed(map.fit.vis2)} on the squared visibilities and ${fixed(map.fit.closurePhase)} on the closure phases (limit ${RECONSTRUCTION_CHI2_LIMIT}), where a spotless star leaves ${fixed(map.fit.spotlessVis2)} and ${fixed(map.fit.spotlessClosurePhase)}; its spots are ${fixed(map.spotRatio)} times those the same fit draws on a spotless star of ${map.spotScale === 1 ? 'the same size' : `${fixed(map.spotScale * map.diameterMas)} mas`} (limit ${SPOT_CONTRAST_RATIO}); and two independent halves of the data give the same spots (correlation ${fixed(map.halves)}, limit ${REPRODUCIBILITY_CORRELATION})`; + +/** What is a resolved surface map's own in the records surface-maps.mts writes. */ +export const RESOLVED_MAPS: MapKind = { + consumer: RESOLVED_CONSUMER, directory: RESOLVED_DIRECTORY, inputTag: 'interferometry-map', generator: RESOLVED_GENERATOR, stepGroup: 'surface', variable: BRIGHTNESS_VARIABLE, units: '%', controlLabel: 'Surface map', legendTitle: 'Surface brightness, infrared', + archiveUrl: ROTIR_URL, archiveOf: map => map.data.url, references: [], + referencesOf: map => [{ catalogueId: map.data.record, role: 'material', evidence: map.data.url }, ...map.papers.map(paper => ({ catalogueId: paper.record, role: 'method', evidence: `https://doi.org/${paper.doi}` })), { catalogueId: ROTIR_RECORD, role: 'method', evidence: ROTIR_URL }], + colors: COLORS, palette: PALETTE, outlineZeroOf: FACING_VARIABLE, outlines: () => false, + scale: scaleOf, + words(map, { count }) { const { choice } = map, when = span(map.nights), unseen = Math.round(unseenDegrees(map)), detail = resolved(map); + const edge = unseen > 0 ? ` The ${unseen}° of longitude that faced the Earth on none of the nights hold the mean, inside the black line.` : ''; + return { productId: `Surface map of ${map.targetName} from ${map.instrument}, ${when}`, + inputTitle: `Surface map of ${map.targetName} from its ${map.instrument} visibilities of ${when}: infrared brightness on a longitude-latitude grid`, + credit: `${map.data.credit}; the star's size, limb, axis and period from ${map.papers[0]!.citation}; map made in this project with ${map.codes.join(' and ')}`, displayCredit: `${map.instrument} · mapped here`, + license: map.data.license, licenseEvidence: [map.data.url], + acquisition: `Built by ${RESOLVED_GENERATOR} from the season ${map.season}: the ${map.nights.length} calibrated files at ${map.data.url}, fitted with the pinned ROTIR (${map.recipe}). Restored from the source cache; not tracked. The receipt with the fit and its checks is written again by that command under output/interferometry and is not kept in git.`, + redistribution: 'A table of fitted values made in this project; the calibrated files themselves are not redistributed.', + description: `Brightness of ${map.targetName}'s surface in the ${map.band}, fitted with ROTIR to the ${map.vis2.toLocaleString('en-US')} squared visibilities and ${map.closurePhases.toLocaleString('en-US')} closure phases ${map.instrument} measured on ${map.nights.length} nights, ${when}: one surface on a sphere ${map.diameterMas} mas across that turns once in ${map.periodDays} d, tilted ${map.inclinationDegrees}° from the line of sight with its pole ${map.positionAngleDegrees}° east of north (${paperOf(map.inclinationSource)}). ${checks(map)}. Percent of the mean of the surface seen. Longitude 0 is the meridian that faced the Earth on ${day(map.referenceNight)}. Nothing smaller than about ${detail}° of the surface is resolved.${edge} The same nights were imaged by their authors (${cite(map)}); this is a reduction made in this project with their code, not their published map.`, + surfaceTitle: `${map.instrument} · surface map made here · ${choice.label}`, qualification: `Mapped in this project · ${map.instrument}, ${choice.label.toLowerCase()}`, + notes: `Where ${map.targetName}'s surface was darker and brighter in infrared light on ${map.nights.length} nights, ${when}, worked out in this project from what the ${map.instrument} interferometer measured. The star turned by ${Math.round(180 - unseenDegrees(map))}° between the first night and the last, so one surface is fitted to all of them, with the size, tilt, pole direction and period ${map.papers[0]!.citation} measured. Both the longitudes and the latitudes of the dark regions are fixed by the data, down to about ${detail}° of the surface; nothing finer is.${edge} The colors are a scale of brightness in one infrared band, not the star's color or temperature.${count > 1 ? ` Step through the ${count} maps to see the spots change.` : ''} A reduction made in this project with the authors' code and calibrated nights, not their published map.`, + legendNote: `Darker: dimmer than the mean of the surface seen; white: brighter.${unseen > 0 ? ' Black line: the edge of what never faced the Earth on these nights.' : ''}`, + text: { title: `Surface map, ${choice.label}`, detail: `${choice.label}, mapped here`, summary: 'Darker and brighter regions of the star, worked out in this project from interferometry of several nights.' } }; }, + // The star in its own color over the map's scale: a darker tone of its hue where the infrared surface is dimmer. + natural(map, star) { const when = span(map.nights), scale = scaleOf([map]), less = Number((100 * (1 - map.darkestPercent / map.brightestPercent)).toFixed(0)), unseen = Math.round(unseenDegrees(map)); + return { id: 'color-surface', label: 'Color + brightness', minimum: scale.minimum, maximum: scale.maximum, colors: tinted(star.colorHex), limbStrength: LIMB_STRENGTH, + description: `The star's color (${star.colorHex}, its Color dataset) over the surface map this project made from ${map.instrument} interferometry of ${when}: the map's scale, ${scale.minimum}% to ${scale.maximum}% of the mean surface, runs from a darker, richer tone of the same hue to the color itself. The map is of infrared light (${map.band}), where the darkest region gives ${less}% less light than the brightest; how much darker the spots are in visible light is not measured, and no color change is drawn. Longitude 0 is the meridian that faced the Earth on ${day(map.referenceNight)}.${unseen > 0 ? ` The ${unseen}° of longitude that never faced the Earth on these nights hold the mean.` : ''} A reduction made in this project, not a published map.`, + surfaceTitle: `${map.instrument} · the star in its color over its surface map · ${when}`, qualification: `The star's color, darker where ${map.instrument} saw its surface dimmer in the infrared · ${when}, mapped in this project`, + notes: `${map.targetName} in its own color, darker where interferometry of ${when} shows its surface darker. The interferometer resolved the star's disc, so the places of the dark regions, in longitude and latitude, are measured, down to about ${resolved(map)}° of the surface. The map is of infrared light, where the darkest region gives ${less}% less light than the brightest; it is drawn as a much darker tone of the star's own hue, and the Surface map's scale has the measured values. How dark the spots are in visible light, and their color, are not measured. The star is drawn as it faced the Earth on ${day(map.referenceNight)}, at the tilt and pole direction ${map.papers[0]!.citation} measured.${unseen > 0 ? ` The ${unseen}° of longitude that faced the Earth on none of the nights are drawn at the mean.` : ''} Spots come and go within weeks or months: this is the star then. The darkening toward the edge is the Color dataset's limb law, drawn ${LIMB_STRENGTH} times as strong. A reduction made in this project with the authors' code and calibrated nights, not their published map.`, + text: { title: `Color + brightness, ${MONTHS[Number(map.referenceNight.slice(5, 7)) - 1]!.slice(0, 3)} ${map.referenceNight.slice(0, 4)}`, detail: `${map.instrument}, ${map.choice.label}`, summary: 'The star in its own color, darker where interferometry resolved darker regions on its surface.' } }; }, + report(maps, scale) { const count = maps.length; return `${count} surface ${count === 1 ? 'map' : 'maps'} on one scale of ${scale.minimum}% to ${scale.maximum}% (${maps.map(map => `${map.choice.label}: ${map.nights.length} nights, chi-squared ${fixed(map.fit.vis2)} and ${fixed(map.fit.closurePhase)}, spots ${fixed(map.spotRatio)} times a spotless star's, halves ${fixed(map.halves)}`).join('; ')})`; }, +}; + +/** The records of the source catalogue (`src/sources`) a star's surface maps are bound to: the calibrated nights, the papers + * and the code. */ +export function resolvedSourceRecords(checkedOn: string, maps: readonly Pick[]): Map { + const records = new Map(); + for (const map of maps) { const { data } = map; + records.set(`src/sources/${data.record}.json`, json({ id: data.record, kind: 'data-product', identityLevel: 'work', title: data.title, identifiers: [], links: [{ role: 'archive', url: data.url, label: 'Files at the pinned commit' }], + evidence: [{ url: data.url, checkedOn, locator: data.locator }], relations: [], statements: [{ kind: 'credit', text: data.credit, scope: 'citation', evidence: data.url }], publisher: data.publisher })); + for (const paper of map.papers) { const url = `https://arxiv.org/abs/${paper.arxiv}`; + records.set(`src/sources/${paper.record}.json`, json({ id: paper.record, kind: 'publication', identityLevel: 'work', title: paper.title, identifiers: [{ type: 'arXiv', value: paper.arxiv }, { type: 'DOI', value: paper.doi }], + links: [{ role: 'archive', url, label: 'arXiv preprint' }, { role: 'landing', url: `https://doi.org/${paper.doi}`, label: 'Journal version' }], evidence: [{ url, checkedOn, locator: paper.locator }], relations: [], + statements: [{ kind: 'credit', text: paper.title.split(':')[0]!, scope: 'citation', evidence: url }], creators: paper.creators, publicationDate: paper.year })); } } + records.set(`src/sources/${ROTIR_RECORD}.json`, json({ id: ROTIR_RECORD, kind: 'software', identityLevel: 'work', title: 'ROTIR.jl: interferometric imaging and light curve inversion on rotating spheroids (F. Baron, A. O. Martinez)', + identifiers: [], links: [{ role: 'archive', url: ROTIR_URL, label: 'Source repository' }], evidence: [{ url: ROTIR_URL, checkedOn, locator: 'Repository page: name, description and GPL-3.0 licence. The commit run is pinned in packages/telescope-cli/src/archives/interferometry/toolchains.json.' }], relations: [], + statements: [{ kind: 'credit', text: 'ROTIR.jl (F. Baron and A. O. Martinez), GPL-3.0', scope: 'citation', evidence: ROTIR_URL }], creators: ['F. Baron', 'A. O. Martinez'] })); + return records; +} + +/** A star's rotation record with the axis its surface map is fitted with: the measured tilt and pole direction, and the + * meridian that turns longitude 0 toward the Earth, where the map puts the face of its reference night. */ +export function measuredAxisRotation(previous: Json, place: { readonly rightAscensionDegrees: number; readonly declinationDegrees: number }, map: Pick): Json { + const pole = inclinedPoleOrientation(place, map.inclinationDegrees, map.positionAngleDegrees), meridian = Number(((pole.displayMeridianDegrees % 360 + 360) % 360).toFixed(6)); + return { ...previous, schema: DISPLAY_ORIENTATION_SCHEMA, rightAscensionDegrees: pole.rightAscensionDegrees, declinationDegrees: pole.declinationDegrees, displayMeridianDegrees: meridian, phase: 'arbitrary-display-phase', + source: `Measured axis: spin inclination ${map.inclinationDegrees} degrees from the line of sight (${map.inclinationSource}), pole position angle ${map.positionAngleDegrees} degrees east of north (${map.positionAngleSource}) and rotation period ${map.periodDays} d (${map.periodSource}): the values the star's surface map is fitted with, the pole tilted toward us; computed by inclinedPoleOrientation in packages/bake/src/objects/stellar/gravity-darkening.ts. Longitude 0 faces the Earth, where the map puts the meridian that faced it on ${map.referenceNight}.`, + coordinateSystem: 'ICRF/J2000. +Z is the measured rotation pole, the one tilted toward the Earth. +X is the display meridian, set so that grid longitude 0 faces the Sun and Earth at the scene epoch; east longitude. No spin is propagated.', + qualification: 'The tilt and the direction of the pole on the sky are measured by interferometry. The star is drawn as it faced the Earth on one night; the rotation is not animated.' }; } + +const README_LEAD = '**Surface from interferometry.**', PROBLEM_LEAD = '- **Surface map.**', FRAME_LEAD = '- **Assumptions of the frame.**'; +/** The star's README with the paragraph on its surface map before the evidence, and the map's known limits among the problems. */ +export function withSurfaceReadme(readme: string, map: ResolvedSurfaceMap): string { + const unseen = Math.round(unseenDegrees(map)), unfit = map.unfitTwins.length ? ` Spotless twins ${map.unfitTwins.map(twin => `${Math.round(Math.abs(twin.scale - 1) * 100)}% ${twin.scale < 1 ? 'smaller' : 'larger'}`).join(' and ')} do not fit on the sphere at all (closure phases ${map.unfitTwins.map(twin => fixed(twin.closurePhase)).join(' and ')}), so they do not decide the spots: a reading of this repository, stated in surface-star.mts.` : ''; + const paragraph = `${README_LEAD} The datasets Color + brightness and Surface map are one fit of the star's surface to the ${map.vis2.toLocaleString('en-US')} squared visibilities and ${map.closurePhases.toLocaleString('en-US')} closure phases ${map.instrument} measured on ${map.nights.length} nights, ${span(map.nights)} (${map.band}). The calibrated files are the authors', shipped with their code (${map.data.url}); the fit is ROTIR at the pinned commit (${map.recipe}, the settings of the authors' own script for these files), on a sphere of ${map.diameterMas} mas with a power-law limb of ${map.limbPowerLaw}, tilted ${map.inclinationDegrees}° with its pole ${map.positionAngleDegrees}° east of north and turning in ${map.periodDays} d (${paperOf(map.inclinationSource)}). ${checks(map)}.${unfit} The page's axis is that measured one, and longitude 0 is the meridian that faced the Earth on ${day(map.referenceNight)}. Run again with \`node ${RESOLVED_GENERATOR} packages/telescope-cli/src/archives/interferometry/seasons/${map.season} output/interferometry/${map.season}\`. ${cite(map)} imaged the same nights; their maps are not redistributed here.`; + const problem = `${PROBLEM_LEAD} It is infrared brightness, drawn over the star's visible color as a darker tone: how dark the spots are in visible light is not measured. Nothing smaller than about ${resolved(map)}° of the surface is resolved${unseen > 0 ? `, and ${unseen}° of longitude never faced the Earth on these nights` : ''}. The map is of ${span(map.nights)}; spots change within months.`; + const lines = readme.split('\n').filter(line => !line.startsWith(README_LEAD) && !line.startsWith(PROBLEM_LEAD) && !line.startsWith(FRAME_LEAD)); + const evidence = lines.indexOf('## Evidence'); if (evidence >= 0) lines.splice(evidence, 0, paragraph, ''); else lines.push('', paragraph); + const problems = lines.indexOf('## Known problems'); + if (problems >= 0) lines.splice(problems + 2, 0, problem, `${FRAME_LEAD} The rotation phase is a convention: the star is drawn as it faced the Earth on one night and does not turn.`); else lines.push('', '## Known problems', '', problem); + return lines.join('\n').replace(/\n{3,}/gu, '\n\n'); +} + +const NOTICE_LEAD = 'Surface map: '; +/** The star's credits with one line for its surface map: whose nights, whose values and whose code. */ +export function withSurfaceNotice(notice: string, map: ResolvedSurfaceMap): string { + const line = `${NOTICE_LEAD}${map.data.credit}; the star's size, limb, axis and period from ${map.papers[0]!.citation}; fitted in this project with ${map.codes.join(' and ')} (GPL-3.0 and LGPL-3.0, run as tools, not redistributed). ${map.data.url}`; + return `${notice.split('\n').filter(one => !one.startsWith(NOTICE_LEAD)).join('\n').trimEnd()}\n\n${line}\n`.replace(/\n{3,}/gu, '\n\n'); +} + +/** The star's investigation ledger with the surface map's entry and the measured axis in place of the convention's. */ +export function withSurfaceLedger(ledger: unknown, map: ResolvedSurfaceMap): Json { + const record = requireRecord(ledger, 'investigation ledger'); if (record.schema !== INVESTIGATION_LEDGER_SCHEMA) throw new TypeError('Not an investigation ledger.'); + const id = `surface-${map.season}`, entries = requireArray(record.entries, 'entries').map(entry => requireRecord(entry, 'entry')).filter(entry => entry.id !== id && entry.id !== 'rotation-axis'); + return { ...record, entries: [...entries, + { id, subject: `${map.instrument} visibilities of ${span(map.nights)} as a surface map`, status: 'included', + finding: `The authors' calibrated files of ${map.nights.length} nights (${map.nights.map(night => `${day(night.night)}: ${night.vis2} squared visibilities`).join('; ')}) are public with their code. ROTIR at the pinned commit (${map.recipe}) fits one turning surface to all of them. ${checks(map)}.${map.unfitTwins.length ? ` Spotless twins of ${map.unfitTwins.map(twin => `${fixed(twin.scale * map.diameterMas)} mas`).join(' and ')} do not fit on the ${map.diameterMas} mas sphere (closure-phase chi-squared ${map.unfitTwins.map(twin => fixed(twin.closurePhase)).join(' and ')}) and so do not decide the spot check.` : ''} The published maps themselves are not redistributed.`, + evidence: [map.data.url, ...map.papers.map(paper => `https://doi.org/${paper.doi}`)] }, + { id: 'rotation-axis', subject: 'Rotation axis, period and prime meridian', status: 'included', + finding: `The page draws the axis the surface map is fitted with: inclination ${map.inclinationDegrees}° and pole position angle ${map.positionAngleDegrees}° east of north, with a period of ${map.periodDays} d (${paperOf(map.inclinationSource)}). No prime meridian is defined for the star: longitude 0 is the meridian that faced the Earth on ${day(map.referenceNight)}, and the star is not turned.`, + evidence: map.papers.map(paper => `https://doi.org/${paper.doi}`) }] }; +} diff --git a/packages/telescope-cli/src/new-object/resolved/resolved.mts b/packages/telescope-cli/src/new-object/resolved/resolved.mts new file mode 100644 index 00000000000..dc707c95e82 --- /dev/null +++ b/packages/telescope-cli/src/new-object/resolved/resolved.mts @@ -0,0 +1,32 @@ +/** `telescope new-object` for the surface maps this repository fits to a star's interferometry of several nights: the + * route (maps/route.mts) that writes a season's map as datasets of the star's page and bakes the star. resolved-maps.mts + * holds the records; `archives/interferometry/surface-star.mts` reduces a season and decides whether its map is cast. + * + * A page that draws its axis by convention takes the measured tilt and pole direction the map is fitted with. */ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { readJson, type MapRoute } from '../maps/route.mts'; +import type { SurfaceMapChoice } from '../maps/surface-maps.mts'; +import { measuredAxisRotation, reducedSurface, RESOLVED_GENERATOR, RESOLVED_MAPS, resolvedSourceRecords, withSurfaceLedger, withSurfaceNotice, withSurfaceReadme, type ResolvedSurfaceMap } from './resolved-maps.mts'; + +const SEASONS = 'packages/telescope-cli/src/archives/interferometry/seasons'; +const reduce = (season: string) => `node ${RESOLVED_GENERATOR} ${SEASONS}/${season} output/interferometry/${season}`; + +async function reduced(root: string, host: string, choice: SurfaceMapChoice): Promise { + const work = resolve(root, 'output/interferometry', choice.program); + const receipt = await readJson(resolve(work, 'verdict.json'), `${choice.program} is not reduced (${reduce(choice.program)})`); + const table = await readFile(resolve(work, `${choice.program}.dat`), 'utf8').catch(() => { throw new Error(`${choice.program}: its map table is not in output/interferometry (${reduce(choice.program)}).`); }); + const map = reducedSurface(choice, receipt, table); + if ((receipt as { object?: unknown }).object !== host) throw new Error(`${choice.program} is a season of ${String((receipt as { object?: unknown }).object)}, not of ${host}.`); + return map; +} + +/** The star's README, credits and investigation ledger, with its newest map. */ +async function starRecords(root: string, host: string, maps: readonly ResolvedSurfaceMap[]): Promise> { + const at = `src/objects/${host}`, newest = maps.at(-1)!; + return new Map([[`${at}/README.md`, withSurfaceReadme(await readFile(resolve(root, at, 'README.md'), 'utf8'), newest)], + [`${at}/NOTICE.md`, withSurfaceNotice(await readFile(resolve(root, at, 'NOTICE.md'), 'utf8'), newest)], + [`${at}/investigations.json`, `${JSON.stringify(withSurfaceLedger(await readJson(resolve(root, at, 'investigations.json'), `${host}: no investigation ledger`), newest), null, 2)}\n`]]); +} + +export const RESOLVED_ROUTE: MapRoute = { kind: RESOLVED_MAPS, specKey: 'resolvedMaps', name: 'surface maps', reduced, sourceRecords: resolvedSourceRecords, tiltedRotation: measuredAxisRotation, starRecords }; diff --git a/packages/telescope-cli/src/new-object/resolved/resolved.test.mts b/packages/telescope-cli/src/new-object/resolved/resolved.test.mts new file mode 100644 index 00000000000..694b0da594f --- /dev/null +++ b/packages/telescope-cli/src/new-object/resolved/resolved.test.mts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isConventionOnly, surfaceMapFiles } from '../maps/surface-maps.mts'; +import { measuredAxisRotation, reducedSurface, RESOLVED_CONSUMER, RESOLVED_MAPS, resolvedSourceRecords, span, unseenDegrees, withSurfaceLedger, withSurfaceNotice, withSurfaceReadme } from './resolved-maps.mts'; + +const TABLE = 'TITLE = "test"\nVARIABLES = "Longitude [Deg]" "Latitude [Deg]" "Brightness [%]" "Facing"\nZONE I=73, J=37, K=1, ZONETYPE=Ordered\n'; +const CHOICE = { program: 'star-mirc-2011-09', id: 'surface-2011-09', label: 'September 2011' }; +const cell = (value: number, what: string) => ({ value, source: `Author et al. (2021), ApJ 1, 1, Table 4 (https://arxiv.org/abs/2107.00001): ${what}` }); +/** A season's receipt as surface-star.mts writes it: three nights, the twin 2% larger not fitting on the sphere. */ +const receipt = (cast = true) => ({ schema: 'cssearth-star-surface-map@1', season: CHOICE.program, object: 'hd-1', title: 'HD 1, CHARA/MIRC', target: 'HD 1', instrument: 'CHARA/MIRC', band: 'CHARA/MIRC H band (1.49-1.73 um)', + papers: [{ record: 'arxiv-2107-00001', citation: 'Author et al. (2021), ApJ 1, 1', title: 'Author et al. (2021): A star', arxiv: '2107.00001', doi: '10.1000/x', creators: ['A. Author'], year: '2021', locator: 'Table 4' }], + data: { record: 'code-demos-hd-1', title: 'HD 1 nights', url: 'https://github.com/example/code/tree/abc/demos/data', publisher: 'Example', license: 'Public', locator: 'Three files', credit: 'CHARA Array/MIRC, as calibrated by the authors' }, + star: { diameterMas: cell(2.742, 'radius'), limbPowerLaw: cell(0.231, 'limb'), inclinationDegrees: cell(85.63, 'inclination'), positionAngleDegrees: cell(26.09, 'position angle'), rotationPeriodDays: cell(54.2, 'period') }, + recipe: { level: 3, regularizer: 'sobel2', weight: 10, iterations: 1000, source: 'the authors\' script' }, referenceNight: '2011-09-14', codes: { rotir: 'ad308759b741b861b6c19fedd001c323a3a64479', oitools: 'f42d2beaf2760cb4beaeba0e0d0f1674a3c6c66a' }, beamMas: 0.5044, + nights: [{ night: '2011-09-02', vis2: 360, closurePhases: 432, subObserverLongitude: 79.131 }, { night: '2011-09-14', vis2: 864, closurePhases: 1104, subObserverLongitude: 0 }, { night: '2011-09-24', vis2: 200, closurePhases: 240, subObserverLongitude: -66.78 }], + points: { vis2: 1424, closurePhases: 1776 }, fit: { vis2: 1.717641, closurePhase: 2.300958, spotlessVis2: 3.127638, spotlessClosurePhase: 28.291627 }, + spots: { ratio: 2.143, scale: 1.01, twins: [{ scale: 1, ratio: 2.94, fits: true, chi2: { vis2: 1.02, closurePhase: 1.03 } }, { scale: 1.02, ratio: 1.4, fits: false, chi2: { vis2: 3.7, closurePhase: 9.4 } }] }, + halves: { correlation: 0.828 }, verdict: cast ? { cast: true, reasons: [] } : { cast: false, reasons: ['its spots are no stronger than a spotless disc\'s (ratio 1.40, limit 2)'] }, table: { minimumPercent: 71.777, maximumPercent: 110.192 } }); +const HOST = () => ({ content: { datasets: { defaultDataset: 'color', controls: [{ id: 'color', label: 'Color' }] } }, text: { datasets: { color: { title: 'Color' } } }, manifest: { inputs: [{ id: 'star-color', path: 'photometry/color.json', consumers: ['datasets'] }] }, + raster: { surfaces: [{ id: 'color', output: 'hd-1-surface-{id}{suffix}.webp', thumbnail: 'hd-1-dataset-{id}.webp', science: { kind: 'stellar-photometric-color' } }] }, + descriptor: { properties: { recipe: { surfaces: [{ id: 'body', datasets: [{ id: 'color', source: 'content', material: 'emission' }] }] } } } }); + +test('a season that did not pass its checks is no map, and one that did says what it was fitted with', () => { + assert.throws(() => reducedSurface(CHOICE, receipt(false), TABLE), /is not cast: its spots are no stronger/u); + assert.throws(() => reducedSurface({ ...CHOICE, program: 'another' }, receipt(), TABLE), /not this season's/u); + assert.throws(() => reducedSurface(CHOICE, receipt(), TABLE.replace(' "Facing"', '')), /not a surface map/u); + const map = reducedSurface(CHOICE, receipt(), TABLE); + assert.deepEqual([map.inclinationDegrees, map.positionAngleDegrees, map.periodDays, map.diameterMas, map.spotScale], [85.63, 26.09, 54.2, 2.742, 1.01]); + assert.deepEqual(map.unfitTwins, [{ scale: 1.02, closurePhase: 9.4 }]); + assert.equal(span(map.nights), '2 to 24 September 2011'); assert.ok(Math.abs(unseenDegrees(map) - 34.089) < 1e-6); +}); + +test('the page gains the star in its color over the map, the map with its scale, and an outline of what was never seen', () => { + const map = reducedSurface(CHOICE, receipt(), TABLE), { files, opensOn } = surfaceMapFiles(RESOLVED_MAPS, { host: 'hd-1', maps: [CHOICE] }, { id: 'hd-1', name: 'HD 1', colorHex: '#ffddbb' }, [map], HOST()); + const raster = JSON.parse(files.get('src/objects/hd-1/source/preparation/raster.json')!) as { surfaces: { id: string; science: Record }[] }; + const content = JSON.parse(files.get('src/objects/hd-1/source/content/object.json')!) as { datasets: { defaultDataset: string; controls: { id: string; notes?: string; step?: unknown }[] } }; + const manifest = JSON.parse(files.get('src/objects/hd-1/source/manifest.json')!) as { inputs: { path: string }[]; generatedIntermediates: { id: string; path: string; generator?: string; sourceBinding?: { references: { catalogueId: string }[] } }[] }; + assert.equal(opensOn, 'color-surface'); assert.deepEqual(content.datasets.controls.map(control => control.id), ['color-surface', 'surface-2011-09', 'color']); + const drawn = raster.surfaces.find(surface => surface.id === 'surface-2011-09')!.science, natural = raster.surfaces.find(surface => surface.id === 'color-surface')!.science; + assert.deepEqual([drawn.format, drawn.variable, drawn.outlineZeroOf, drawn.consumer, drawn.minimum, drawn.maximum], ['tecplot-lonlat-map', 'Brightness [%]', 'Facing', RESOLVED_CONSUMER, 70, 130]); + assert.equal(drawn.outlineLatitudes, undefined); assert.equal(natural.outlineZeroOf, undefined); assert.equal(natural.limbOf, 'color'); + // One map has no steps; its table is built here, kept out of git and restored from the source cache, and is bound to the nights, the paper and the code. + assert.equal(content.datasets.controls[1]!.step, undefined); + const input = manifest.generatedIntermediates.find(one => one.path === 'science/interferometry/star-mirc-2011-09.dat')!; + assert.equal(input.generator, 'packages/telescope-cli/src/archives/interferometry/surface-star.mts'); assert.ok(!manifest.inputs.some(one => one.path === input.path)); + assert.deepEqual(input.sourceBinding!.references.map(reference => reference.catalogueId), ['code-demos-hd-1', 'arxiv-2107-00001', 'rotir-jl']); + assert.equal(files.get('src/objects/hd-1/source/science/interferometry/star-mirc-2011-09.dat'), TABLE); + for (const control of content.datasets.controls.slice(0, 2)) assert.match(control.notes!, /not (?:a|their) published map|made in this project/u); + assert.match(content.datasets.controls[0]!.notes!, /34° of longitude that faced the Earth on none of the nights/u); + assert.deepEqual([...resolvedSourceRecords('2026-10-08', [map]).keys()], ['src/sources/code-demos-hd-1.json', 'src/sources/arxiv-2107-00001.json', 'src/sources/rotir-jl.json']); +}); + +test('a convention page takes the measured tilt and pole direction, and the records say what was checked', () => { + const map = reducedSurface(CHOICE, receipt(), TABLE), convention = { schema: 'cssearth-display-orientation@1', rightAscensionDegrees: 174.4, declinationDegrees: 43.5, displayMeridianDegrees: -90, source: 'No measured rotation axis or period is used.' }; + assert.equal(isConventionOnly(convention), true); + const measured = measuredAxisRotation(convention, { rightAscensionDegrees: 354.39, declinationDegrees: 46.46 }, map); + assert.equal(isConventionOnly(measured), false); assert.match(String(measured.source), /inclination 85\.63 degrees.*position angle 26\.09 degrees east of north.*54\.2 d/u); + // The pole tilted 4.4 degrees toward us and 26 degrees east of north: north of the star on the sky, away from its own declination by about that. + assert.ok(Number(measured.declinationDegrees) > 20 && Number(measured.declinationDegrees) < 46.46); + const readme = '# HD 1\n\n## Sources\n\n**Limb.** x\n\n## Evidence\n\ny\n\n## Known problems\n\n- **Assumptions of the frame.** The axis\'s position angle and the rotation phase are conventions.\n- **Model limb.** z\n'; + const written = withSurfaceReadme(readme, map); + assert.match(written, /\*\*Surface from interferometry\.\*\* .*1,424 squared visibilities and 1,776 closure phases.*chi-squared of 1\.72 on the squared visibilities and 2\.30 on the closure phases.*2\.14 times.*2\.77 mas.*correlation 0\.83.*2% larger do not fit on the sphere at all \(closure phases 9\.40\)/su); + assert.ok(written.indexOf('**Surface from interferometry.**') < written.indexOf('## Evidence')); + assert.match(written, /- \*\*Surface map\.\*\* It is infrared brightness/u); assert.doesNotMatch(written, /position angle and the rotation phase are conventions/u); + assert.equal(withSurfaceReadme(written, map), written); + const ledger = withSurfaceLedger({ schema: 'cssearth-investigation-ledger@1', objectId: 'hd-1', entries: [{ id: 'placement', subject: 'Placement', status: 'included', finding: 'x' }] }, map) as { entries: { id: string; status: string; finding: string }[] }; + assert.deepEqual(ledger.entries.map(entry => entry.id), ['placement', 'surface-star-mirc-2011-09', 'rotation-axis']); + assert.match(ledger.entries[1]!.finding, /Spotless twins of 2\.80 mas do not fit on the 2\.742 mas sphere/u); + assert.deepEqual(withSurfaceLedger(ledger, map), ledger); + const notice = withSurfaceNotice('# HD 1 credits\n\nColor: x.\n', map); + assert.match(notice, /^# HD 1 credits\n\nColor: x\.\n\nSurface map: CHARA Array\/MIRC, as calibrated by the authors; .*Author et al\. \(2021\), ApJ 1, 1; fitted in this project with ROTIR\.jl ad308759 and OITOOLS\.jl f42d2bea/u); + assert.equal(withSurfaceNotice(notice, map), notice); +}); diff --git a/src/objects/arcturus/NOTICE.md b/src/objects/arcturus/NOTICE.md index 118a311afa7..7bda2da2d8e 100644 --- a/src/objects/arcturus/NOTICE.md +++ b/src/objects/arcturus/NOTICE.md @@ -3,3 +3,5 @@ Shape: a sphere of the radius from Lacour, Meimon, Thiébaut et al., "The limb-darkened Arcturus: imaging with the IOTA/IONIC interferometer", A&A 485, 561 (2008), arXiv:0804.0192. No image of the photosphere is shown; the neutral gray is a display convention. Placement: SIMBAD (CDS, Strasbourg) position, proper motion, parallax and radial velocity with the references SIMBAD lists. No measured rotation axis is adopted: the display axis is celestial north. + +Limb darkening: Lacour et al. (2008), A&A 485, 561. diff --git a/src/objects/arcturus/README.md b/src/objects/arcturus/README.md index 6e24e3bb7b0..194ed4ef614 100644 --- a/src/objects/arcturus/README.md +++ b/src/objects/arcturus/README.md @@ -14,6 +14,7 @@ Rotation: no rotation axis or period is adopted here; see Known problems for wha Color dataset: The color of Arcturus's spectrum as the Pulkovo spectrophotometric catalogue measured it from the ground, 320-1080 nm at 10 nm resolution. Its samples from 380 to 780 nm are weighted by the CIE 1931 2° observer and converted to sRGB with the D65 white, brightest channel full ([stellar-photometric-color.ts](../../../packages/bake/src/objects/stellar/stellar-photometric-color.ts)): **#ffd8a5**. The file, how it is read and the full citation are in [stellar-color.json](source/photometry/stellar-color.json). The disc is darkened toward its edge by the quadratic V-band law that Claret & Bloemen (2011, A&A 529, A75) compute from ATLAS model atmospheres, read at 4295 K and log g 1.66: the edge is 18% as bright as the centre. That law is a model, not a measurement of this star. Gravity: log g 1.66 +/- 0.05 measured by Ramirez & Allende Prieto 2011 (ApJ 743, 135; https://arxiv.org/abs/1109.4425), abstract. The catalogue swatch, the minimap and the navigation marker use the same color. [stellar-spectra/author.mts](../../../packages/telescope-cli/authoring/stellar-spectra/author.mts) writes the colors from these inputs, and `--check` recomputes them. Cross-check: Kiehling (1987), HR 5340: an independent ground-based scan gives #ffdab0, 11 levels from the dataset color in its most different channel (the threshold for agreement is 12). +**Limb.** The disc is dimmed toward the limb by the power law I(mu) = mu^0.258 that Lacour et al. (2008), A&A 485, 561 fit to the star's resolved disc (IOTA/IONIC H band, 1.5-1.8 um, seven channels fitted with one law; not a visible band). ## Evidence @@ -23,6 +24,7 @@ Run of 2026-09-21 (this version): ## Known problems -The cited paper images Arcturus’s limb darkening; this package shows a neutral sphere and does not cast that image. No mass is adopted. +The cited paper's images of Arcturus are a limb-darkened disc without features; this package draws the limb law that paper fitted and casts no image. No mass is adopted. +- **Measured limb, other band.** The law was measured or fixed outside the visible band the color is drawn in; the visible limb is not measured. [Inputs](source/manifest.json) · [Preparation](source/preparation) · [Delivered files](inventory.json) · [Credits](NOTICE.md) diff --git a/src/objects/arcturus/inventory.json b/src/objects/arcturus/inventory.json index 91f5ca0d2df..68355df4035 100644 --- a/src/objects/arcturus/inventory.json +++ b/src/objects/arcturus/inventory.json @@ -4,14 +4,14 @@ { "location": "public", "filename": "arcturus-arrival.webp", - "bytes": 29610, - "sha256": "287c9c1255751a7d85e5333e6824cae2eba85da533b2efab18b8383203ab9db7" + "bytes": 29574, + "sha256": "3943b519081d205b0eb629fb0c527d80bff7c839675550cf6206fbee070f8571" }, { "location": "public", "filename": "arcturus-billboard.webp", - "bytes": 5234, - "sha256": "d3e2c9c4fe3d99f8c71bf83d5f14cab446ffd4482227b3cb28ee12eeaa644b5a" + "bytes": 5324, + "sha256": "02dbb5008bc559c8a173d23862beb137a41c0fc00ba80a92d37781b41b7fd044" }, { "location": "public", @@ -22,8 +22,8 @@ { "location": "public", "filename": "arcturus-limb-color@2x.webp", - "bytes": 102886, - "sha256": "7b5d2a70fd4a6859841bdefeaa0ba643da047a3dd4d9cfbbbc605c1b2aa01876" + "bytes": 91978, + "sha256": "4441dc7f1ac1a87b1bc1ca0cc978b8644a2a872e3121bc147c4e13cff064522c" }, { "location": "public", @@ -46,14 +46,14 @@ { "location": "prepared", "filename": "assets.json", - "bytes": 1947, - "sha256": "f3170877ac6693ad8d6343e76d281b4d4d06a0e86c666cdf3483bf5dcf78bd96" + "bytes": 1680, + "sha256": "2f46fa68e0aad2aa1e9c5a62195c0f8cd412d78e0dbcb699d95a9fbaa65df13e" }, { "location": "prepared", "filename": "authored-preparation.json", - "bytes": 723, - "sha256": "6b6dc8250e0bd6385ca33fb7b11d82ad092b852ce16e0414495785c21c29e84f" + "bytes": 661, + "sha256": "6b65026b05071ff7c90319b1e91066eabb33447c9615bc5b9e801bdbd2731a49" }, { "location": "prepared", @@ -70,8 +70,8 @@ { "location": "prepared", "filename": "datasets.json", - "bytes": 1011, - "sha256": "51c211163566e0a6b9a63c705619d0d3cbf6d823a01c4450769f2ae237b6a92e" + "bytes": 1059, + "sha256": "338a9e968fa7e1d3ce0890c0b4ce56da15d9df98c8d3ac9ea3f6237d14c23dd5" }, { "location": "prepared", @@ -94,8 +94,8 @@ { "location": "prepared", "filename": "scene.json", - "bytes": 393339, - "sha256": "127f6a05550bcbc8de10945eb53850a347603b24901fc61cf8f91944320ccb1c" + "bytes": 393351, + "sha256": "d97f9140e9bd302fe14b9e724c788c786e9529cbaadd9aca949524da98a22886" }, { "location": "prepared", diff --git a/src/objects/arcturus/investigations.json b/src/objects/arcturus/investigations.json index c762d6a3ce7..8ae57497fec 100644 --- a/src/objects/arcturus/investigations.json +++ b/src/objects/arcturus/investigations.json @@ -11,6 +11,15 @@ "https://simbad.cds.unistra.fr/simbad/sim-id?Ident=alf+Boo", "https://arxiv.org/abs/0804.0192" ] + }, + { + "id": "limb-darkening", + "subject": "Limb darkening", + "status": "included", + "finding": "The disc is dimmed toward the limb by the power law I(mu) = mu^0.258 that Lacour et al. (2008), A&A 485, 561 fit to the star's resolved disc (IOTA/IONIC H band, 1.5-1.8 um, seven channels fitted with one law; not a visible band).", + "evidence": [ + "https://doi.org/10.1051/0004-6361:200809611" + ] } ] } diff --git a/src/objects/arcturus/source/content/object.json b/src/objects/arcturus/source/content/object.json index f18c019064c..d9477c5993d 100644 --- a/src/objects/arcturus/source/content/object.json +++ b/src/objects/arcturus/source/content/object.json @@ -41,7 +41,7 @@ { "id": "color", "label": "Color", - "qualification": "Color from a measured spectrum of the star (Pulkovo catalogue). The disc is unresolved; its darker edge follows a model limb-darkening law, not a measurement.", + "qualification": "Color from a measured spectrum of the star (Pulkovo catalogue). The disc is unresolved; its darker edge follows a model limb-darkening law, not a measurement; darkening toward the edge from a published law.", "thumbnail": "arcturus-dataset-color.webp", "surface": "arcturus-surface-color@2x.webp", "poles": "arcturus-poles-color@2x.webp", @@ -51,7 +51,7 @@ "url": "https://cdsarc.cds.unistra.fr/viz-bin/cat/III/201" }, "falseColor": false, - "notes": "The color of Arcturus's spectrum as the Pulkovo spectrophotometric catalogue measured it from the ground, 320-1080 nm at 10 nm resolution. No resolved image of the surface is cast; the display axis is celestial north, a convention." + "notes": "The color of Arcturus's spectrum as the Pulkovo spectrophotometric catalogue measured it from the ground, 320-1080 nm at 10 nm resolution. No resolved image of the surface is cast; the display axis is celestial north, a convention. The darkening toward the edge is the power law I(mu) = mu^0.258 that Lacour et al. (2008), A&A 485, 561 fit to the star's resolved disc (IOTA/IONIC H band, 1.5-1.8 um, seven channels fitted with one law; not a visible band)." } ] }, diff --git a/src/objects/arcturus/source/manifest.json b/src/objects/arcturus/source/manifest.json index 792b7813c9b..c9855f14b16 100644 --- a/src/objects/arcturus/source/manifest.json +++ b/src/objects/arcturus/source/manifest.json @@ -137,32 +137,6 @@ ] } }, - { - "id": "arcturus-claret-2011-v-quadratic", - "path": "photometry/claret-2011-v-quadratic.tsv", - "origin": "https://vizier.cds.unistra.fr/viz-bin/asu-tsv", - "credit": "Claret & Bloemen (2011), A&A 529, A75; VizieR J/A+A/529/A75 (CDS)", - "license": "CDS VizieR catalogue: free use with citation", - "licenseEvidence": [ - "https://cds.unistra.fr/vizier-org/licences_vizier.html" - ], - "acquisition": "VizieR ASU TSV for J/A+A/529/A75/table-af, the grid nodes around the star's temperature and gravity (V filter, least-squares fit, solar metallicity, 2 km/s microturbulence), with the response's dated comment lines and blank lines removed so the bytes are stable; restored through source/preparation/acquisition.json.", - "redistribution": "A few catalogue rows, with citation", - "consumers": [ - "assets", - "datasets" - ], - "sourceBinding": { - "kind": "catalogued", - "references": [ - { - "catalogueId": "vizier-j-a-a-529-a75-claret-2011", - "role": "method", - "evidence": "src/objects/arcturus/source/photometry/claret-2011-v-quadratic.tsv" - } - ] - } - }, { "id": "arcturus-crosscheck-spectrum", "path": "photometry/crosscheck-kiehling-5340.tsv", @@ -188,11 +162,28 @@ } ] } + }, + { + "id": "arcturus-lacour-2008-limb-darkening", + "path": "photometry/lacour-2008-limb-darkening.json", + "origin": "https://doi.org/10.1051/0004-6361:200809611", + "credit": "Lacour et al. (2008), A&A 485, 561", + "license": "Factual numerical measurements; source attribution retained", + "acquisition": "Transcribed from the paper, each value with its quoted cell", + "redistribution": "Factual parameter transcription only; no paper figures", + "consumers": [ + "assets", + "datasets" + ], + "sourceBinding": { + "kind": "local", + "reason": "Published limb-darkening law transcribed with its cells; repinned when edited." + } } ], "generatedIntermediates": [ { - "id": "stellar-color-context-marker", + "id": "limb-darkened-disc-context-marker", "path": "presentation/context.png", "origin": "https://cdsarc.cds.unistra.fr/viz-bin/cat/III/201", "credit": "Photosphere color from the measured spectrum as a uniform disc; rendered by packages/telescope-cli/authoring/stellar-spectra/author.mts", @@ -207,7 +198,7 @@ "arcturus-pulkovo-spectrophotometry", "arcturus-cie-1931-2deg-cmf", "arcturus-preparation-raster", - "arcturus-claret-2011-v-quadratic" + "arcturus-lacour-2008-limb-darkening" ] }, "generator": "packages/telescope-cli/authoring/stellar-spectra/author.mts", diff --git a/src/objects/arcturus/source/photometry/claret-2011-v-quadratic.tsv b/src/objects/arcturus/source/photometry/claret-2011-v-quadratic.tsv deleted file mode 100644 index a0d3ea37fca..00000000000 --- a/src/objects/arcturus/source/photometry/claret-2011-v-quadratic.tsv +++ /dev/null @@ -1,19 +0,0 @@ -logg Teff Z xi a b Filt Met Mod -[cm/s2] K [Sun] km/s ------ ------ ---- ---- ------- ------- -- - - - 1.50 4000 0.0 2.0 1.3026 -0.4377 v L A - 2.00 4000 0.0 2.0 1.2949 -0.4268 v L A - 1.50 4250 0.0 2.0 1.2707 -0.3907 v L A - 2.00 4250 0.0 2.0 1.2666 -0.3818 v L A - 1.50 4500 0.0 2.0 1.2133 -0.3324 v L A - 2.00 4500 0.0 2.0 1.2066 -0.3206 v L A - 1.50 4750 0.0 2.0 1.1512 -0.2755 v L A - 2.00 4750 0.0 2.0 1.1409 -0.2588 v L A - 1.50 4000 0.0 2.0 0.9335 -0.0949 V L A - 2.00 4000 0.0 2.0 0.9298 -0.0937 V L A - 1.50 4250 0.0 2.0 0.8522 -0.0242 V L A - 2.00 4250 0.0 2.0 0.8543 -0.0269 V L A - 1.50 4500 0.0 2.0 0.7653 0.0463 V L A - 2.00 4500 0.0 2.0 0.7706 0.0423 V L A - 1.50 4750 0.0 2.0 0.6893 0.1012 V L A - 2.00 4750 0.0 2.0 0.6926 0.1012 V L A diff --git a/src/objects/arcturus/source/photometry/lacour-2008-limb-darkening.json b/src/objects/arcturus/source/photometry/lacour-2008-limb-darkening.json new file mode 100644 index 00000000000..5c9468d7982 --- /dev/null +++ b/src/objects/arcturus/source/photometry/lacour-2008-limb-darkening.json @@ -0,0 +1,13 @@ +{ + "schema": "cssearth-published-limb-darkening@1", + "objectId": "arcturus", + "law": "power", + "source": "Lacour et al. (2008), A&A 485, 561, Table 3, power-law limb darkening fitted to the IOTA/IONIC visibilities and closure phases of May 2006 (https://doi.org/10.1051/0004-6361:200809611)", + "band": "IOTA/IONIC H band, 1.5-1.8 um, seven channels fitted with one law; not a visible band", + "alpha": { + "value": 0.258, + "uncertainty": 0.003, + "cell": "α = 0.258 ± 0.003" + }, + "note": "I(mu)/I(1) = mu^alpha, the paper's equation 3 (Hestroffer 1997). Fitted with the limb-darkened diameter 20.900 ± 0.007 mas at a reduced chi-squared of 1.962; the paper recommends the power law over its two-parameter fit, which does no better (1.959). The record's radius keeps the diameter of that two-parameter fit, 20.922 mas, 0.1% larger. The visible limb is not measured." +} diff --git a/src/objects/arcturus/source/preparation/acquisition.json b/src/objects/arcturus/source/preparation/acquisition.json index fee943cd5c5..cb87f8c9925 100644 --- a/src/objects/arcturus/source/preparation/acquisition.json +++ b/src/objects/arcturus/source/preparation/acquisition.json @@ -10,42 +10,6 @@ "url": "https://cdsarc.cds.unistra.fr/ftp/III/201/table5.dat", "path": "photometry/pulkovo-table5.dat" }, - { - "kind": "request-download", - "groups": [ - "restore", - "refresh" - ], - "path": "photometry/claret-2011-v-quadratic.tsv", - "url": "https://vizier.cds.unistra.fr/viz-bin/asu-tsv", - "form": { - "-source": "J/A+A/529/A75/table-af", - "-out.max": "500", - "Teff": "4000..4750", - "logg": "1.5..2.0", - "Z": "=0", - "xi": "=2", - "Filt": "V", - "Met": "L", - "Mod": "A", - "-out": "logg,Teff,Z,xi,a,b,Filt,Met,Mod" - }, - "requiredText": [ - "logg\tTeff" - ], - "replacements": [ - { - "pattern": "^#.*\\n", - "flags": "gm", - "replacement": "" - }, - { - "pattern": "^\\s*\\n", - "flags": "gm", - "replacement": "" - } - ] - }, { "kind": "request-download", "groups": [ diff --git a/src/objects/arcturus/source/preparation/raster.json b/src/objects/arcturus/source/preparation/raster.json index 669f76a6c10..59e9d305ca3 100644 --- a/src/objects/arcturus/source/preparation/raster.json +++ b/src/objects/arcturus/source/preparation/raster.json @@ -26,25 +26,11 @@ "falseColor": false, "science": { "kind": "stellar-photometric-color", - "qualification": "Uniform photosphere color from the star's ground-based spectrophotometry (Pulkovo catalogue). The disc is unresolved: it is darkened toward the limb by the quadratic V-band law Claret & Bloemen (2011) compute from ATLAS model atmospheres at the star's temperature and gravity, a model rather than a measurement; no map or absolute brightness is implied. Self-luminous, so no lighting.", + "qualification": "Photosphere color from the star's ground-based spectrophotometry (Pulkovo catalogue). The disc is unresolved: it is darkened toward the limb by the quadratic V-band law Claret & Bloemen (2011) compute from ATLAS model atmospheres at the star's temperature and gravity, a model rather than a measurement; no map or absolute brightness is implied. Self-luminous, so no lighting. The disc is dimmed toward the limb by the power law I(mu) = mu^0.258 that Lacour et al. (2008), A&A 485, 561 fit to the star's resolved disc (IOTA/IONIC H band, 1.5-1.8 um, seven channels fitted with one law; not a visible band).", "limbDarkening": { - "law": "quadratic", - "path": "photometry/claret-2011-v-quadratic.tsv", - "grid": { - "teffK": 4295, - "logg": 1.66, - "models": { - "Filt": "V", - "Met": "L", - "Mod": "A" - } - }, - "columns": { - "teff": "Teff", - "logg": "logg", - "u1": "a", - "u2": "b" - } + "law": "power", + "published": true, + "path": "photometry/lacour-2008-limb-darkening.json" } } } @@ -67,7 +53,7 @@ }, "limbMaterial": { "logicalSize": 496, - "composition": "transparent: the sphere's own silhouette is the limb; the light just outside it is on the off-limb plate", + "composition": "black alpha darkens the photosphere according to the selected limb-darkening law; transparent outside the silhouette", "surfaceReplacement": false, "runtimeAlphaProcessing": false }, diff --git a/src/objects/arcturus/source/presentation/context.png b/src/objects/arcturus/source/presentation/context.png index d2b0260742f..320118e7cda 100644 Binary files a/src/objects/arcturus/source/presentation/context.png and b/src/objects/arcturus/source/presentation/context.png differ diff --git a/src/objects/hd-95086-system/inventory.json b/src/objects/hd-95086-system/inventory.json index 49e3827ccf4..23a894a2779 100644 --- a/src/objects/hd-95086-system/inventory.json +++ b/src/objects/hd-95086-system/inventory.json @@ -4,8 +4,8 @@ { "location": "prepared", "filename": "members.json", - "bytes": 1091, - "sha256": "7a82cea03a86e3ca69a24154307ed8732c25fd103a82f43083104b5f431db96d" + "bytes": 1120, + "sha256": "868c3a494f84a5f2547727d5fb6585fa5a3fe744dc301d5233cdb21b7f4811a8" }, { "location": "prepared", diff --git a/src/objects/lmc/inventory.json b/src/objects/lmc/inventory.json index 745b6a2afc8..ced783ae41b 100644 --- a/src/objects/lmc/inventory.json +++ b/src/objects/lmc/inventory.json @@ -40,8 +40,8 @@ { "location": "prepared", "filename": "members.json", - "bytes": 948, - "sha256": "33f466e18f2e7a15d2a3687bc233e53336c5a8c48af1b16e1bad5ffd0d12796f" + "bytes": 6357, + "sha256": "db6b200d11cdcc7651eef9452c36636a2f34bed414630c97ba04d9c450befe36" }, { "location": "prepared", @@ -52,8 +52,8 @@ { "location": "prepared", "filename": "plain-stars.bin", - "bytes": 1516, - "sha256": "af730a3a90067f134b9b30057a142afb77ced0132c52d8f38481cdde16ae12ca" + "bytes": 999, + "sha256": "7961967d97a11d24a0b89944c8c9537ebade740c2488251d8e4093118a9883de" }, { "location": "prepared", diff --git a/src/objects/m31/inventory.json b/src/objects/m31/inventory.json index 93635c3cfa8..d6c495191e6 100644 --- a/src/objects/m31/inventory.json +++ b/src/objects/m31/inventory.json @@ -28,8 +28,8 @@ { "location": "prepared", "filename": "members.json", - "bytes": 1411, - "sha256": "a5be57d8ce024ca1980e795c70049c64ad3d8a7727ba2853ce6d59984d86046a" + "bytes": 2126, + "sha256": "a313832223f67e1729712a95a012f06965056acdbf2900a40cc3bc7d67ebcce2" }, { "location": "prepared", @@ -40,8 +40,8 @@ { "location": "prepared", "filename": "plain-stars.bin", - "bytes": 1804, - "sha256": "0cfc42e3624a1355d84a0899bd261abf43b3dbb83d153380eddfea7777e9b467" + "bytes": 1771, + "sha256": "bc81647f793dd955a43e622a353f5d18c6288dd5d5e4a31064884e852354aa95" }, { "location": "prepared", diff --git a/src/objects/m33/inventory.json b/src/objects/m33/inventory.json index ac696396795..1f5e50cd4ad 100644 --- a/src/objects/m33/inventory.json +++ b/src/objects/m33/inventory.json @@ -28,14 +28,14 @@ { "location": "prepared", "filename": "members.json", - "bytes": 595, - "sha256": "a0a2e7782a90d4c2b2b9f957b734196b6449f65aac8d007d24e4c53cbc92915e" + "bytes": 7310, + "sha256": "ee3f449cc6e55c17bae808ddcfc1d55b13b8802cfa7df17dd4c34898e6b5ad5c" }, { "location": "prepared", "filename": "plain-stars.bin", - "bytes": 2998, - "sha256": "4898db7f2d0f6cb4fd530ae8aecb5b9c931f449aeb40ad2a3ca8b38cadabe711" + "bytes": 2513, + "sha256": "8205d08a33e21ac50a2a60b1a378c5a5b9aea2d48a26f037063dd3e7dfdcca1e" }, { "location": "prepared", diff --git a/src/objects/milky-way-volume/README.md b/src/objects/milky-way-volume/README.md index c52aadde2bb..4d4706b6cbc 100644 --- a/src/objects/milky-way-volume/README.md +++ b/src/objects/milky-way-volume/README.md @@ -39,7 +39,7 @@ Add `--acquire-source .local/volume-source-cache` to `prepare:volume` to reacqui **Sky.** Six 1536 × 1536 WebP cube faces sample the NASA map with a fixed exposure of 4.5, the sRGB curve and a final gain of 0.12. A shadow factor removes faint grain below display luminance 0.04. The NASA image omits bright Hipparcos/Tycho stars and none are added: the stars are the catalogue dots. One rule picks the picture by zoom: nearer the viewed body than the thin disc's 300 pc scale height (Bland-Hawthorn & Gerhard 2016; `discHalfHeightM` in the Sun's `source/navigation/universe.json`) the NASA map is the sky; from twice that the volume, the backing and the galaxies beyond show instead, so orbiting at one distance never switches them. -**Dots.** [`prepare-catalogue-points.mts`](../../../packages/bake/cli/prepare-catalogue-points.mts) has Astropy convert each row to Sun-centred ICRS; rows without a distance are left out. An arm-tracer layer is kept when its arm score in the [merge recipe](source/tracers/merge.json) is 0.5 or more. Kinematic distances with uncertainty over 1 kpc are dropped (532 sources). Each layer keeps its catalogue color, mixed halfway to white; tone follows absolute magnitude where the catalogue measures it. [`merge-catalogue-points.mts`](../../../packages/bake/cli/merge-catalogue-points.mts) thins the galaxy-wide, 3 kpc and 800 pc levels to the thin disc's density law, exponential with a 2.6 kpc scale length (Bland-Hawthorn & Gerhard 2016). The 100 pc and 20 pc levels are not thinned: inside the galaxy the nearby-star census is drawn as measured, except that the 100 pc census fades out from 50 pc to its 100 pc reach, so it does not end in a ball of dense stars (`fadePcFromSun`). [`stack-catalogue-points.mts`](../../../packages/bake/cli/stack-catalogue-points.mts) joins them into [one bank](source/dots/stack.json) of 33,820 dots, revealed level by level as you zoom in: each level fills in as the view narrows from three times its radius, so its tapered ball of stars arrives as you approach it. At most 4,000 of the bank's dots show at once from outside (`screenBudget`); past that an even share, the same dots every frame, is drawn. Inside the galaxy the budget rises to 6,000 as the 100 pc level appears, above the 2,800 to 5,810 the unthinned levels put on screen along a zoom path from 1 to 1,000 ly, so none of them is thinned. Near the Sun the galaxy-wide and 3 kpc levels dim to half (`nearOpacity`), since from there those distant stars would look faint. The star packages of this repository that the map does not name are dots of this bank too ([`packaged-stars`](source/packaged-stars/points.json), 2,174 on 2026-10-07): each sits in the level of its own distance from the Sun, is never thinned by a density cap and takes no other dot's room, and a catalogue row within 0.2 pc of one is the same star and is left out (93 rows). Every other dot is the one the bank drew before, in the same order and color. +**Dots.** [`prepare-catalogue-points.mts`](../../../packages/bake/cli/prepare-catalogue-points.mts) has Astropy convert each row to Sun-centred ICRS; rows without a distance are left out. An arm-tracer layer is kept when its arm score in the [merge recipe](source/tracers/merge.json) is 0.5 or more. Kinematic distances with uncertainty over 1 kpc are dropped (532 sources). Each layer keeps its catalogue color, mixed halfway to white; tone follows absolute magnitude where the catalogue measures it. [`merge-catalogue-points.mts`](../../../packages/bake/cli/merge-catalogue-points.mts) thins the galaxy-wide, 3 kpc and 800 pc levels to the thin disc's density law, exponential with a 2.6 kpc scale length (Bland-Hawthorn & Gerhard 2016). The 100 pc and 20 pc levels are not thinned: inside the galaxy the nearby-star census is drawn as measured, except that the 100 pc census fades out from 50 pc to its 100 pc reach, so it does not end in a ball of dense stars (`fadePcFromSun`). [`stack-catalogue-points.mts`](../../../packages/bake/cli/stack-catalogue-points.mts) joins them into [one bank](source/dots/stack.json) of 33,820 dots, revealed level by level as you zoom in: each level fills in as the view narrows from three times its radius, so its tapered ball of stars arrives as you approach it. At most 4,000 of the bank's dots show at once from outside (`screenBudget`); past that an even share, the same dots every frame, is drawn. Inside the galaxy the budget rises to 6,000 as the 100 pc level appears, above the 2,800 to 5,810 the unthinned levels put on screen along a zoom path from 1 to 1,000 ly, so none of them is thinned. Near the Sun the galaxy-wide and 3 kpc levels dim to half (`nearOpacity`), since from there those distant stars would look faint. The star packages of this repository that the map does not name are dots of this bank too ([`packaged-stars`](source/packaged-stars/points.json), 2,124 on 2026-10-08): each sits in the level of its own distance from the Sun, is never thinned by a density cap and takes no other dot's room, and a catalogue row within 0.2 pc of one is the same star and is left out (93 rows). Every other dot is the one the bank drew before, in the same order and color. **Old stars.** The bulge's height comes from a second bank, [the old stars](source/old-star-dots/stack.json): the RR Lyrae stars within 3 kpc of the centre from OGLE (Prudil et al. 2025), and beyond it Gaia DR3's (Li et al. 2023), each at its measured distance, one in 8 ([merge](source/old-stars/merge.json)). [`gaia-rr-lyrae-sample.mts`](../../../packages/bake/authoring/milky-way/gaia-rr-lyrae-sample.mts) leaves out the Magellanic Clouds' and the Sagittarius dwarf's own stars, and keeps a Gaia star with a chance falling exponentially past 3 kpc on the disc's 2.6 kpc scale length, so the bulge thins into the disc; farther out Gaia finds these stars mostly near the Sun, through the disc's dust, which would draw a bubble around the Sun, not a structure. The bank's 8,793 dots are whole within 100 kpc of the Sun, at most 3,500 on screen, and fade out as the view narrows from 9 to 3 kpc across at the Sun (`fadeOutUnits`): from outside they give the flat disc its bulge, from within they would cover the view. These are presentation choices. diff --git a/src/objects/milky-way-volume/source/packaged-stars/positions.csv.gz b/src/objects/milky-way-volume/source/packaged-stars/positions.csv.gz index 6649aa8b1cc..5646f8c595c 100644 Binary files a/src/objects/milky-way-volume/source/packaged-stars/positions.csv.gz and b/src/objects/milky-way-volume/source/packaged-stars/positions.csv.gz differ diff --git a/src/objects/milky-way/inventory.json b/src/objects/milky-way/inventory.json index c7ba6aa2378..fee460938ab 100644 --- a/src/objects/milky-way/inventory.json +++ b/src/objects/milky-way/inventory.json @@ -28,8 +28,8 @@ { "location": "prepared", "filename": "members.json", - "bytes": 83414, - "sha256": "d338510a3e0d9475dccf0828958afe6dbbdcc8c659f3112ade0d82075b7b5c65" + "bytes": 91985, + "sha256": "2c13a8a0319e4a9281d6a90bd08958e76dd9b9ff81860c03968800eae7493111" }, { "location": "prepared", diff --git a/src/objects/observable-universe/inventory.json b/src/objects/observable-universe/inventory.json index 224f87055f7..3ec48e34659 100644 --- a/src/objects/observable-universe/inventory.json +++ b/src/objects/observable-universe/inventory.json @@ -64,14 +64,14 @@ { "location": "prepared", "filename": "world-context.json", - "bytes": 58518246, - "sha256": "9613d725c27af4398ac558d6510f6a9dcb63f35a71142830cebfbb6977a4b537" + "bytes": 58513815, + "sha256": "1cca0741e8d6105b7a94b12ae02b1fc014d8f75a80f153dc0eb6991eaa8464ac" }, { "location": "prepared", "filename": "world-index.json", - "bytes": 1414801, - "sha256": "462342002d9efa90783b5aa24dceea322bc8c122a7fc102ab6fb911961dd9ba3" + "bytes": 1339029, + "sha256": "094c2ee393ead027e78fd0508ddf48e2563371e4297786dae67ae0cea9b86ea4" }, { "location": "prepared", diff --git a/src/objects/smc/inventory.json b/src/objects/smc/inventory.json index 07a913d32e4..a1b90fd5426 100644 --- a/src/objects/smc/inventory.json +++ b/src/objects/smc/inventory.json @@ -52,8 +52,8 @@ { "location": "prepared", "filename": "members.json", - "bytes": 776, - "sha256": "604b8008cd4e1d374d9069a913e15a3ce168bd2f187e483c8fa5c0b36908d73a" + "bytes": 1490, + "sha256": "b2a606fdad744a5a291d443828740e7f297887120a798078cdf32b5501e66ab3" }, { "location": "prepared", @@ -64,8 +64,8 @@ { "location": "prepared", "filename": "plain-stars.bin", - "bytes": 1052, - "sha256": "9b8ed310defab92374f287b5d46fe2dfb947a9082627b45486bf6dba67386c13" + "bytes": 949, + "sha256": "e975ac998e7632708221c3e280a394b2ba154203c25543f799bf04ebd0b2b1a5" }, { "location": "prepared", diff --git a/src/objects/udkadua/NOTICE.md b/src/objects/udkadua/NOTICE.md index 31aa1d84776..a21c3bfe14c 100644 --- a/src/objects/udkadua/NOTICE.md +++ b/src/objects/udkadua/NOTICE.md @@ -6,4 +6,6 @@ Color: Kharitonov, Tereshchenko & Knyazeva (1988), Spectrophotometric Catalogue Placement: Gaia DR3 source 1939115478598580352: position, proper motion; distance: McDonald, Zijlstra & Watson (2017), MNRAS 471, 770, table 2, HIP 116584: distance 26.406 pc, the paper's parallax inverted (Gaia DR1's where it revised the star's, else the Hipparcos reduction of van Leeuwen 2007; section 2.3; fractional uncertainty 0.006), at which the luminosity and radius hold. This work has made use of data from the European Space Agency (ESA) mission Gaia, processed by the Gaia Data Processing and Analysis Consortium (DPAC). Identifiers: SIMBAD, CDS, Strasbourg. -Limb darkening: Claret & Bloemen (2011), A&A 529, A75, via VizieR J/A+A/529/A75. +Limb darkening: Martinez et al. (2021), ApJ 916, 60. + +Surface map: CHARA Array/MIRC, September 2011, as calibrated by the authors of Parks et al. (2021) and Martinez et al. (2021) and shipped with ROTIR.jl (F. Baron); the star's size, limb, axis and period from Martinez et al. (2021), ApJ 916, 60; fitted in this project with ROTIR.jl ad308759 and OITOOLS.jl f42d2bea (GPL-3.0 and LGPL-3.0, run as tools, not redistributed). https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data diff --git a/src/objects/udkadua/README.md b/src/objects/udkadua/README.md index 22ad0330d72..59a3ea42330 100644 --- a/src/objects/udkadua/README.md +++ b/src/objects/udkadua/README.md @@ -8,7 +8,9 @@ Its brightness, measured band by band, gives 24 times the Sun's luminosity at 4, **Color.** Kharitonov, Tereshchenko & Knyazeva (1988), Spectrophotometric Catalogue of Stars (Alma-Ata), record 1125: HR 8961; VizieR III/202, cross-checked against Burnashev (1985), Abastumani Astrophys. Obs. Bull. 59, 83; VizieR III/126, part2 record 520 (BS 8961) (1 levels apart at most, the threshold is 12), through the CIE 1931 2° observer: #ffddbb. Routes tried in order: stis-ngsl: HD 222107 is not in the library; pulkovo: HR 8961 is not in the catalogue; kiehling: HR 8961 is not among its 60 stars; gaia-xp: found, not needed after the color and its cross-check; kharitonov: used. -**Limb.** The disc is dimmed toward the limb by the quadratic law Claret & Bloemen (2011), A&A 529, A75 compute from ATLAS model atmospheres for the Johnson V band at 4,667 K and log g 2.76 (u1 0.730, u2 0.071): a model, because no fit of this star's limb is used. Gravity: log g 2.76 from 2025A&A...703A.128B, the median of its 2 spectra; the 17 published values span log g 2.3 to 3.22, across which the limb law changes by at most 0.2% of the centre brightness. +**Limb.** The disc is dimmed toward the limb by the power law I(mu) = mu^0.231 that Martinez et al. (2021), ApJ 916, 60 fit to the star's resolved disc (CHARA/MIRC H band, 1.5-1.7 um; not a visible band). + +**Surface from interferometry.** The datasets Color + brightness and Surface map are one fit of the star's surface to the 2,984 squared visibilities and 3,704 closure phases CHARA/MIRC measured on 6 nights, 2 to 24 September 2011 (infrared H band (1.49-1.73 um, eight channels)). The calibrated files are the authors', shipped with their code (https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data); the fit is ROTIR at the pinned commit (HEALPix level 3, sobel2 at weight 10, 1000 iterations, the settings of the authors' own script for these files), on a sphere of 2.742 mas with a power-law limb of 0.231, tilted 85.63° with its pole 26.09° east of north and turning in 54.2 d (Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366)). The fit leaves a reduced chi-squared of 1.72 on the squared visibilities and 2.30 on the closure phases (limit 3), where a spotless star leaves 3.13 and 28.29; its spots are 2.14 times those the same fit draws on a spotless star of 2.77 mas (limit 2); and two independent halves of the data give the same spots (correlation 0.83, limit 0.5). Spotless twins 2% smaller and 2% larger do not fit on the sphere at all (closure phases 7.42 and 9.40), so they do not decide the spots: a reading of this repository, stated in surface-star.mts. The page's axis is that measured one, and longitude 0 is the meridian that faced the Earth on 14 September 2011. Run again with `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`. Martinez et al. (2021), ApJ 916, 60; Parks et al. (2021), ApJ 913, 54 imaged the same nights; their maps are not redistributed here. ## Evidence @@ -18,8 +20,9 @@ Generated 2026-10-03 by [new-object-cli.mts](../../../packages/telescope-cli/src ## Known problems -- **Assumptions of the frame.** The axis's position angle and the rotation phase are conventions. +- **Surface map.** It is infrared brightness, drawn over the star's visible color as a darker tone: how dark the spots are in visible light is not measured. Nothing smaller than about 21° of the surface is resolved, and 34° of longitude never faced the Earth on these nights. The map is of 2 to 24 September 2011; spots change within months. +- **Assumptions of the frame.** The rotation phase is a convention: the star is drawn as it faced the Earth on one night and does not turn. - **Quoted text.** The introduction quotes sentences of the Wikipedia article "Lambda Andromedae" (revision 1374783321) verbatim, CC BY-SA 4.0. -- **Model limb.** The limb darkening is a model atmosphere at the star's temperature and gravity, not a measurement of this star. +- **Measured limb, other band.** The law was measured or fixed outside the visible band the color is drawn in; the visible limb is not measured. [Investigation ledger](investigations.json) · [Inputs](source/manifest.json) · [Preparation](source/preparation) · [Delivered files](inventory.json) · [Credits](NOTICE.md) diff --git a/src/objects/udkadua/inventory.json b/src/objects/udkadua/inventory.json index 008ccfd938d..9e480c334a8 100644 --- a/src/objects/udkadua/inventory.json +++ b/src/objects/udkadua/inventory.json @@ -4,14 +4,20 @@ { "location": "public", "filename": "udkadua-arrival.webp", - "bytes": 29164, - "sha256": "d8dd7ca33396840d01dc053bbeab7b786fef736c6ee56f4bd2da2d299c0cce7c" + "bytes": 31740, + "sha256": "b171e58cf596fa6b30e81df67fdb9f2f2c66ed7b53c5a048caa204a16e5b060d" }, { "location": "public", "filename": "udkadua-billboard.webp", - "bytes": 5148, - "sha256": "7b9de2f4b811cfc579fe1707c8c4e31dc2ed370b3b98b48efbf1f365160e5d3f" + "bytes": 5832, + "sha256": "f29e407aa74a19d475d2f378dea595d9f3557855515cec781ed170e077b9787d" + }, + { + "location": "public", + "filename": "udkadua-dataset-color-surface.webp", + "bytes": 274, + "sha256": "97b272955cada82e4b81ef2085976b6689ed17ed90cb8f3fa58a6d249b188347" }, { "location": "public", @@ -19,11 +25,29 @@ "bytes": 44, "sha256": "1769544fc1cb5b50827a1d4f208b12757fdf258643c51bd8a03762ff06eff173" }, + { + "location": "public", + "filename": "udkadua-dataset-surface-2011-09.webp", + "bytes": 312, + "sha256": "75354bb0d778f4f82192da9f18516eb15efe927470b778dc87b1565d812ad1fe" + }, + { + "location": "public", + "filename": "udkadua-limb-color-surface@2x.webp", + "bytes": 108840, + "sha256": "73a85654a00b1d81e85a65d46cbec5b874bb9a8008ce701add342b35809f53b9" + }, { "location": "public", "filename": "udkadua-limb-color@2x.webp", - "bytes": 102398, - "sha256": "5661423c0aefb02d970489455a744abf832b54b65ac1fd915cb5203f052e95b8" + "bytes": 90960, + "sha256": "901247abbb39eacd506c9f87769e2fe56a31ff51cddb7ca2910619ea3795f21d" + }, + { + "location": "public", + "filename": "udkadua-poles-color-surface@2x.webp", + "bytes": 13402, + "sha256": "2289de5ff5688fbbfa13da4f5c518da4cebc64ac332df01e428a9c88872f4741" }, { "location": "public", @@ -31,23 +55,41 @@ "bytes": 1340, "sha256": "bd29b6577ad0a580ca425e6b300958e2e2d84fea589b6e3f984abf3c7681644c" }, + { + "location": "public", + "filename": "udkadua-poles-surface-2011-09@2x.webp", + "bytes": 16982, + "sha256": "b1ae353f2e7c495f1367a936e8ea2d77c5866c34de3ac2c6b6793bf709882d65" + }, + { + "location": "public", + "filename": "udkadua-surface-color-surface@2x.webp", + "bytes": 18264, + "sha256": "4e5566b9d3de27e4a2db08322bd9622b512f50a43fd5f5d5315029a0892aa515" + }, { "location": "public", "filename": "udkadua-surface-color@2x.webp", "bytes": 38, "sha256": "53cb97e6def9fbb335a33444095f5b14cf57f237543bb31ea92de6cfa6564a23" }, + { + "location": "public", + "filename": "udkadua-surface-surface-2011-09@2x.webp", + "bytes": 24392, + "sha256": "57c7607162c28af66483653f9505a027215faf07dc4f55c684b2f514d31b1667" + }, { "location": "prepared", "filename": "arrival-billboard.json", - "bytes": 379, - "sha256": "95bc90d743f4dbf618eb92dc46623acb53215bce94bb16d064b10399fd507378" + "bytes": 377, + "sha256": "eb0748f27e4c8c84387e93b8893bb54951d905654250009160176ac80308863f" }, { "location": "prepared", "filename": "assets.json", - "bytes": 1785, - "sha256": "e7f83f35da5ee62bb1b4a1873a048ee39df4be3b88141a5d3692243a298c93a1" + "bytes": 2227, + "sha256": "0cf87af49bfd3f00aff1677e057cfb1323de4cec41859156023bef1230a79da1" }, { "location": "prepared", @@ -64,20 +106,26 @@ { "location": "prepared", "filename": "controls.json", - "bytes": 364, - "sha256": "fac472776d82c7f0eda43e20fe0796e8a4a992c8194792f718131f4602539ba3" + "bytes": 2150, + "sha256": "fce934df8c62b88cb99167d5c5a7ebb2e11e4b298969712fdf00b17e80f970ea" }, { "location": "prepared", "filename": "datasets.json", - "bytes": 956, - "sha256": "15a64f4299250cea67ac08b965a89d64244898209939a1c61a9a58667107e947" + "bytes": 4114, + "sha256": "39741669bb1ea71342bd60404529312c2bab0e61ccfa15d8b8c70f43d324e3b2" }, { "location": "prepared", "filename": "minimaps.json", - "bytes": 101, - "sha256": "087c2291c1fc076eb71a6d0d7ff5d5b77074bd9ac9b4e63929c77733a8528617" + "bytes": 313, + "sha256": "d7503536a4da149cab88e4098834a9e884ebd95821fc5488b14539230b9af8dc" + }, + { + "location": "prepared", + "filename": "minimaps/color-surface.webp", + "bytes": 1434, + "sha256": "1c178f919e7fd3b4f4eff15432ab757e67518866625e86bf74ab3e7c5952cf3e" }, { "location": "prepared", @@ -85,23 +133,29 @@ "bytes": 444, "sha256": "285d75f47d62c4584b9c810806ab8da9bcb177a2879cda8e9b0e57aceeecd7d1" }, + { + "location": "prepared", + "filename": "minimaps/surface-2011-09.webp", + "bytes": 2806, + "sha256": "cdf97a3e657a2aff14fb635b3bf3975f036c7f928fd907eeb7cde84dffa52154" + }, { "location": "prepared", "filename": "runtime.json", - "bytes": 249453, - "sha256": "6be3008e7dd96dcd5ed610c11ab5d6a90a3549d66a426c0fad79d99e54d543c2" + "bytes": 253559, + "sha256": "7bca51d190bd0058807ef9c472d75b4dc6259d3de2f656d497680e4fc9de9eea" }, { "location": "prepared", "filename": "scene.json", - "bytes": 392890, - "sha256": "3e1b6506af1d503a61b2a594fd066fff99eb54341d606847c857605f9e3f1339" + "bytes": 392819, + "sha256": "e56639bb8aa21356e6d4fc0add4573e9fb1b51963b440678cc5cc1bf776ffe45" }, { "location": "prepared", "filename": "sky.json", - "bytes": 1205, - "sha256": "323bcc71693cb4b08a5ce861507dda1c6613af8f129a7a22ab43f86e20f563c2" + "bytes": 1199, + "sha256": "df7c9352e66c0a3194ed2f892e6f59be499304e547a1b66472e13102491c5cc5" }, { "location": "prepared", @@ -112,14 +166,14 @@ { "location": "prepared", "filename": "text.json", - "bytes": 1665, - "sha256": "6c8ef28129fd08462488bc49918dc28b9f57ff66cf1c2a2330c98a13baa061b0" + "bytes": 2083, + "sha256": "503020c19fff1f91e5a4988f0148fbf73ce785de63a950120bf9cf24dfdb8eb1" }, { "location": "prepared", "filename": "world-navigation.json", - "bytes": 1170, - "sha256": "097e364daafdccabe83c348d26069260cbca6a70be32e8736eacecfd8835cb34" + "bytes": 1117, + "sha256": "e357e657ee9345637cec225f3d7956f326021211128aee5b11253bb65752a7ac" } ] } diff --git a/src/objects/udkadua/investigations.json b/src/objects/udkadua/investigations.json index c39cdeb50e5..067f064ea6d 100644 --- a/src/objects/udkadua/investigations.json +++ b/src/objects/udkadua/investigations.json @@ -36,10 +36,30 @@ "id": "limb-darkening", "subject": "Limb darkening", "status": "included", - "finding": "The disc is dimmed toward the limb by the quadratic law Claret & Bloemen (2011), A&A 529, A75 compute from ATLAS model atmospheres for the Johnson V band at 4,667 K and log g 2.76 (u1 0.730, u2 0.071): a model, because no fit of this star's limb is used. Gravity: log g 2.76 from 2025A&A...703A.128B, the median of its 2 spectra; the 17 published values span log g 2.3 to 3.22, across which the limb law changes by at most 0.2% of the centre brightness.", + "finding": "The disc is dimmed toward the limb by the power law I(mu) = mu^0.231 that Martinez et al. (2021), ApJ 916, 60 fit to the star's resolved disc (CHARA/MIRC H band, 1.5-1.7 um; not a visible band).", "evidence": [ - "https://vizier.cds.unistra.fr/viz-bin/asu-tsv", - "https://ui.adsabs.harvard.edu/abs/2025A%26A...703A.128B" + "https://doi.org/10.3847/1538-4357/ac06a5" + ] + }, + { + "id": "surface-udkadua-mirc-2011-09", + "subject": "CHARA/MIRC visibilities of 2 to 24 September 2011 as a surface map", + "status": "included", + "finding": "The authors' calibrated files of 6 nights (2 September 2011: 360 squared visibilities; 6 September 2011: 392 squared visibilities; 10 September 2011: 360 squared visibilities; 14 September 2011: 864 squared visibilities; 19 September 2011: 808 squared visibilities; 24 September 2011: 200 squared visibilities) are public with their code. ROTIR at the pinned commit (HEALPix level 3, sobel2 at weight 10, 1000 iterations) fits one turning surface to all of them. The fit leaves a reduced chi-squared of 1.72 on the squared visibilities and 2.30 on the closure phases (limit 3), where a spotless star leaves 3.13 and 28.29; its spots are 2.14 times those the same fit draws on a spotless star of 2.77 mas (limit 2); and two independent halves of the data give the same spots (correlation 0.83, limit 0.5). Spotless twins of 2.69 mas and 2.80 mas do not fit on the 2.742 mas sphere (closure-phase chi-squared 7.42 and 9.40) and so do not decide the spot check. The published maps themselves are not redistributed.", + "evidence": [ + "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data", + "https://doi.org/10.3847/1538-4357/ac06a5", + "https://doi.org/10.3847/1538-4357/abb670" + ] + }, + { + "id": "rotation-axis", + "subject": "Rotation axis, period and prime meridian", + "status": "included", + "finding": "The page draws the axis the surface map is fitted with: inclination 85.63° and pole position angle 26.09° east of north, with a period of 54.2 d (Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366)). No prime meridian is defined for the star: longitude 0 is the meridian that faced the Earth on 14 September 2011, and the star is not turned.", + "evidence": [ + "https://doi.org/10.3847/1538-4357/ac06a5", + "https://doi.org/10.3847/1538-4357/abb670" ] } ] diff --git a/src/objects/udkadua/object.json b/src/objects/udkadua/object.json index be1024e77c7..9f7bcdb1b0d 100644 --- a/src/objects/udkadua/object.json +++ b/src/objects/udkadua/object.json @@ -31,6 +31,16 @@ "id": "color", "source": "content", "material": "emission" + }, + { + "id": "surface-2011-09", + "source": "content", + "material": "emission" + }, + { + "id": "color-surface", + "source": "content", + "material": "emission" } ] } @@ -116,25 +126,25 @@ "referenceFrame": "sun-icrf", "epochJdTt": 2461286.5, "originM": [ - 558653415282633900, - -54848033204116824, - 590599190042590700 + 558653415282634050, + -54848033204116850, + 590599190042590800 ], "presentationToReference": [ - 0.6856115018110364, - 0.7213796181710326, - -0.09771548020569995, - -0.06731265088751344, - -0.07083305822952247, - -0.9952143914214409, - 0.7248488639812517, - -0.6889079215318026, - 0.000005985035297034387 + 0.6375431445751888, + 0.6553748663670597, + -0.40499694238295014, + -0.02892214644756789, + -0.5049562608837745, + -0.8626602367323667, + 0.769871579063707, + -0.5616965009047467, + 0.30297655457364775 ], "orbitUpReference": [ - -0.7213796181710326, - 0.07083305822952247, - 0.6889079215318026 + -0.6553748663670597, + 0.5049562608837745, + 0.5616965009047467 ], "metersPerUnit": 16948149.677419353, "bodyRadiusM": 5253926400 diff --git a/src/objects/udkadua/source/content/object.json b/src/objects/udkadua/source/content/object.json index 7b33be0935b..9cf85f4bb43 100644 --- a/src/objects/udkadua/source/content/object.json +++ b/src/objects/udkadua/source/content/object.json @@ -36,12 +36,88 @@ }, "datasets": { "titleKey": "datasets", - "defaultDataset": "color", + "defaultDataset": "color-surface", "controls": [ + { + "id": "color-surface", + "label": "Color + brightness", + "qualification": "The star's color, darker where CHARA/MIRC saw its surface dimmer in the infrared · 2 to 24 September 2011, mapped in this project", + "thumbnail": "udkadua-dataset-color-surface.webp", + "surface": "udkadua-surface-color-surface@2x.webp", + "poles": "udkadua-poles-color-surface@2x.webp", + "source": { + "id": "udkadua-interferometry-map-surface-2011-09", + "path": "../manifest.json", + "url": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + }, + "falseColor": false, + "notes": "λ Andromedae in its own color, darker where interferometry of 2 to 24 September 2011 shows its surface darker. The interferometer resolved the star's disc, so the places of the dark regions, in longitude and latitude, are measured, down to about 21° of the surface. The map is of infrared light, where the darkest region gives 35% less light than the brightest; it is drawn as a much darker tone of the star's own hue, and the Surface map's scale has the measured values. How dark the spots are in visible light, and their color, are not measured. The star is drawn as it faced the Earth on 14 September 2011, at the tilt and pole direction Martinez et al. (2021), ApJ 916, 60 measured. The 34° of longitude that faced the Earth on none of the nights are drawn at the mean. Spots come and go within weeks or months: this is the star then. The darkening toward the edge is the Color dataset's limb law, drawn 1.5 times as strong. A reduction made in this project with the authors' code and calibrated nights, not their published map." + }, + { + "id": "surface-2011-09", + "label": "Surface map", + "qualification": "Mapped in this project · CHARA/MIRC, september 2011", + "thumbnail": "udkadua-dataset-surface-2011-09.webp", + "surface": "udkadua-surface-surface-2011-09@2x.webp", + "poles": "udkadua-poles-surface-2011-09@2x.webp", + "source": { + "id": "udkadua-interferometry-map-surface-2011-09", + "path": "../manifest.json", + "url": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + }, + "falseColor": true, + "legend": { + "kind": "scale", + "title": "Surface brightness, infrared", + "labels": [ + "70%", + "100%", + "130%" + ], + "recipe": { + "palette": [ + [ + 26, + 26, + 26 + ], + [ + 92, + 92, + 92 + ], + [ + 156, + 156, + 156 + ], + [ + 214, + 214, + 214 + ], + [ + 255, + 255, + 255 + ] + ], + "labels": [ + "70%", + "100%", + "130%" + ] + }, + "meta": "%", + "sourceUrl": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + }, + "notes": "Where λ Andromedae's surface was darker and brighter in infrared light on 6 nights, 2 to 24 September 2011, worked out in this project from what the CHARA/MIRC interferometer measured. The star turned by 146° between the first night and the last, so one surface is fitted to all of them, with the size, tilt, pole direction and period Martinez et al. (2021), ApJ 916, 60 measured. Both the longitudes and the latitudes of the dark regions are fixed by the data, down to about 21° of the surface; nothing finer is. The 34° of longitude that faced the Earth on none of the nights hold the mean, inside the black line. The colors are a scale of brightness in one infrared band, not the star's color or temperature. A reduction made in this project with the authors' code and calibrated nights, not their published map.", + "legendNote": "Darker: dimmer than the mean of the surface seen; white: brighter. Black line: the edge of what never faced the Earth on these nights." + }, { "id": "color", "label": "Color", - "qualification": "Color from its measured spectrum. The disc itself is unresolved; darkening toward the edge from a model atmosphere.", + "qualification": "Color from its measured spectrum. The disc itself is unresolved; darkening toward the edge from a published law.", "thumbnail": "udkadua-dataset-color.webp", "surface": "udkadua-surface-color@2x.webp", "poles": "udkadua-poles-color@2x.webp", @@ -51,7 +127,7 @@ "url": "https://cdsarc.cds.unistra.fr/ftp/III/202/catalog.dat" }, "falseColor": false, - "notes": "The color of the star's measured spectrum (Kharitonov, Tereshchenko & Knyazeva (1988), Spectrophotometric Catalogue of Stars (Alma-Ata), record 1125). The spin axis's direction on the sky is a display convention. The darkening toward the edge is the quadratic law Claret & Bloemen (2011), A&A 529, A75 compute from ATLAS model atmospheres for the Johnson V band at 4,667 K and log g 2.76 (u1 0.730, u2 0.071): a model, because no fit of this star's limb is used." + "notes": "The color of the star's measured spectrum (Kharitonov, Tereshchenko & Knyazeva (1988), Spectrophotometric Catalogue of Stars (Alma-Ata), record 1125). The spin axis's direction on the sky is a display convention. The darkening toward the edge is the power law I(mu) = mu^0.231 that Martinez et al. (2021), ApJ 916, 60 fit to the star's resolved disc (CHARA/MIRC H band, 1.5-1.7 um; not a visible band)." } ] }, diff --git a/src/objects/udkadua/source/manifest.json b/src/objects/udkadua/source/manifest.json index 7b3d0e7112f..9f92a3f25ff 100644 --- a/src/objects/udkadua/source/manifest.json +++ b/src/objects/udkadua/source/manifest.json @@ -189,29 +189,20 @@ } }, { - "id": "udkadua-claret-2011-limb-darkening", - "path": "photometry/claret-2011-v-quadratic.tsv", - "origin": "https://vizier.cds.unistra.fr/viz-bin/asu-tsv", - "credit": "Claret & Bloemen (2011), A&A 529, A75 (VizieR J/A+A/529/A75)", - "license": "VizieR catalogue data, public with citation of the paper and CDS", - "licenseEvidence": [ - "https://cds.unistra.fr/vizier-org/licences_vizier.html" - ], - "acquisition": "VizieR request in source/preparation/acquisition.json: the quadratic Johnson V coefficients of the grid nodes at 4500..4750 K, log g 2.5..3, solar metallicity.", - "redistribution": "Catalogue rows retained unchanged with their citation.", + "id": "udkadua-martinez-2021-limb-darkening", + "path": "photometry/martinez-2021-limb-darkening.json", + "origin": "https://doi.org/10.3847/1538-4357/ac06a5", + "credit": "Martinez et al. (2021), ApJ 916, 60", + "license": "Factual numerical measurements; source attribution retained", + "acquisition": "Transcribed from the paper, each value with its quoted cell", + "redistribution": "Factual parameter transcription only; no paper figures", "consumers": [ "assets", "datasets" ], "sourceBinding": { - "kind": "catalogued", - "references": [ - { - "catalogueId": "source-udkadua-claret-2011-limb-darkening", - "role": "material", - "evidence": "Origin and product identifier recorded on this manifest entry." - } - ] + "kind": "local", + "reason": "Published limb-darkening law transcribed with its cells; repinned when edited." } } ], @@ -231,7 +222,7 @@ "udkadua-stellar-color", "udkadua-kharitonov", "udkadua-preparation-raster", - "udkadua-claret-2011-limb-darkening" + "udkadua-martinez-2021-limb-darkening" ] }, "generator": "packages/telescope-cli/src/source-authoring/context-markers.mts", @@ -239,6 +230,51 @@ "kind": "local", "reason": "The color dataset rendered as a disc; `context-markers.mts --check` recomputes it." } + }, + { + "id": "udkadua-interferometry-map-surface-2011-09", + "path": "science/interferometry/udkadua-mirc-2011-09.dat", + "origin": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data", + "productId": "Surface map of λ Andromedae from CHARA/MIRC, 2 to 24 September 2011", + "title": "Surface map of λ Andromedae from its CHARA/MIRC visibilities of 2 to 24 September 2011: infrared brightness on a longitude-latitude grid", + "sourceUrl": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data", + "credit": "CHARA Array/MIRC, September 2011, as calibrated by the authors of Parks et al. (2021) and Martinez et al. (2021) and shipped with ROTIR.jl (F. Baron); the star's size, limb, axis and period from Martinez et al. (2021), ApJ 916, 60; map made in this project with ROTIR.jl ad308759 and OITOOLS.jl f42d2bea", + "displayCredit": "CHARA/MIRC · mapped here", + "license": "Shipped in the public ROTIR.jl repository (GPL-3.0); CHARA Array data, acknowledge the array and the papers", + "licenseEvidence": [ + "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + ], + "acquisition": "Built by packages/telescope-cli/src/archives/interferometry/surface-star.mts from the season udkadua-mirc-2011-09: the 6 calibrated files at https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data, fitted with the pinned ROTIR (HEALPix level 3, sobel2 at weight 10, 1000 iterations). Restored from the source cache; not tracked. The receipt with the fit and its checks is written again by that command under output/interferometry and is not kept in git.", + "generator": "packages/telescope-cli/src/archives/interferometry/surface-star.mts", + "redistribution": "A table of fitted values made in this project; the calibrated files themselves are not redistributed.", + "consumers": [ + "rotir-surface" + ], + "sourceBinding": { + "kind": "catalogued", + "references": [ + { + "catalogueId": "rotir-demos-lambda-andromedae-mirc-2011", + "role": "material", + "evidence": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + }, + { + "catalogueId": "arxiv-2107-06366", + "role": "method", + "evidence": "https://doi.org/10.3847/1538-4357/ac06a5" + }, + { + "catalogueId": "arxiv-1508-04755", + "role": "method", + "evidence": "https://doi.org/10.3847/1538-4357/abb670" + }, + { + "catalogueId": "rotir-jl", + "role": "method", + "evidence": "https://github.com/fabienbaron/ROTIR.jl" + } + ] + } } ], "documents": [ diff --git a/src/objects/udkadua/source/photometry/claret-2011-v-quadratic.tsv b/src/objects/udkadua/source/photometry/claret-2011-v-quadratic.tsv deleted file mode 100644 index 2182c48fa4b..00000000000 --- a/src/objects/udkadua/source/photometry/claret-2011-v-quadratic.tsv +++ /dev/null @@ -1,11 +0,0 @@ -logg Teff Z xi a b Filt Met Mod -[cm/s2] K [Sun] km/s ------ ------ ---- ---- ------- ------- -- - - - 2.50 4500 0.0 2.0 1.1980 -0.3096 v L A - 3.00 4500 0.0 2.0 1.1872 -0.2992 v L A - 2.50 4750 0.0 2.0 1.1282 -0.2432 v L A - 3.00 4750 0.0 2.0 1.1164 -0.2312 v L A - 2.50 4500 0.0 2.0 0.7799 0.0325 V L A - 3.00 4500 0.0 2.0 0.7865 0.0236 V L A - 2.50 4750 0.0 2.0 0.6986 0.0967 V L A - 3.00 4750 0.0 2.0 0.7069 0.0884 V L A diff --git a/src/objects/udkadua/source/photometry/martinez-2021-limb-darkening.json b/src/objects/udkadua/source/photometry/martinez-2021-limb-darkening.json new file mode 100644 index 00000000000..10b59191b62 --- /dev/null +++ b/src/objects/udkadua/source/photometry/martinez-2021-limb-darkening.json @@ -0,0 +1,13 @@ +{ + "schema": "cssearth-published-limb-darkening@1", + "objectId": "udkadua", + "law": "power", + "source": "Martinez et al. (2021), ApJ 916, 60, Table 4, power-law limb darkening of the star from the ROTIR fit to the CHARA/MIRC nights of 2010 and 2011 (https://doi.org/10.3847/1538-4357/ac06a5)", + "band": "CHARA/MIRC H band, 1.5-1.7 um; not a visible band", + "alpha": { + "value": 0.231, + "uncertainty": 0.024, + "cell": "Limb-darkening coefficient 0.231 ±0.024" + }, + "note": "I(mu)/I(1) = mu^alpha, the Hestroffer (1997) power law the paper fits. Its uncertainty is the paper's bootstrap. Parks et al. (2021), ApJ 913, 54, give 0.229 ± 0.111 from the same nights. The visible limb is not measured." +} diff --git a/src/objects/udkadua/source/preparation/acquisition.json b/src/objects/udkadua/source/preparation/acquisition.json index d64dc8fcdda..6a74d41d4aa 100644 --- a/src/objects/udkadua/source/preparation/acquisition.json +++ b/src/objects/udkadua/source/preparation/acquisition.json @@ -34,42 +34,6 @@ "QUERY": "SELECT s.source_id, s.ref_epoch, s.ra, s.dec, s.parallax, s.parallax_error, s.pmra, s.pmdec, s.radial_velocity, s.radial_velocity_error, s.ruwe, s.phot_g_mean_mag, s.bp_rp, s.has_xp_sampled, a.mass_flame, a.mass_flame_lower, a.mass_flame_upper, a.radius_flame, a.radius_flame_lower, a.radius_flame_upper FROM gaiadr3.gaia_source AS s LEFT JOIN gaiadr3.astrophysical_parameters AS a ON a.source_id = s.source_id WHERE s.source_id = 1939115478598580352" }, "requiredPrefix": "source_id," - }, - { - "kind": "request-download", - "groups": [ - "restore", - "refresh" - ], - "path": "photometry/claret-2011-v-quadratic.tsv", - "url": "https://vizier.cds.unistra.fr/viz-bin/asu-tsv", - "form": { - "-source": "J/A+A/529/A75/table-af", - "-out.max": "500", - "Teff": "4500..4750", - "logg": "2.5..3", - "Z": "=0", - "xi": "=2", - "Filt": "V", - "Met": "L", - "Mod": "A", - "-out": "logg,Teff,Z,xi,a,b,Filt,Met,Mod" - }, - "requiredText": [ - "logg\tTeff" - ], - "replacements": [ - { - "pattern": "^#.*\\n", - "flags": "gm", - "replacement": "" - }, - { - "pattern": "^\\s*\\n", - "flags": "gm", - "replacement": "" - } - ] } ] } diff --git a/src/objects/udkadua/source/preparation/raster.json b/src/objects/udkadua/source/preparation/raster.json index 1085a5c9cc4..64e50de3a11 100644 --- a/src/objects/udkadua/source/preparation/raster.json +++ b/src/objects/udkadua/source/preparation/raster.json @@ -26,27 +26,89 @@ "falseColor": false, "science": { "kind": "stellar-photometric-color", - "qualification": "Photosphere color from the star's measured spectrum (Kharitonov, Tereshchenko & Knyazeva (1988), Spectrophotometric Catalogue of Stars (Alma-Ata), record 1125). The disc is unresolved: no map or absolute brightness is implied. Self-luminous, so no lighting. The disc is dimmed toward the limb by the quadratic law Claret & Bloemen (2011), A&A 529, A75 compute from ATLAS model atmospheres for the Johnson V band at 4,667 K and log g 2.76 (u1 0.730, u2 0.071): a model, because no fit of this star's limb is used.", + "qualification": "Photosphere color from the star's measured spectrum (Kharitonov, Tereshchenko & Knyazeva (1988), Spectrophotometric Catalogue of Stars (Alma-Ata), record 1125). The disc is unresolved: no map or absolute brightness is implied. Self-luminous, so no lighting. The disc is dimmed toward the limb by the power law I(mu) = mu^0.231 that Martinez et al. (2021), ApJ 916, 60 fit to the star's resolved disc (CHARA/MIRC H band, 1.5-1.7 um; not a visible band).", "limbDarkening": { - "law": "quadratic", - "path": "photometry/claret-2011-v-quadratic.tsv", - "grid": { - "teffK": 4667, - "logg": 2.76, - "models": { - "Filt": "V", - "Met": "L", - "Mod": "A" - } - }, - "columns": { - "teff": "Teff", - "logg": "logg", - "u1": "a", - "u2": "b" - } + "law": "power", + "published": true, + "path": "photometry/martinez-2021-limb-darkening.json" } } + }, + { + "id": "surface-2011-09", + "output": "udkadua-surface-{id}{suffix}.webp", + "thumbnail": "udkadua-dataset-{id}.webp", + "source": "science/interferometry/udkadua-mirc-2011-09.dat", + "falseColor": true, + "science": { + "kind": "terrestrial-scientific", + "id": "surface-2011-09", + "label": "September 2011", + "format": "tecplot-lonlat-map", + "path": "science/interferometry/udkadua-mirc-2011-09.dat", + "variable": "Brightness [%]", + "outlineZeroOf": "Facing", + "consumer": "rotir-surface", + "sampling": "bilinear", + "displaySampling": "bilinear", + "outputLongitudeOrigin": 0, + "units": "%", + "minimum": 70, + "maximum": 130, + "colors": [ + "#1a1a1a", + "#5c5c5c", + "#9c9c9c", + "#d6d6d6", + "#ffffff" + ], + "labels": [ + "70%", + "100%", + "130%" + ], + "description": "Brightness of λ Andromedae's surface in the infrared H band (1.49-1.73 um, eight channels), fitted with ROTIR to the 2,984 squared visibilities and 3,704 closure phases CHARA/MIRC measured on 6 nights, 2 to 24 September 2011: one surface on a sphere 2.742 mas across that turns once in 54.2 d, tilted 85.63° from the line of sight with its pole 26.09° east of north (Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366)). The fit leaves a reduced chi-squared of 1.72 on the squared visibilities and 2.30 on the closure phases (limit 3), where a spotless star leaves 3.13 and 28.29; its spots are 2.14 times those the same fit draws on a spotless star of 2.77 mas (limit 2); and two independent halves of the data give the same spots (correlation 0.83, limit 0.5). Percent of the mean of the surface seen. Longitude 0 is the meridian that faced the Earth on 14 September 2011. Nothing smaller than about 21° of the surface is resolved. The 34° of longitude that faced the Earth on none of the nights hold the mean, inside the black line. The same nights were imaged by their authors (Martinez et al. (2021), ApJ 916, 60; Parks et al. (2021), ApJ 913, 54); this is a reduction made in this project with their code, not their published map.", + "title": "CHARA/MIRC · surface map made here · September 2011", + "sourceUrl": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + } + }, + { + "id": "color-surface", + "output": "udkadua-surface-{id}{suffix}.webp", + "thumbnail": "udkadua-dataset-{id}.webp", + "source": "science/interferometry/udkadua-mirc-2011-09.dat", + "falseColor": false, + "science": { + "kind": "terrestrial-scientific", + "id": "color-surface", + "label": "Color + brightness", + "format": "tecplot-lonlat-map", + "path": "science/interferometry/udkadua-mirc-2011-09.dat", + "variable": "Brightness [%]", + "consumer": "rotir-surface", + "sampling": "bilinear", + "displaySampling": "bilinear", + "outputLongitudeOrigin": 0, + "units": "%", + "minimum": 70, + "maximum": 130, + "colors": [ + "#935901", + "#ae7a30", + "#c99b5e", + "#e4bc8d", + "#ffddbb" + ], + "labels": [ + "70%", + "130%" + ], + "limbOf": "color", + "limbStrength": 1.5, + "description": "The star's color (#ffddbb, its Color dataset) over the surface map this project made from CHARA/MIRC interferometry of 2 to 24 September 2011: the map's scale, 70% to 130% of the mean surface, runs from a darker, richer tone of the same hue to the color itself. The map is of infrared light (infrared H band (1.49-1.73 um, eight channels)), where the darkest region gives 35% less light than the brightest; how much darker the spots are in visible light is not measured, and no color change is drawn. Longitude 0 is the meridian that faced the Earth on 14 September 2011. The 34° of longitude that never faced the Earth on these nights hold the mean. A reduction made in this project, not a published map.", + "title": "CHARA/MIRC · the star in its color over its surface map · 2 to 24 September 2011", + "sourceUrl": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + } } ], "emission": { diff --git a/src/objects/udkadua/source/preparation/rotation.json b/src/objects/udkadua/source/preparation/rotation.json index 3dc8dd59f9e..fddded0dba4 100644 --- a/src/objects/udkadua/source/preparation/rotation.json +++ b/src/objects/udkadua/source/preparation/rotation.json @@ -1,10 +1,10 @@ { "schema": "cssearth-display-orientation@1", - "rightAscensionDegrees": 174.39204130799976, - "declinationDegrees": 43.543723447297175, - "displayMeridianDegrees": -90, + "rightAscensionDegrees": 142.38630107822388, + "declinationDegrees": 34.17320346331079, + "displayMeridianDegrees": 291.481706, "phase": "arbitrary-display-phase", - "source": "No measured rotation axis or period is used. The display axis is celestial north at the catalogue position, placed in the plane of the sky; computed by skyPlaneOrientation in @cssearth/astronomy.", - "coordinateSystem": "ICRF/J2000. +Z is the display axis: the sky-north direction at the star, in the plane of the sky. +X is the display meridian, set so that grid longitude 0 faces the Sun and Earth at the scene epoch; east longitude. No spin is propagated.", - "qualification": "Display convention, not a measurement. The rotation axis, spin sense, period and prime meridian of Udkadua are unmeasured; the axis shown is where celestial north lies on the sky." + "source": "Measured axis: spin inclination 85.63 degrees from the line of sight (Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): inclination 85.63 +/- 2.32 degrees), pole position angle 26.09 degrees east of north (Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): position angle 26.09 +/- 0.82 degrees) and rotation period 54.2 d (Martinez et al. (2021), ApJ 916, 60, Table 4 (https://arxiv.org/abs/2107.06366): rotation period 54.2 d, held fixed in the fit): the values the star's surface map is fitted with, the pole tilted toward us; computed by inclinedPoleOrientation in packages/bake/src/objects/stellar/gravity-darkening.ts. Longitude 0 faces the Earth, where the map puts the meridian that faced it on 2011-09-14.", + "coordinateSystem": "ICRF/J2000. +Z is the measured rotation pole, the one tilted toward the Earth. +X is the display meridian, set so that grid longitude 0 faces the Sun and Earth at the scene epoch; east longitude. No spin is propagated.", + "qualification": "The tilt and the direction of the pole on the sky are measured by interferometry. The star is drawn as it faced the Earth on one night; the rotation is not animated." } diff --git a/src/objects/udkadua/source/presentation/context.png b/src/objects/udkadua/source/presentation/context.png index ba6c60187ea..afb37b1c790 100644 Binary files a/src/objects/udkadua/source/presentation/context.png and b/src/objects/udkadua/source/presentation/context.png differ diff --git a/src/objects/udkadua/text.json b/src/objects/udkadua/text.json index e50ce07875a..c20a87cd29f 100644 --- a/src/objects/udkadua/text.json +++ b/src/objects/udkadua/text.json @@ -46,6 +46,16 @@ "title": "Photosphere color", "detail": "From its spectrum", "summary": "The color of the star's light." + }, + "surface-2011-09": { + "title": "Surface map, September 2011", + "detail": "September 2011, mapped here", + "summary": "Darker and brighter regions of the star, worked out in this project from interferometry of several nights." + }, + "color-surface": { + "title": "Color + brightness, Sep 2011", + "detail": "CHARA/MIRC, September 2011", + "summary": "The star in its own color, darker where interferometry resolved darker regions on its surface." } } } diff --git a/src/sources/arxiv-1508-04755.json b/src/sources/arxiv-1508-04755.json new file mode 100644 index 00000000000..30364fcb1e7 --- /dev/null +++ b/src/sources/arxiv-1508-04755.json @@ -0,0 +1,54 @@ +{ + "id": "arxiv-1508-04755", + "kind": "publication", + "identityLevel": "work", + "title": "Parks et al. (2021): Interferometric Imaging of λ Andromedae: Evidence of Starspots and Rotation", + "identifiers": [ + { + "type": "arXiv", + "value": "1508.04755" + }, + { + "type": "DOI", + "value": "10.3847/1538-4357/abb670" + } + ], + "links": [ + { + "role": "archive", + "url": "https://arxiv.org/abs/1508.04755", + "label": "arXiv preprint" + }, + { + "role": "landing", + "url": "https://doi.org/10.3847/1538-4357/abb670", + "label": "Journal version" + } + ], + "evidence": [ + { + "url": "https://arxiv.org/abs/1508.04755", + "checkedOn": "2026-10-08", + "locator": "arXiv listing and Crossref: title, authors, DOI. Table 1 lists the six nights of September 2011 and their numbers of points; section III.2 describes the SQUEEZE images." + } + ], + "relations": [], + "statements": [ + { + "kind": "credit", + "text": "Parks et al. (2021)", + "scope": "citation", + "evidence": "https://arxiv.org/abs/1508.04755" + } + ], + "creators": [ + "J. R. Parks", + "R. J. White", + "F. Baron", + "J. D. Monnier", + "B. Kloppenborg", + "G. W. Henry", + "et al." + ], + "publicationDate": "2021" +} diff --git a/src/sources/arxiv-2107-06366.json b/src/sources/arxiv-2107-06366.json new file mode 100644 index 00000000000..b12ad159b34 --- /dev/null +++ b/src/sources/arxiv-2107-06366.json @@ -0,0 +1,52 @@ +{ + "id": "arxiv-2107-06366", + "kind": "publication", + "identityLevel": "work", + "title": "Martinez et al. (2021): Dynamical Surface Imaging of λ Andromedae", + "identifiers": [ + { + "type": "arXiv", + "value": "2107.06366" + }, + { + "type": "DOI", + "value": "10.3847/1538-4357/ac06a5" + } + ], + "links": [ + { + "role": "archive", + "url": "https://arxiv.org/abs/2107.06366", + "label": "arXiv preprint" + }, + { + "role": "landing", + "url": "https://doi.org/10.3847/1538-4357/ac06a5", + "label": "Journal version" + } + ], + "evidence": [ + { + "url": "https://arxiv.org/abs/2107.06366", + "checkedOn": "2026-10-08", + "locator": "arXiv listing and Crossref: title, authors, DOI. Table 4 gives the star's angular radius, limb-darkening coefficient, inclination, position angle and rotation period; section V describes the ROTIR reconstruction of the 2010 and 2011 nights." + } + ], + "relations": [], + "statements": [ + { + "kind": "credit", + "text": "Martinez et al. (2021)", + "scope": "citation", + "evidence": "https://arxiv.org/abs/2107.06366" + } + ], + "creators": [ + "A. O. Martinez", + "F. R. Baron", + "J. D. Monnier", + "R. M. Roettenbacher", + "J. R. Parks" + ], + "publicationDate": "2021" +} diff --git a/src/sources/rotir-demos-lambda-andromedae-mirc-2011.json b/src/sources/rotir-demos-lambda-andromedae-mirc-2011.json new file mode 100644 index 00000000000..ef4927f92ee --- /dev/null +++ b/src/sources/rotir-demos-lambda-andromedae-mirc-2011.json @@ -0,0 +1,31 @@ +{ + "id": "rotir-demos-lambda-andromedae-mirc-2011", + "kind": "data-product", + "identityLevel": "work", + "title": "λ Andromedae, CHARA/MIRC calibrated visibilities of six nights of September 2011, as shipped with ROTIR.jl", + "identifiers": [], + "links": [ + { + "role": "archive", + "url": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data", + "label": "Files at the pinned commit" + } + ], + "evidence": [ + { + "url": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data", + "checkedOn": "2026-10-08", + "locator": "Six files 2011Sep02 to 2011Sep24 .lam_And_prepped.oifits under demos/data at the pinned commit, read by demos/lam_and_reconstruction.jl there; their numbers of squared visibilities match Parks et al. (2021), Table 1." + } + ], + "relations": [], + "statements": [ + { + "kind": "credit", + "text": "CHARA Array/MIRC, September 2011, as calibrated by the authors of Parks et al. (2021) and Martinez et al. (2021) and shipped with ROTIR.jl (F. Baron)", + "scope": "citation", + "evidence": "https://github.com/fabienbaron/ROTIR.jl/tree/ad308759b741b861b6c19fedd001c323a3a64479/demos/data" + } + ], + "publisher": "F. Baron, ROTIR.jl (GitHub)" +} diff --git a/src/sources/rotir-jl.json b/src/sources/rotir-jl.json new file mode 100644 index 00000000000..869630fbcea --- /dev/null +++ b/src/sources/rotir-jl.json @@ -0,0 +1,34 @@ +{ + "id": "rotir-jl", + "kind": "software", + "identityLevel": "work", + "title": "ROTIR.jl: interferometric imaging and light curve inversion on rotating spheroids (F. Baron, A. O. Martinez)", + "identifiers": [], + "links": [ + { + "role": "archive", + "url": "https://github.com/fabienbaron/ROTIR.jl", + "label": "Source repository" + } + ], + "evidence": [ + { + "url": "https://github.com/fabienbaron/ROTIR.jl", + "checkedOn": "2026-10-08", + "locator": "Repository page: name, description and GPL-3.0 licence. The commit run is pinned in packages/telescope-cli/src/archives/interferometry/toolchains.json." + } + ], + "relations": [], + "statements": [ + { + "kind": "credit", + "text": "ROTIR.jl (F. Baron and A. O. Martinez), GPL-3.0", + "scope": "citation", + "evidence": "https://github.com/fabienbaron/ROTIR.jl" + } + ], + "creators": [ + "F. Baron", + "A. O. Martinez" + ] +}