diff --git a/packages/bake/authoring/ngc-3132/grid-fit.mts b/packages/bake/authoring/ngc-3132/grid-fit.mts index 638dd4f9b33..5830fe02da5 100644 --- a/packages/bake/authoring/ngc-3132/grid-fit.mts +++ b/packages/bake/authoring/ngc-3132/grid-fit.mts @@ -59,10 +59,10 @@ const file = await readFile(resolve(sourceDirectory, 'source.jpg')), tags = file const tag = (name: string) => { const found = new RegExp(`([\\s\\S]*?)`, 'u').exec(tags) ?? new RegExp(`avm:${name}="([^"]*)"`, 'u').exec(tags); if (!found) throw new Error(`source.jpg has no avm:${name}.`); return found[1]!.replace(/<[^>]+>/gu, ' ').trim().split(/\s+/u).map(Number); }; const pixelArcsec = Math.abs(tag('Spatial.Scale')[0]!) * 3600, rotation = tag('Spatial.Rotation')[0]! * rad; const full = await sharp(file).removeAlpha().raw().toBuffer({ resolveWithObject: true }), fullWidth = full.info.width, fullHeight = full.info.height; -// Where the tags alone put the star: the recipe's target, from the tags' reference place and pixel (counted from the bottom). +// Where the tags alone put the star: the recipe's target, from the tags' reference place and pixel (counted from the bottom left, the first pixel's middle at 1, as FITS counts). const target = (JSON.parse(await readFile(resolve(sourceDirectory, 'recipe.json'), 'utf8')) as { target: { centerRaDeg: number; centerDecDeg: number } }).target, reference = tag('Spatial.ReferenceValue'), referencePixel = tag('Spatial.ReferencePixel'), degrees = Math.abs(tag('Spatial.Scale')[0]!); const eastPixels = (target.centerRaDeg - reference[0]!) * Math.cos(target.centerDecDeg * rad) / degrees, northPixels = (target.centerDecDeg - reference[1]!) / degrees; -const tagged = [referencePixel[0]! - eastPixels * Math.cos(rotation) - northPixels * Math.sin(rotation), fullHeight - (referencePixel[1]! - eastPixels * Math.sin(rotation) + northPixels * Math.cos(rotation))] as const, within = Math.round(STAR_WITHIN_ARCSEC / pixelArcsec); +const tagged = [referencePixel[0]! - 1 - eastPixels * Math.cos(rotation) - northPixels * Math.sin(rotation), fullHeight - (referencePixel[1]! - eastPixels * Math.sin(rotation) + northPixels * Math.cos(rotation))] as const, within = Math.round(STAR_WITHIN_ARCSEC / pixelArcsec); // The star: the largest patch of saturated pixels that starts within that reach of the place. const saturated = (p: number) => full.data[3 * p]! >= 250 && full.data[3 * p + 1]! >= 250 && full.data[3 * p + 2]! >= 250, seen = new Uint8Array(fullWidth * fullHeight); let star = { pixels: 0, x: 0, y: 0, from: 0 }; for (let y = Math.max(0, Math.round(tagged[1] - within)); y <= Math.min(fullHeight - 1, tagged[1] + within); y++) for (let x = Math.max(0, Math.round(tagged[0] - within)); x <= Math.min(fullWidth - 1, tagged[0] + within); x++) { const start = y * fullWidth + x; if (seen[start] || !saturated(start) || Math.hypot(x - tagged[0], y - tagged[1]) > within) continue; let pixels = 0, sumX = 0, sumY = 0; const stack = [start]; seen[start] = 1; diff --git a/packages/telescope-cli/AGENTS.md b/packages/telescope-cli/AGENTS.md index 8fefa81a04d..c752692a5b6 100644 --- a/packages/telescope-cli/AGENTS.md +++ b/packages/telescope-cli/AGENTS.md @@ -39,6 +39,11 @@ own through `workspace-commands/new-object.mts`, `new-object-cli.mts` is the sta The generated solar geometry (`src/platform/solar-geometry.mts`) stays generated: the entries load it with `solar-epoch.mts` and pass the epoch down, so no module here imports it. +A published picture as one more dataset of a page is `src/new-object/pictures/`: `--from-esa` and a spec's `pictures`. `esa-image.mts` reads +ESA's picture pages and the sky tags in a JPEG, `registration.mts` owns the arithmetic from those tags and one pixel to a recipe's +observation, plane and rim, `picture-bank.mts` writes one entry's records from the bank it is like, and `pictures.mts` drafts, writes +and bakes. It never writes a sentence a reader sees and never picks a value for a color: those are the spec's, written by a person. + The star survey behind `telescope stars GALAXY` is `src/stars/`. It and the generator's `--from-table` route (`src/new-object/archives/tables/table-stars.mts`) read VizieR's table metadata and SIMBAD through the same two modules, `vizier-tables.mts` and `simbad-tap.mts` beside it; a star class is a branch of SIMBAD's own type tree, never a diff --git a/packages/telescope-cli/README.md b/packages/telescope-cli/README.md index f7aff20268c..6df2ac332b7 100644 --- a/packages/telescope-cli/README.md +++ b/packages/telescope-cli/README.md @@ -38,6 +38,8 @@ In a terminal, `explore` shows the observations, unknown metadata, unsupported r `telescope simulations OBJECT` lists the Zenodo dataset records that name the object and speak of a simulation or a model, each with its license, size and files. It downloads nothing, and a listed record is a lead. To show one, read its paper and write an entry for `node packages/telescope-cli/src/new-object/new-object-cli.mts --simulation entries.json` ([simulation-dataset.mts](src/new-object/simulation/simulation-dataset.mts) documents the entry). That command checks the record's license, that it names the object and that it lists the file, and writes the dataset with its labels. It never fetches the release whole: two range requests bring the file's first bytes (its header and coordinate variables) and the bytes of the selected grid into the body's source directory, each declared with its byte range in the source manifest. A 3.5 GB run costs about 130 kB. The [published simulations](../../.agents/skills/celestial-skill/references/scientific-faithfulness.md#published-simulations) rule gives the conditions. +`telescope new-object --from-esa PAGE_ID=PICTURE_URL... --out SPEC.json` drafts a published picture as one more dataset of a page that already shows a shaped layer bank: a picture ESA publishes for Hubble or Webb (`m57=https://esawebb.org/images/weic2320c/`). It reads the picture's page (title, credit, release date, colors) and the sky tags in its JPEG, finds the star the picture shows where the tags put the page's place, and names the bank the picture will lie on like: the one the page opens on. A picture that does not hold the page's place is refused. A person then writes the dataset's sentences and, under `geometry`, what this picture's light takes on those walls: printed values, each with its source. `telescope new-object SPEC.json` writes the new bank's records, the picture, the page's dataset and a README whose measured lines are filled in; `--bake` bakes the banks and their pages in one chain. A spec can be run again after a change, and the README a person wrote is kept. The entry's fields are in [picture-bank.mts](src/new-object/pictures/picture-bank.mts). + `telescope stars GALAXY` counts the stars SIMBAD lists inside a galaxy's catalogued outline by class (Cepheids, RR Lyrae stars, long-period variables, supergiants), names the papers they come from, and reads VizieR's tables of those papers for what a placed star needs: a period and a position of its own. It also names the single stars of no class that SIMBAD holds there with no parallax and no proper motion, the most cited first: a star one paper studied alone (M51-DS1) is often typed a plain star, and is drafted by hand from that paper. Last, it asks the papers API (OpenAlex, with arXiv when OpenAlex's daily budget is spent; the same index `telescope papers` reads) for the works whose title or abstract names the galaxy and a kind of star, those with the star in the title first: SIMBAD and VizieR take months to hold a new paper and never hold some, so a 2026 paper of Cepheids in M61 and M77 and a 2003 paper of one star in NGC 253's halo were found only there. It downloads nothing, and a star SIMBAD lists inside the outline may still be in front of the galaxy. A table it calls ready drafts a star: `telescope new-object --from-table cepheid:m81=J/ApJ/743/176/table1 --out spec.json` writes the spec of one Cepheid of that table, placed where its sight line crosses the disc the galaxy is drawn as ([table-stars.mts](src/new-object/archives/tables/table-stars.mts) documents the row choices). A table that gives every row its galaxy's centre, as a reanalysis does, places its stars at SIMBAD's position of the SIMBAD name CDS added to each row. A table that lists each star by its detector pixel (a chip, X and Y, as the Hubble Cepheid papers do) needs the exposure those pixels were measured on, which only the paper says: `--from-table "cepheid:m66=J/ApJ/529/723/appen#exposure=mast:HST/product/u35i0101r_c0m.fits,firstPixel=1"` reads that chip's header from MAST by byte range (11,520 bytes of a 10 MB file) and places the star where the header's world coordinates put the pixel. `firstPixel` is the coordinate the paper's software gives the centre of the first pixel: 1 for DAOPHOT and ALLFRAME, 0.5 for HSTphot. The position is Hubble's pointing as archived, not registered against a catalogue: on the one exposure checked, a supernova with a catalogued position fell 1.2″ from it ([image-pixel.mts](src/new-object/archives/images/image-pixel.mts)); a hand-written spec does the same with `position.archive: "mast"`. Coordinates a paper prints in a table that neither VizieR nor SIMBAD holds yet are used as printed with `position.archive: "paper"`: the paper is cited by its DOI and nothing is archived. When you know the scientific acceptance criteria, save an explicit request: diff --git a/packages/telescope-cli/src/cli.test.mts b/packages/telescope-cli/src/cli.test.mts index 11f592e2d63..0521929d4dd 100644 --- a/packages/telescope-cli/src/cli.test.mts +++ b/packages/telescope-cli/src/cli.test.mts @@ -83,6 +83,8 @@ test('new-object bakes a spec after generating it, or objects already in the tre const draft=parseCli(['new-object','--from-debcat','47 Tuc V69','CM Dra','--out','spec.json']);assert.equal(draft.command,'new-object');if(draft.command!=='new-object')return; assert.deepEqual([draft.from,draft.names],['debcat',['47 Tuc V69','CM Dra']]); assert.throws(()=>parseCli(['new-object','--from-apokasc','--out','spec.json']),/--from-apokasc NAME\.\.\. --out SPEC\.json/u); + const picture=parseCli(['new-object','--from-esa','m57=https://esawebb.org/images/weic2320c/','--out','spec.json']);assert.equal(picture.command,'new-object');if(picture.command!=='new-object')return; + assert.deepEqual([picture.from,picture.names],['esa',['m57=https://esawebb.org/images/weic2320c/']]); }); test('local import has one bounded data-only entry point',()=>{ const parsed=parseCli(['import','spec.json','--out','run','--json']);assert.equal(parsed.command,'import');if(parsed.command!=='import')return; diff --git a/packages/telescope-cli/src/help.mts b/packages/telescope-cli/src/help.mts index 8d06858ec53..555fa4d9eb3 100644 --- a/packages/telescope-cli/src/help.mts +++ b/packages/telescope-cli/src/help.mts @@ -20,7 +20,7 @@ Human entry points: telescope leads TARGET | --class CLASS [--json] [--out DIRECTORY] telescope stars GALAXY [--json] [--out DIRECTORY] telescope new-object SPEC.json [--check] [--skip-existing] [--json] - telescope new-object --from-archive HOST... | --from-debcat SYSTEM... | --from-apokasc KIC... | --from-cepheids NAME... | --from-k2 EPIC... | --from-tess TIC... | --from-gaia SOURCE_ID... | --from-hipparcos HIP... | --from-iau all|NAME... | --from-chara HD... | --from-npoi HD... | --from-sh0es HOST[/ID]... | --from-m31cepheids all|V1|ID... | --from-m33cepheids all|ID... | --from-table CLASS:GALAXY=TABLE[#ROW]... --out SPEC.json + telescope new-object --from-archive HOST... | --from-debcat SYSTEM... | --from-apokasc KIC... | --from-cepheids NAME... | --from-k2 EPIC... | --from-tess TIC... | --from-gaia SOURCE_ID... | --from-hipparcos HIP... | --from-iau all|NAME... | --from-chara HD... | --from-npoi HD... | --from-sh0es HOST[/ID]... | --from-m31cepheids all|V1|ID... | --from-m33cepheids all|ID... | --from-table CLASS:GALAXY=TABLE[#ROW]... | --from-esa PAGE_ID=PICTURE_URL... --out SPEC.json telescope ascl SOFTWARE [--json] telescope ascl --product PRODUCT.json|RUN/pick-N/result.json [--json] telescope wwt-fits EXPLORE.json --pick N --level N --x X --y Y --out DIRECTORY @@ -50,7 +50,10 @@ Only prose is left marked TODO(new-object), unless the spec carries drafted text NASA Exoplanet Archive's default parameter sets (their transiting planets, with drafted text); --from-debcat drafts both stars of an eclipsing binary from DEBCat, --from-apokasc a Kepler-field giant from APOKASC-3, --from-cepheids a Cepheid from Groenewegen (2013), --from-k2 a K2-field giant and --from-tess a TESS giant from Khan et al. (2023), --from-gaia any star from Gaia DR3 FLAME, --from-hipparcos a Hipparcos star from the model-atmosphere fit to its light (McDonald et al. 2017), --from-iau the stars the IAU has named that the universe lacks, each by the first of those routes that measures it, --from-chara a nearby star whose disc CHARA measured (Boyajian et al. 2012), --from-npoi a bright star whose disc the NPOI measured (Baines et al. 2018, 2021), --from-sh0es the Cepheids Hubble found in a SN Ia host galaxy (Hoffmann et al. 2016), --from-m31cepheids Hubble's V1 and the Cepheids Hubble measured in Andromeda (Li et al. 2021), --from-m33cepheids those it measured in Triangulum (Breuval et al. 2023), --from-table a star of another galaxy from any VizieR table that lists it with a period (cepheid:m81=J/ApJ/743/176/table1), placed in its galaxy by the table's position, by SIMBAD's, or by the table's detector pixel on the exposure the request names (#exposure=mast:HST/product/FILE.fits,firstPixel=N), each naming what the spec still needs from the paper; --check runs the bake's first steps on -what was generated; --skip-existing leaves objects already in the universe alone. The spec format is in packages/telescope-cli/src/new-object/spec.mts. +what was generated; --skip-existing leaves objects already in the universe alone. --from-esa drafts a picture ESA publishes for Hubble or Webb as one more +dataset of a page that shows a shaped layer bank (m57=https://esawebb.org/images/weic2320c/): it reads the picture's page and the sky tags in its JPEG, finds the +star the picture shows at the page's place, and leaves the sentences and what each color takes on the bank's walls to write; a spec of pictures writes each +picture's bank and its page's dataset, may be run again, and bakes with SPEC.json --bake. The spec format is in packages/telescope-cli/src/new-object/spec.mts. Papers lists up to 20 OpenAlex works that name the target, by any spelling of its catalogue names, in their title or abstract, with the instrument and any one of the --about phrases (singular or plural); --fulltext asks for the works whose indexed full text names them. It uses arXiv's Atom API when OpenAlex is unavailable or its daily budget is spent, and the diff --git a/packages/telescope-cli/src/new-object/cli.mts b/packages/telescope-cli/src/new-object/cli.mts index 14a87c76bb1..5f61dc624f4 100644 --- a/packages/telescope-cli/src/new-object/cli.mts +++ b/packages/telescope-cli/src/new-object/cli.mts @@ -22,6 +22,8 @@ export function parseNewObjectOptions(value:unknown):NewObjectOptions{ return {...string('spec'),...strings('ids'),...string('from'),...strings('names'),...string('out'),check:flag('check'),bake:flag('bake'),refresh:flag('refresh'),skipExisting:flag('skipExisting'),json:flag('json')}; } +const holdsPictures=async(spec:string)=>{const {readFile}=await import('node:fs/promises'),value:unknown=JSON.parse(await readFile(spec,'utf8'));return typeof value==='object'&&value!==null&&'pictures' in value;}; + /** The result text and exit code for one parsed `new-object` command. */ export async function newObjectCommand(options:NewObjectOptions,root:string,stderr:(text:string)=>void):Promise<{readonly text:string;readonly code:number}>{ let text:string,code:number; @@ -38,6 +40,11 @@ export async function newObjectCommand(options:NewObjectOptions,root:string,stde }else if(options.from){ const result=await writeDrafts(options.from,options.names??[],options.out!,{root,archive:liveArchive,progress:line=>stderr(`${line}\n`)}); text=options.json?`${JSON.stringify(result)}\n`:`${result.report.join('\n')}\n${result.entries} entries written to ${result.path}\n`;code=result.entries?0:3; + }else if(await holdsPictures(options.spec!)){ + // A spec of published pictures: each one a dataset on a page that shows a shaped layer bank (pictures/pictures.mts). + const {runPictures,bakePictures,formatPictures}=await import('./pictures/pictures.mts'),results=await runPictures(options.spec!,{root,archive:liveArchive,progress}),good=results.filter(result=>!result.failed); + const baked=options.bake&&good.length?await bakePictures(good,{root,progress}):true; + text=options.json?`${JSON.stringify(results)}\n`:formatPictures(results,options.spec!,baked&&options.bake&&good.length>0);code=baked&&good.length===results.length?0:1; }else{ const results=await runNewObject(options.spec!,{root,progress:line=>stderr(`${line}\n`),skipExisting:options.skipExisting,solarEpoch:await loadSolarEpoch(root)}); const good=results.filter(result=>!result.failed).map(result=>result.id),baked=(options.check||options.bake)&&good.length?await prepareObjects(good,{root,progress,...(options.bake?{}:{to:'page'})}):true; diff --git a/packages/telescope-cli/src/new-object/drafts.mts b/packages/telescope-cli/src/new-object/drafts.mts index 3bfe7de288e..6e08f7a1961 100644 --- a/packages/telescope-cli/src/new-object/drafts.mts +++ b/packages/telescope-cli/src/new-object/drafts.mts @@ -5,7 +5,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import type { Archive } from './archives/archives.mts'; -export interface Drafts { readonly stars: readonly unknown[]; readonly report: readonly string[] } +export interface Drafts { readonly stars: readonly unknown[]; readonly pictures?: readonly unknown[]; readonly report: readonly string[] } interface Context { readonly root: string; readonly progress: (line: string) => void; readonly archive: Archive } export const DRAFT_ROUTES: Readonly Promise }>> = { // Transiting planet hosts from the NASA Exoplanet Archive's default parameter sets (from-archive.mts). @@ -38,6 +38,8 @@ export const DRAFT_ROUTES: Readonly (await import('./archives/m33-cepheids.mts')).draftsFromM33Cepheids(names, archive, root) }, // A star of another galaxy from any VizieR table that lists it with a position and a period (tables/table-stars.mts); `telescope stars GALAXY` finds the tables. table: { names: 'CLASS:GALAXY=TABLE[#ROW]', draft: async (names, { archive, root }) => (await import('./archives/tables/table-stars.mts')).draftsFromTable(names, archive, root) }, + // A picture ESA publishes for Hubble or Webb, as one more dataset of a page that shows a shaped layer bank (pictures/pictures.mts). + esa: { names: 'HOST=PAGE_URL', draft: async (names, context) => (await import('./pictures/pictures.mts')).draftsFromEsa(names, context) }, }; /** Draft `names` through `route` and write the spec file at `out`. */ @@ -45,7 +47,7 @@ export async function writeDrafts(route: string, names: readonly string[], out: const source = DRAFT_ROUTES[route]; if (!source) throw new TypeError(`No draft route ${route}; the routes are ${Object.keys(DRAFT_ROUTES).map(key => `--from-${key}`).join(', ')}.`); if (!names.length) throw new TypeError(`Usage: new-object --from-${route} ${source.names}... --out spec.json`); - const { stars, report } = await source.draft(names, context), path = resolve(context.root, out); - await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify({ stars }, null, 2)}\n`); - return { path, entries: stars.length, report }; + const { stars, pictures, report } = await source.draft(names, context), path = resolve(context.root, out); + await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(pictures ? { pictures } : { stars }, null, 2)}\n`); + return { path, entries: (pictures ?? stars).length, report }; } diff --git a/packages/telescope-cli/src/new-object/new-object.test.mts b/packages/telescope-cli/src/new-object/new-object.test.mts index eceee79d670..5d5c223337b 100644 --- a/packages/telescope-cli/src/new-object/new-object.test.mts +++ b/packages/telescope-cli/src/new-object/new-object.test.mts @@ -865,7 +865,7 @@ test('APOKASC-3 and Groenewegen (2013) rows draft single stars through the one r const { physicalValues } = await import('./generate.mts'), values = physicalValues(measured, { sourceId: '1', ra: 0, dec: 0, g: 13, hasXpSampled: false }); assert.deepEqual([values.gm, values.logg], [0, 1.2]); assert.match(values.massText, /No mass is measured, so GM is 0/u); - assert.deepEqual(Object.keys(DRAFT_ROUTES), ['archive', 'debcat', 'apokasc', 'cepheids', 'k2', 'tess', 'gaia', 'hipparcos', 'iau', 'chara', 'npoi', 'sh0es', 'm31cepheids', 'm33cepheids', 'table']); + assert.deepEqual(Object.keys(DRAFT_ROUTES), ['archive', 'debcat', 'apokasc', 'cepheids', 'k2', 'tess', 'gaia', 'hipparcos', 'iau', 'chara', 'npoi', 'sh0es', 'm31cepheids', 'm33cepheids', 'table', 'esa']); await assert.rejects(writeDrafts('gcvs', ['X'], 'output/x.json', { root, progress: () => {}, archive: {} as Archive }), /No draft route gcvs; the routes are --from-archive, --from-debcat, --from-apokasc, --from-cepheids, --from-k2, --from-tess, --from-gaia/u); }); diff --git a/packages/telescope-cli/src/new-object/orbit.mts b/packages/telescope-cli/src/new-object/orbit.mts index 9bdd099ea3f..1825c6976ff 100644 --- a/packages/telescope-cli/src/new-object/orbit.mts +++ b/packages/telescope-cli/src/new-object/orbit.mts @@ -20,7 +20,7 @@ export interface HostedOrbit { } const round = (value: number, digits: number) => Number(value.toFixed(digits)); /** The archive writes reference labels as HTML: accented author names arrive as entities. */ -const NAMED_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', agrave: 'à', egrave: 'è', ntilde: 'ñ', uuml: 'ü', ouml: 'ö', auml: 'ä', ccedil: 'ç', szlig: 'ß', oslash: 'ø', aring: 'å', Aacute: 'Á', Eacute: 'É', Oslash: 'Ø', ecirc: 'ê', ocirc: 'ô', acirc: 'â', scaron: 'š', zcaron: 'ž', ccaron: 'č' }; +const NAMED_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', agrave: 'à', egrave: 'è', ntilde: 'ñ', uuml: 'ü', ouml: 'ö', auml: 'ä', ccedil: 'ç', szlig: 'ß', oslash: 'ø', aring: 'å', Aacute: 'Á', Eacute: 'É', Oslash: 'Ø', ecirc: 'ê', ocirc: 'ô', acirc: 'â', scaron: 'š', zcaron: 'ž', ccaron: 'č', mu: 'µ', deg: '°' }; export const decodeEntities = (text: string) => text.replace(/&(#x[0-9a-f]+|#\d+|[a-zA-Z]+);/gu, (whole, code: string) => code.startsWith('#x') ? String.fromCodePoint(parseInt(code.slice(2), 16)) : code.startsWith('#') ? String.fromCodePoint(Number(code.slice(1))) : NAMED_ENTITIES[code] ?? whole); const mod360 = (value: number) => ((value % 360) + 360) % 360; diff --git a/packages/telescope-cli/src/new-object/pictures/esa-image.mts b/packages/telescope-cli/src/new-object/pictures/esa-image.mts new file mode 100644 index 00000000000..c0669263930 --- /dev/null +++ b/packages/telescope-cli/src/new-object/pictures/esa-image.mts @@ -0,0 +1,64 @@ +/** A picture ESA publishes for Hubble or Webb (esahubble.org, esawebb.org): what its page says of it, and the sky tags + * its JPEG carries. The page gives the title, the credit, the release date and the colors; the file gives its own size, + * and in its embedded Astronomy Visualization Metadata the scale, the direction of north and one place on the sky. */ +import { decodeEntities } from '../orbit.mts'; +import type { Archive } from '../archives/archives.mts'; + +/** A JPEG's sky tags, for the file as downloaded: degrees a pixel, north's turn counter-clockwise from up, and the sky + * place (right ascension, declination) of one pixel, counted from the picture's bottom left. */ +export interface SkyTags { readonly scaleDeg: number; readonly rotationDeg: number; readonly reference: readonly [number, number]; readonly referencePixel: readonly [number, number] } +export interface PictureColor { readonly band: string; readonly shown: string; readonly line?: string; readonly wavelength: string; readonly instrument: string } +export interface EsaPage { readonly id: string; readonly telescope: 'Hubble' | 'Webb'; readonly page: string; readonly download: string; readonly rights: string; + readonly title: string; readonly credit: string; readonly released: string; readonly original: readonly [number, number]; readonly colors: readonly PictureColor[] } + +const SITES: Readonly> = { 'esahubble.org': 'Hubble', 'esawebb.org': 'Webb' }; +const words = (html: string) => decodeEntities(html.replace(/<[^>]*>/gu, ' ')).replace(/\s+/gu, ' ').trim(); + +/** The picture a page address names: `https://esawebb.org/images/weic2320c/`. */ +export function esaPictureAddress(address: string): Pick { + const found = /^https:\/\/(esahubble\.org|esawebb\.org)\/images\/([a-z0-9-]+)\/?$/u.exec(address); + if (!found) throw new TypeError(`${JSON.stringify(address)} is not a picture's page at esahubble.org or esawebb.org (https://esawebb.org/images//).`); + const [, site, id] = found as unknown as [string, string, string]; + return { id, telescope: SITES[site]!, page: `https://${site}/images/${id}/`, download: `https://cdn.${site}/archives/images/large/${id}.jpg`, rights: `https://${site}/copyright/` }; +} + +/** What a picture's page says of it. A field the page lacks is refused by name: the generator writes none of them itself. */ +export function parseEsaPage(address: string, html: string): EsaPage { + const picture = esaPictureAddress(address), lacks = (what: string): never => { throw new TypeError(`${picture.page}: the page has no ${what} to read.`); }; + const lines = html.replace(//gu, '').replace(/<[^>]*>/gu, '\n').split('\n').map(words).filter(Boolean); + const after = (label: string) => lines[lines.indexOf(label) + 1] ?? ''; + const title = words(/([^<]*)<\/title>/u.exec(html)?.[1] ?? '').split(' | ')[0]! || lacks('title'); + const credit = words(/<div class="credit">([\s\S]*?)<\/div>/u.exec(html)?.[1] ?? '').replace(/\.$/u, '') || lacks('credit'); + const size = /^(\d+) x (\d+) px$/u.exec(lines.includes('Size:') ? after('Size:') : '') ?? lacks('"Size: W x H px"'); + const released = /^\d{1,2} [A-Z][a-z]+ \d{4}/u.exec(lines.includes('Release date:') ? after('Release date:') : '')?.[0] ?? lacks('release date'); + // The colors table: a row is the band with the color it is shown in and the line's name where the page gives one, the wavelength, and the telescope with its instrument. + const table = /<h3[^>]*>Colou?rs &(?:amp;)? filters<\/h3>\s*<table[\s\S]*?<\/table>/u.exec(html)?.[0] ?? '', colors: PictureColor[] = []; + for (const row of table.matchAll(/<tr>([\s\S]*?)<\/tr>/gu)) { + const cells = [...row[1]!.matchAll(/<td>([\s\S]*?)<\/td>/gu)].map(cell => cell[1]!); + if (cells.length !== 3) continue; + const [band, wavelength, facility] = cells as [string, string, string], line = words(/class="band_instrument">([\s\S]*?)<\/span>/u.exec(band)?.[1] ?? ''); + const [, instrument = ''] = facility.split(/<br\s*\/?>/u).map(words); + colors.push({ band: words(band).split(' ')[0]!, shown: /class="band_([A-Za-z]+)"/u.exec(band)?.[1] ?? '', ...(line ? { line } : {}), wavelength: words(wavelength), instrument }); + } + if (!colors.length) lacks('"Colours & filters" table'); + return { ...picture, title, credit, released, original: [Number(size[1]), Number(size[2])], colors }; +} + +/** The sky tags in a JPEG's bytes, for a file `width` pixels across: a publisher's smaller copy carries the tags of its + * original, whose scale and reference pixel are brought to this file's own pixels. */ +export function skyTags(bytes: Buffer, width: number, height: number, name: string): SkyTags { + const text = bytes.toString('latin1'); + const tag = (key: string, count: number) => { const found = new RegExp(`<avm:${key}>([\\s\\S]*?)</avm:${key}>`, 'u').exec(text) ?? new RegExp(`avm:${key}="([^"]*)"`, 'u').exec(text); + const values = found?.[1]!.replace(/<[^>]+>/gu, ' ').trim().split(/\s+/u).map(Number) ?? []; + if (values.length < count || !values.slice(0, count).every(Number.isFinite)) throw new TypeError(`${name} carries no avm:${key}: the picture has no sky tags to place it by.`); + return values; }; + const [taggedWidth, taggedHeight] = tag('Spatial.ReferenceDimension', 2) as [number, number], shrink = taggedWidth / width; + if (Math.abs(taggedHeight / shrink - height) > 1) throw new TypeError(`${name} is ${width} x ${height} px; its sky tags describe ${taggedWidth} x ${taggedHeight}, another shape.`); + const pixel = tag('Spatial.ReferencePixel', 2), place = tag('Spatial.ReferenceValue', 2); + return { scaleDeg: Math.abs(tag('Spatial.Scale', 1)[0]!) * shrink, rotationDeg: tag('Spatial.Rotation', 1)[0]!, reference: [place[0]!, place[1]!], referencePixel: [pixel[0]! / shrink, pixel[1]! / shrink] }; +} + +/** A picture's page, read through the telescope's archive transfer. */ +export async function readEsaPage(address: string, archive: Archive): Promise<EsaPage> { + return parseEsaPage(address, await archive.text(esaPictureAddress(address).page)); +} diff --git a/packages/telescope-cli/src/new-object/pictures/picture-bank.mts b/packages/telescope-cli/src/new-object/pictures/picture-bank.mts new file mode 100644 index 00000000000..0d6fac4f1aa --- /dev/null +++ b/packages/telescope-cli/src/new-object/pictures/picture-bank.mts @@ -0,0 +1,159 @@ +/** One published picture as a dataset of a page that already shows a shaped layer bank. A spec entry names the page, the + * picture and the bank whose walls, surface or rings the picture lies on (`like`); the records of the new bank are that + * bank's with this picture's own registration, and the page gains a dataset. The generator reads and computes: every + * sentence a reader sees (the dataset's title and text, what is chosen and why) is the spec's, written by a person. */ +import { isRecord, requireArray, requireFiniteNumber, requireRecord, requireString } from '@cssearth/core'; +import { INVESTIGATION_LEDGER_SCHEMA, OBJECT_SCHEMA, PREPARED_IMAGE_LAYER_BANK_SCHEMA } from '@cssearth/objects'; +import { json, type PackageFiles } from '../dataset.mts'; +import { TODO } from '../scaffold.mts'; +import type { EsaPage, PictureColor, SkyTags } from './esa-image.mts'; +import { registerPicture, RIM_FROM, type Place, type Registration } from './registration.mts'; + +/** A layer bank's own dataset is named `optical` whatever its light: the page's dataset carries the reader's name for it. */ +export const BANK_DATASET = 'optical'; +/** The widest face a picture is baked at unless its entry says otherwise: the Ring Nebula's near-infrared bank is 4.9 MB at + * this size (2026-10-04). */ +export const FACE_PIXELS = 1500; +const LICENSE = 'CC-BY-4.0', ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u, STATUSES = ['included', 'excluded', 'deferred'] as const; + +/** A source an entry cites; `checked` is the day a person read it, the run's day when absent. */ +export interface PictureSource { readonly catalogueId: string; readonly url: string; readonly label: string; readonly locator: string; readonly checked?: string } +export interface LedgerEntry { readonly id: string; readonly subject: string; readonly status: typeof STATUSES[number]; readonly finding: string; readonly evidence: readonly string[] } +export interface PictureEntry { + readonly host: string; readonly image: string; readonly bank: string; readonly like: string; + readonly dataset: { readonly id: string; readonly label: string; readonly title: string; readonly summary: string; readonly description: string }; + /** The wavelengths the picture shows; the page's own table when absent. */ + readonly colors?: readonly string[]; + /** The pixel, from the picture's top left, of the star that stands at the page's place; the tags' own pixel when absent. */ + readonly star?: readonly [number, number]; + /** How that star was found in the picture, as a draft reports it: "the largest patch of saturated pixels within 1 arcsec, 29 px". */ + readonly starFound?: string; + readonly facePixels?: number; + /** Laid over the like bank's recipe geometry: the values this picture's light takes on the same walls; null removes one. */ + readonly geometry?: Readonly<Record<string, unknown>>; + readonly displayModel?: string; + readonly sources: readonly PictureSource[]; readonly ledger: readonly LedgerEntry[]; +} +type Json = Record<string, unknown>; +export interface PictureInputs { + readonly page: EsaPage; readonly tags: SkyTags; readonly dimensions: readonly [number, number]; + /** Where the tags alone put the recipe's target, and how the entry's star was found when the draft found it. */ + readonly tagged: readonly [number, number]; + readonly like: { readonly recipe: Json; readonly manifest: Json; readonly presentation: Json; readonly provenance: Json }; + readonly host: { readonly content: Json; readonly text: Json }; + readonly checked: string; +} + +/** A spec file's `pictures`. An entry a draft left unfinished is refused by the field still to write. */ +export function parsePictures(value: unknown): PictureEntry[] { + const entries = requireArray(requireRecord(value, 'picture spec').pictures, 'pictures').map((entry, index): PictureEntry => { + const at = `pictures[${index}]`, input = requireRecord(entry, at), text = (record: Json, key: string, where: string) => requireString(record[key], `${where}.${key}`).trim(); + const unwritten = (found: unknown, where: string): void => { if (typeof found === 'string' && found.includes('TODO(')) throw new TypeError(`${where} is still to write: ${found}`); + if (Array.isArray(found)) found.forEach((one, i) => unwritten(one, `${where}[${i}]`)); else if (isRecord(found)) for (const [key, one] of Object.entries(found)) unwritten(one, `${where}.${key}`); }; + unwritten(input, at); + const id = (key: string) => { const found = text(input, key, at); if (!ID.test(found)) throw new TypeError(`${at}.${key} ${JSON.stringify(found)} is not an object id (lower case, digits, hyphens).`); return found; }; + const dataset = requireRecord(input.dataset, `${at}.dataset`), datasetId = text(dataset, 'id', `${at}.dataset`); + if (!ID.test(datasetId)) throw new TypeError(`${at}.dataset.id ${JSON.stringify(datasetId)} is not a dataset id (lower case, digits, hyphens).`); + const pair = (found: unknown, where: string) => { const list = requireArray(found, where).map(one => requireFiniteNumber(one, where)); if (list.length !== 2) throw new TypeError(`${where} must be two numbers: x and y from the picture's top left.`); return [list[0]!, list[1]!] as const; }; + return { host: id('host'), image: text(input, 'image', at), bank: id('bank'), like: id('like'), + dataset: { id: datasetId, label: text(dataset, 'label', `${at}.dataset`), title: text(dataset, 'title', `${at}.dataset`), summary: text(dataset, 'summary', `${at}.dataset`), description: text(dataset, 'description', `${at}.dataset`) }, + ...(input.colors === undefined ? {} : { colors: requireArray(input.colors, `${at}.colors`).map(one => requireString(one, `${at}.colors`)) }), + ...(input.star === undefined ? {} : { star: pair(input.star, `${at}.star`) }), ...(input.starFound === undefined ? {} : { starFound: text(input, 'starFound', at) }), + ...(input.facePixels === undefined ? {} : { facePixels: requireFiniteNumber(input.facePixels, `${at}.facePixels`) }), + ...(input.geometry === undefined ? {} : { geometry: requireRecord(input.geometry, `${at}.geometry`) }), + ...(input.displayModel === undefined ? {} : { displayModel: text(input, 'displayModel', at) }), + sources: (input.sources === undefined ? [] : requireArray(input.sources, `${at}.sources`)).map((one, i) => { const where = `${at}.sources[${i}]`, source = requireRecord(one, where); + return { catalogueId: text(source, 'catalogueId', where), url: text(source, 'url', where), label: text(source, 'label', where), locator: text(source, 'locator', where), ...(source.checked === undefined ? {} : { checked: text(source, 'checked', where) }) }; }), + ledger: (input.ledger === undefined ? [] : requireArray(input.ledger, `${at}.ledger`)).map((one, i) => { const where = `${at}.ledger[${i}]`, row = requireRecord(one, where), status = text(row, 'status', where) as LedgerEntry['status']; + if (!STATUSES.includes(status)) throw new TypeError(`${where}.status must be one of ${STATUSES.join(', ')}.`); + return { id: text(row, 'id', where), subject: text(row, 'subject', where), status, finding: text(row, 'finding', where), evidence: requireArray(row.evidence, `${where}.evidence`).map(link => requireString(link, `${where}.evidence`)) }; }) }; + }); + const repeated = entries.map(entry => entry.bank).filter((bank, index, all) => all.indexOf(bank) !== index); + if (repeated.length) throw new TypeError(`Picture banks repeat: ${repeated.join(', ')}.`); + return entries; +} + +/** The wavelengths a page's table lists, in its order, each once; and a list of them as a reader's phrase: "7.7, 12 and 18 µm". */ +export const pageColors = (colors: readonly PictureColor[]) => [...new Set(colors.map(color => color.wavelength))]; +export function colorsPhrase(colors: readonly string[]) { + const parts = colors.map(color => /^(\S+) (\S+)$/u.exec(color)), unit = parts[0]?.[2], shared = parts.every(part => part && part[2] === unit) && colors.length > 1; + const list = shared ? parts.map(part => part![1]!) : [...colors]; + return `${list.length > 1 ? `${list.slice(0, -1).join(', ')} and ${list.at(-1)}` : list[0] ?? ''}${shared ? ` ${unit}` : ''}`; +} +export const pictureRecordId = (page: Pick<EsaPage, 'id' | 'telescope'>) => `esa${page.telescope.toLowerCase()}-${page.id}`; + +/** `over` laid on `base`, key by key: a record goes into the record under it, any other value takes its place, and null removes it. */ +const lay = (base: Json, over: Readonly<Record<string, unknown>>): Json => { for (const [key, value] of Object.entries(over)) { if (value === null) delete base[key]; else base[key] = isRecord(value) && isRecord(base[key]) ? lay({ ...base[key] }, value) : value; } return base; }; +const copy = <T,>(value: T): T => structuredClone(value); + +/** Every record of one picture's bank and its page's dataset; `carried` are the like bank's other inputs, to copy beside + * the picture. `readme` is written only where the bank has none. */ +export function pictureFiles(entry: PictureEntry, inputs: PictureInputs): { readonly files: PackageFiles; readonly readme: string; readonly carried: readonly { readonly from: string; readonly to: string }[]; readonly registration: Registration; readonly todo: readonly string[] } { + const { page, tags, dimensions: [width, height], like, host, checked } = inputs, { bank, dataset } = entry, files: PackageFiles = new Map(), at = `src/objects/${bank}`, likeAt = `src/objects/${entry.like}/`; + const likeRecipe = copy(like.recipe), target = requireRecord(likeRecipe.target, `${entry.like} recipe target`) as unknown as Place, likeSource = requireRecord(likeRecipe.source, `${entry.like} recipe source`); + const star = entry.star ?? inputs.tagged, registration = registerPicture(tags, [width, height], star, target), { observation, plane, pixelArcsec, circleArcsec } = registration; + const publisher = `ESA/${page.telescope}`, record = pictureRecordId(page), colors = entry.colors ?? pageColors(page.colors), instruments = [...new Set(page.colors.map(color => color.instrument).filter(Boolean))]; + const pixels = `${width} × ${height}`, field = `${(observation.fieldOfViewDeg[0] * 60).toFixed(2)} × ${(observation.fieldOfViewDeg[1] * 60).toFixed(2)} arcmin`, shown = `${instruments.join(' and ')} at ${colorsPhrase(colors)}`; + const direction = `${pixelArcsec.toFixed(4)} arcsec per pixel and north ${Math.abs(tags.rotationDeg).toFixed(1)}° ${tags.rotationDeg >= 0 ? 'left' : 'right'} of vertical`, centre = `${observation.centerRaDeg}°, ${observation.centerDecDeg}°`; + const fromTags = (Math.hypot(star[0] - inputs.tagged[0], star[1] - inputs.tagged[1]) * pixelArcsec).toFixed(2); + const registered = entry.star + ? `The file's embedded sky tags for scale and direction, ${direction}, and the star for place: the star in the picture, ${entry.starFound ? `${entry.starFound}, ` : ''}pixel ${star.join(', ')}, is set at the recipe's target, so the frame's centre is ${centre}. The tags alone put the star ${fromTags} arcsec from where the picture shows it.` + : `The file's embedded sky tags, used as they are: ${direction}, the frame's centre at ${centre}. Not measured against a star here.`; + const mark = entry.star ? 'the star' : 'the recipe\'s target', rim = `between ${(RIM_FROM * circleArcsec).toFixed(1)} and ${circleArcsec} arcsec from ${mark}`; + + // The bank: the like bank's recipe with this picture, its registration and what the entry lays over the geometry. + const recipe = { ...likeRecipe, id: bank, source: { path: 'source.jpg', dimensions: [width, height], originalDimensions: page.original, publisherUrl: page.page, downloadUrl: page.download, credit: page.credit, license: LICENSE }, observation, + geometry: lay({ ...requireRecord(likeRecipe.geometry, `${entry.like} recipe geometry`), ...plane }, entry.geometry ?? {}), + bake: { ...requireRecord(likeRecipe.bake, `${entry.like} recipe bake`), maxFacePixels: entry.facePixels ?? Math.min(Math.max(width, height), FACE_PIXELS) } }; + files.set(`${at}/source/recipe.json`, json(recipe)); + files.set(`${at}/object.json`, json({ schema: OBJECT_SCHEMA, id: bank, type: 'image-layer-bank', properties: { preparation: { source: 'source/recipe.json' }, host: entry.host }, prepared: { format: PREPARED_IMAGE_LAYER_BANK_SCHEMA, url: 'prepared/image-layers.json' } })); + + // The manifest: the picture, then every other input the like bank reads, each beside this bank. + const likePicture = `${likeAt}source/${requireString(likeSource.path, `${entry.like} recipe source.path`)}`, moved = (path: unknown) => requireString(path, `${entry.like} manifest path`).replace(likeAt, `${at}/`); + const others = requireArray(like.manifest.inputs, `${entry.like} manifest inputs`).map(input => requireRecord(input, `${entry.like} manifest input`)).filter(input => input.path !== likePicture); + const manifest = { ...copy(like.manifest), inputs: [{ id: BANK_DATASET, path: `${at}/source/source.jpg`, origin: page.download, sourceUrl: page.page, title: page.title, credit: page.credit, displayCredit: publisher, + acquisition: `The publisher's JPEG (${width} x ${height} px), downloaded unchanged; not tracked. Its scale and direction on the sky are the file's embedded tags; its place is ${entry.star ? 'set by the star' : 'the tags\' own'} (recipe observation). A display composite, not a calibrated scientific array.`, + sourceBinding: { kind: 'catalogued', references: [{ catalogueId: record, role: 'material', evidence: `${at}/source/provenance.json` }] }, dependencies: [], datasetId: BANK_DATASET, license: `${LICENSE}; retain the complete recorded credit.`, + capture: { attributions: [{ kind: 'facility', facilityId: page.telescope.toLowerCase(), evidence: page.page }] } }, ...others.map(input => ({ ...input, path: moved(input.path) }))], + documents: requireArray(like.manifest.documents, `${entry.like} manifest documents`).map(document => { const one = requireRecord(document, `${entry.like} manifest document`); return { ...one, path: moved(one.path) }; }), generatedIntermediates: [] }; + files.set(`${at}/source/manifest.json`, json(manifest)); + files.set(`${at}/source/presentation.json`, json({ ...copy(like.presentation), objectId: bank, defaultDataset: BANK_DATASET, bank: { path: `${at}/prepared/image-layers.json` }, + recipes: [{ id: 'image-layer-recipe', path: `${at}/source/recipe.json` }, { id: 'source-evidence', path: `${at}/source/provenance.json` }], sharedInputs: [], + datasets: [{ id: BANK_DATASET, label: dataset.label, title: dataset.title, description: dataset.description, summary: dataset.summary, detail: `${pixels} px`, + facts: [{ id: 'bands', label: 'Colors', value: colorsPhrase(colors) }, { id: 'source-pixels', label: 'Source pixels', value: pixels }, { id: 'coverage', label: 'Field', value: field }, { id: 'license', label: 'License', value: LICENSE }], + input: BANK_DATASET, preview: { path: `${at}/source/source.jpg`, url: page.download } }] })); + const displayModel = entry.displayModel ?? requireString(like.provenance.displayModel, `${entry.like} provenance displayModel`); + files.set(`${at}/source/provenance.json`, json({ schema: 'cssearth-image-layer-provenance@1', title: page.title, publisher, sourceId: page.id, sourcePage: page.page, download: page.download, credit: page.credit, license: LICENSE, + original: { dimensions: page.original, bands: [page.telescope, ...instruments, ...colors], fieldOfViewDeg: observation.fieldOfViewDeg }, checkedSource: { kind: 'Published JPEG', dimensions: [width, height] }, + geometryEvidence: { inclinationDeg: plane.inclinationDeg, lineOfNodesPaDeg: plane.lineOfNodesPaDeg, reference: `A picture of the sky: the plane is the picture's own tangent plane, perpendicular to the sight line through the picture's centre, so it faces the Sun. That centre is ${registration.starFromCentreArcsec.toFixed(1)} arcsec from ${mark}, which is the whole of the recipe's inclination. Not the orientation of the object itself.` }, + registration: { method: registered, pixelArcsec: Number(pixelArcsec.toPrecision(4)), northClockwiseDeg: observation.northClockwiseDeg }, displayModel, + coverage: `The published frame inside a circle about ${mark}: the picture fades out ${rim}, the largest circle the frame holds, so no straight edge shows.`, + acquisition: { url: page.download, dimensions: [width, height], operation: 'Download the publisher\'s JPEG; no crop or resample.' } })); + files.set(`${at}/investigations.json`, json({ schema: INVESTIGATION_LEDGER_SCHEMA, objectId: bank, entries: [ + { id: page.id, subject: `${publisher} ${page.id}`, status: 'included', finding: `${page.title}: ${shown}; ${pixels} px over ${field}, released ${page.released}. ${registered}`, evidence: [page.page, page.rights] }, ...entry.ledger] })); + files.set(`src/sources/${record}.json`, json({ id: record, kind: 'data-product', identityLevel: 'work', title: page.title, identifiers: [{ type: `${publisher} image`, value: page.id }], + links: [{ role: 'landing', url: page.page, label: 'Published source' }, { role: 'original', url: page.download, label: `JPEG, ${width} x ${height} px` }, { role: 'rights', url: page.rights, label: 'Reuse terms' }], + evidence: [{ url: page.page, checkedOn: checked, locator: `${width} x ${height} px over ${field}; ${shown}; released ${page.released}. ${registered}` }], publisher, relations: [], + statements: [{ kind: 'credit', text: page.credit, scope: 'Published source', evidence: page.page }, { kind: 'rights', text: 'Creative Commons Attribution 4.0 International; the full credit must be shown.', scope: 'Published source', evidence: page.rights }] })); + + // The page: one more dataset, shown from this bank. + const content = copy(host.content), datasets = requireRecord(content.datasets, `${entry.host} content datasets`), controls = requireArray(datasets.controls, `${entry.host} dataset controls`).map(control => requireRecord(control, `${entry.host} dataset control`)); + datasets.controls = [...controls.filter(control => control.id !== dataset.id), { id: dataset.id, label: dataset.label, thumbnail: `${entry.host}-dataset-${dataset.id}.webp`, volume: { objectId: bank, datasetId: BANK_DATASET, surface: dataset.id }, + source: { id: `${bank}-${BANK_DATASET}`, path: `../../${bank}/source/manifest.json`, url: page.page }, falseColor: true }]; + files.set(`src/objects/${entry.host}/source/content/object.json`, json(content)); + const text = copy(host.text), texts = requireRecord(text.datasets, `${entry.host} text datasets`), picture: PictureSource = { catalogueId: record, url: page.page, label: `${publisher} ${page.id}: ${page.credit}`, locator: page.title }; + // A source the page's dataset already cites in the same words keeps the day it was read: a run that changes nothing writes nothing. + const before = texts[dataset.id], cited = isRecord(before) && Array.isArray(before.sources) ? before.sources.filter(isRecord) : []; + const read = (source: PictureSource) => { const day = cited.find(one => one.catalogueId === source.catalogueId && one.url === source.url && one.label === source.label && one.locator === source.locator)?.checked; return typeof day === 'string' ? day : undefined; }; + texts[dataset.id] = { title: dataset.title, detail: `${pixels} px`, summary: dataset.summary, sources: [picture, ...entry.sources].map(source => ({ catalogueId: source.catalogueId, url: source.url, label: source.label, checked: source.checked ?? read(source) ?? checked, locator: source.locator })) }; + files.set(`src/objects/${entry.host}/text.json`, json(text)); + + const name = requireString(like.presentation.name, `${entry.like} presentation name`), parsecs = `${registration.widthPc.toFixed(2)} pc wide at ${Math.round(target.distancePc)} pc`; + const readme = `# ${name}, ${dataset.label}\n\n${TODO}: one paragraph. What the picture is, what it lies on (the [${entry.like}](../${entry.like}/README.md) bank's walls), and what is chosen here and not measured.\n\n## Sources\n\n| Selected source | Input and meaning |\n| --- | --- |\n` + + `| [${publisher} ${page.id}](${page.page}) | [Record](../../sources/${record}.json). ${page.title}: ${shown}; ${pixels} px over ${field} (\`source/source.jpg\`, the publisher's JPEG, restored from its origin). Credit: ${page.credit}. A display composite, not calibrated photometry. |\n` + + entry.sources.map(source => `| [${source.label}](${source.url}) | [Record](../../sources/${source.catalogueId}.json). ${source.locator} |\n`).join('') + + `\n## The picture\n\n- **Registration:** ${registered}\n- **Depth:** ${TODO}: what the picture's light lies on, and which printed value each color takes.\n- **Size:** ${field}, ${parsecs}.\n- **Rim:** the picture fades out ${rim}, the largest circle the frame holds.\n` + + `\n## Evidence\n\n${TODO}: the page with this dataset selected, captured headless, and what the capture shows.\n\n## Known problems\n\n- ${TODO}: what is chosen and not measured.\n- Colors are the publisher's display composite, not a measurement.\n`; + return { files, readme, carried: others.map(input => ({ from: requireString(input.path, `${entry.like} manifest path`), to: moved(input.path) })), registration, + todo: [`write ${at}/README.md`, `name the ${bank} bank in src/objects/${entry.host}/README.md`] }; +} diff --git a/packages/telescope-cli/src/new-object/pictures/pictures.mts b/packages/telescope-cli/src/new-object/pictures/pictures.mts new file mode 100644 index 00000000000..4a6d90a684e --- /dev/null +++ b/packages/telescope-cli/src/new-object/pictures/pictures.mts @@ -0,0 +1,140 @@ +/** `telescope new-object` for published pictures: draft a spec from ESA's picture pages, write each entry's layer bank + * and its page's dataset, and bake them. One entry is one picture on a page that already shows a shaped bank + * (picture-bank.mts); the spec may be run again, and what a person wrote beside the records (the README) is kept. */ +import { spawn } from 'node:child_process'; +import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import sharp from 'sharp'; +import { isRecord, requireArray, requireRecord, requireString } from '@cssearth/core'; +import { fetchPublication, type Archive } from '../archives/archives.mts'; +import { TODO } from '../scaffold.mts'; +import { esaPictureAddress, readEsaPage, skyTags, type EsaPage } from './esa-image.mts'; +import { colorsPhrase, pageColors, parsePictures, pictureFiles, type PictureEntry } from './picture-bank.mts'; +import { locateStar, registerPicture, taggedPixel, type Place } from './registration.mts'; + +interface Context { readonly root: string; readonly archive: Archive; readonly progress: (line: string) => void } +export interface PictureResult { readonly id: string; readonly host: string; readonly dataset: string; readonly files: number; readonly todo: readonly string[]; readonly failed?: string } +type Json = Record<string, unknown>; + +const exists = (path: string) => stat(path).then(() => true, () => false); +const reason = (error: unknown) => (error as Error).message.split('\n')[0]!; +const readJson = async (root: string, path: string, what: string): Promise<Json> => { let text: string; try { text = await readFile(resolve(root, path), 'utf8'); } catch { throw new Error(`${what}: ${path} does not exist.`); } return requireRecord(JSON.parse(text), path); }; +/** What a reader calls an instrument's light, for a draft's dataset id and label; the instrument's own name otherwise. */ +const LIGHT: Readonly<Record<string, string>> = { NIRCam: 'near infrared', MIRI: 'mid infrared' }; + +/** The publisher's JPEG, read once: kept in the ignored `output/new-object/pictures/` between a draft and its run. */ +async function pictureBytes(page: EsaPage, { root, archive, progress }: Context) { + const kept = resolve(root, 'output/new-object/pictures', `${page.id}.jpg`); + if (await exists(kept)) return readFile(kept); + progress(` ${page.id}: reading ${page.download}`); + const bytes = await archive.bytes(page.download); + await mkdir(dirname(kept), { recursive: true }); await writeFile(kept, bytes); + return bytes; +} +async function pictureOnSky(page: EsaPage, target: Place, context: Context) { + const bytes = await pictureBytes(page, context), { data, info } = await sharp(bytes, { limitInputPixels: false }).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + if (info.channels !== 3) throw new Error(`${page.download}: a picture of ${info.channels} channel(s), not a color picture.`); + const tags = skyTags(bytes, info.width, info.height, page.download); + return { bytes, tags, rgb: data, dimensions: [info.width, info.height] as const, tagged: taggedPixel(tags, info.height, target) }; +} +const bankOf = (content: Json, host: string) => { const datasets = requireRecord(content.datasets, `${host} content datasets`), controls = requireArray(datasets.controls, `${host} dataset controls`).map(control => requireRecord(control, `${host} dataset control`)); + const shown = [controls.find(control => control.id === datasets.defaultDataset), ...controls].find(control => isRecord(control?.volume)); + if (!shown) throw new Error(`${host}: the page shows no layer bank a picture could lie on like.`); + return requireString(requireRecord(shown.volume, `${host} dataset volume`).objectId, `${host} dataset volume.objectId`); }; + +/** `--from-esa HOST=PAGE...`: one entry a picture, on the walls of the bank its page opens on, with the star the picture + * shows at the page's place. The sentences a reader sees are left to write. */ +export async function draftsFromEsa(names: readonly string[], context: Context): Promise<{ readonly stars: readonly unknown[]; readonly pictures: readonly unknown[]; readonly report: readonly string[] }> { + const pictures: unknown[] = [], report: string[] = []; + for (const asked of names) { + try { + const [host, address] = asked.split(/=(.*)/su) as [string, string | undefined]; + if (!address) throw new Error(`${asked}: name a page and its picture as HOST=https://esawebb.org/images/<id>/.`); + const page = await readEsaPage(address, context.archive), like = bankOf(await readJson(context.root, `src/objects/${host}/source/content/object.json`, `${host}: no such page`), host); + const target = requireRecord((await readJson(context.root, `src/objects/${like}/source/recipe.json`, `${like}: not a layer bank`)).target, `${like} recipe target`) as unknown as Place; + const sky = await pictureOnSky(page, target, context), pixelArcsec = sky.tags.scaleDeg * 3600, star = locateStar(sky.rgb, ...sky.dimensions, pixelArcsec, sky.tagged); + registerPicture(sky.tags, sky.dimensions, star?.pixel ?? sky.tagged, target); + const name = requireString((await readJson(context.root, `src/objects/${like}/source/presentation.json`, like)).name, `${like} presentation name`), colors = pageColors(page.colors); + const instruments = [...new Set(page.colors.map(color => color.instrument).filter(Boolean))], light = instruments.length === 1 ? LIGHT[instruments[0]!] ?? instruments[0]! : instruments.join(' + '); + const slug = (words: string) => words.toLowerCase().replace(/[^a-z0-9]+/gu, '-'), write = (what: string) => `${TODO}: ${what}`; + pictures.push({ host, image: page.page, bank: `${host}-${slug(instruments.join(' '))}-layers`, like, + dataset: { id: slug(light), label: `${page.telescope} · ${light}`, title: write('the dataset\'s title'), summary: write('one sentence under the title'), description: write('what the picture shows and what it lies on') }, + colors, ...(star ? { star: star.pixel.map(value => Number(value.toFixed(1))), starFound: star.found } : {}), geometry: {}, sources: [], ledger: [] }); + report.push(`${page.id} on ${host}, like ${like}: ${page.title}; ${sky.dimensions.join(' x ')} px, ${instruments.join(' and ')} at ${colorsPhrase(colors)}. ` + (star + ? `The star: ${star.found}, at ${star.pixel.map(value => value.toFixed(1)).join(', ')}, ${(Math.hypot(star.pixel[0] - sky.tagged[0], star.pixel[1] - sky.tagged[1]) * pixelArcsec).toFixed(2)} arcsec from where the tags put the page's place. Remove "star" and "starFound" where the page stands at no star.` + : 'No star near the page\'s place: the picture is placed by its tags.') + ` What its colors show: telescope papers ${JSON.stringify(name)} --instrument ${instruments[0]}`); + } catch (error) { report.push(`${asked}: not drafted: ${reason(error)}`); } + } + return { stars: [], pictures, report }; +} + +/** A record in src/sources for every source an entry cites: the one there, or one read from arXiv or Crossref by its link. */ +async function sourceRecords(entry: PictureEntry, { root, archive }: Context) { + const records = new Map<string, string>(); + for (const source of entry.sources) { + const path = `src/sources/${source.catalogueId}.json`; + if (await exists(resolve(root, path))) continue; + const publication = /arxiv\.org\/abs\/|doi\.org\//u.test(source.url) ? await fetchPublication(archive, source.url) : undefined; + if (!publication) throw new Error(`${entry.bank}: ${path} does not exist, and ${source.url} is not an arXiv or DOI link to write it from.`); + const { publicationRecord } = await import('../generate.mts'); + records.set(path, `${JSON.stringify(publicationRecord({ ...publication, id: source.catalogueId }), null, 2)}\n`); + } + return records; +} + +/** Write one entry: its bank's records, picture and other inputs, and its page's dataset. */ +async function writePicture(entry: PictureEntry, context: Context): Promise<PictureResult> { + const { root } = context, at = `src/objects/${entry.bank}`, inTree = (path: string) => resolve(root, path); + const page = await readEsaPage(entry.image, context.archive); + if (await exists(inTree(at))) { const made = await readJson(root, `${at}/source/recipe.json`, `${entry.bank} exists and is not a layer bank`); + if (requireRecord(made.source, `${entry.bank} recipe source`).publisherUrl !== page.page) throw new Error(`${at} already exists and shows another picture; the generator rewrites only the records of the picture it is given.`); } + if (entry.like === entry.bank) throw new Error(`${entry.bank}: a bank cannot be like itself.`); + const like = { recipe: await readJson(root, `src/objects/${entry.like}/source/recipe.json`, `${entry.like}: not a layer bank`), manifest: await readJson(root, `src/objects/${entry.like}/source/manifest.json`, entry.like), + presentation: await readJson(root, `src/objects/${entry.like}/source/presentation.json`, entry.like), provenance: await readJson(root, `src/objects/${entry.like}/source/provenance.json`, entry.like) }; + const host = { content: await readJson(root, `src/objects/${entry.host}/source/content/object.json`, `${entry.host}: no such page`), text: await readJson(root, `src/objects/${entry.host}/text.json`, entry.host) }; + const sky = await pictureOnSky(page, requireRecord(like.recipe.target, `${entry.like} recipe target`) as unknown as Place, context), records = await sourceRecords(entry, context); + const { files, readme, carried, todo } = pictureFiles(entry, { page, tags: sky.tags, dimensions: sky.dimensions, tagged: sky.tagged, like, host, checked: new Date().toISOString().slice(0, 10) }); + for (const { from } of carried) if (!await exists(inTree(from))) throw new Error(`${entry.bank}: ${from}, which the ${entry.like} bank reads, is not restored; its manifest names where it comes from.`); + let written = 0; + const put = async (path: string, value: string | Buffer) => { await mkdir(dirname(inTree(path)), { recursive: true }); await writeFile(inTree(path), value); written++; }; + for (const [path, value] of [...files, ...records]) if (!path.startsWith('src/sources/') || !await exists(inTree(path))) await put(path, value); + if (!await exists(inTree(`${at}/README.md`))) await put(`${at}/README.md`, readme); + await put(`${at}/source/source.jpg`, sky.bytes); + // The like bank's other inputs stand beside this bank too; one that Git ignores there is ignored here. + const ignore = inTree('.gitignore'), ignored = (await readFile(ignore, 'utf8')).split('\n'); + for (const { from, to } of carried) { await copyFile(inTree(from), inTree(to)); written++; + if (ignored.includes(from) && !ignored.includes(to)) { ignored.splice(ignored.indexOf(from) + 1, 0, `# The same file beside the ${entry.bank} bank; restored from the same origin (${at}/source/manifest.json).`, to); await writeFile(ignore, ignored.join('\n')); } } + // What is left for a person: the bank's README while it holds a line to write, and the page's README until it names the bank. + const holds = async (path: string, words: string) => (await readFile(inTree(path), 'utf8').catch(() => '')).includes(words), left = [await holds(`${at}/README.md`, TODO), !await holds(`src/objects/${entry.host}/README.md`, `../${entry.bank}/`)]; + return { id: entry.bank, host: entry.host, dataset: entry.dataset.id, files: written, todo: todo.filter((_, index) => left[index]) }; +} + +/** Every picture of a spec file, written. One that fails is reported with its reason and the rest go on. */ +export async function runPictures(specPath: string, context: Context): Promise<PictureResult[]> { + const entries = parsePictures(JSON.parse(await readFile(resolve(specPath), 'utf8'))), results: PictureResult[] = []; + for (const entry of entries) { + try { esaPictureAddress(entry.image); results.push(await writePicture(entry, context)); context.progress(` ${entry.bank}: written`); } + catch (error) { results.push({ id: entry.bank, host: entry.host, dataset: entry.dataset.id, files: 0, todo: [], failed: reason(error) }); context.progress(` ${entry.bank}: FAILED, not written: ${reason(error)}`); } + } + return results; +} + +/** Bake the banks and their pages: each bank's layers and presentation, each page's hosted context, then the pages' + * prepared files in one run (the reader-text step checks every page against its prepared datasets), the catalogue and the + * world. Stops at the first step that fails. */ +export async function bakePictures(results: readonly PictureResult[], { root, progress }: Pick<Context, 'root' | 'progress'>): Promise<boolean> { + const banks = results.map(result => result.id), hosts = [...new Set(results.map(result => result.host))], node = (...args: string[]) => ['node', ...args]; + const steps = [node('packages/bake/cli/check-stale-builds.mts', '--run'), ...banks.flatMap(bank => [node('packages/bake/cli/prepare-image-layers.mts', `src/objects/${bank}`), node('site/build/prepare/prepare-volume-presentation.mts', `--object=${bank}`)]), + ...hosts.map(host => node('site/build/prepare/companion-context.mts', host)), node('packages/bake/cli/prepare-object.mts', ...hosts, '--from', 'catalogue', '--to', 'markers'), node('site/build/prepare/prepare-catalog.mts'), + node('packages/bake/cli/prepare-object.mts', ...hosts, '--from', 'world'), node('packages/bake/cli/prepare-navigation.mts', '--catalog-only')]; + for (const [command, ...args] of steps as [string, ...string[]][]) { + progress(`== ${args.join(' ')}`); + // A step's own output is progress: it goes where the telescope's does, and the result text stays the telescope's. + const code = await new Promise<number | null>(done => { spawn(command, args, { cwd: root, stdio: ['ignore', 2, 2] }).on('error', () => done(null)).on('close', done); }); + if (code !== 0) { progress(`FAILED: ${command} ${args.join(' ')}`); return false; } + } + return true; +} + +export const formatPictures = (results: readonly PictureResult[], spec: string, baked: boolean) => `${results.map(result => result.failed ? `${result.id} (picture): FAILED, not written: ${result.failed}` + : `${result.id} (picture, the ${result.dataset} dataset of ${result.host}): ${result.files} files.${result.todo.length ? `\n Still to write: ${result.todo.join('; ')}.` : ''}`).join('\n')}\n${baked ? `${results.filter(result => !result.failed).length} picture(s) baked.` : `Then bake: telescope new-object ${spec} --bake`}\n`; diff --git a/packages/telescope-cli/src/new-object/pictures/pictures.test.mts b/packages/telescope-cli/src/new-object/pictures/pictures.test.mts new file mode 100644 index 00000000000..47f7f0df0cd --- /dev/null +++ b/packages/telescope-cli/src/new-object/pictures/pictures.test.mts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import sharp from 'sharp'; +import { sourceTest } from '@cssearth/objects/node/source-test'; +import type { Archive } from '../archives/archives.mts'; +import { TODO } from '../scaffold.mts'; +import { esaPictureAddress, parseEsaPage, skyTags, type EsaPage, type SkyTags } from './esa-image.mts'; +import { colorsPhrase, parsePictures, pictureFiles } from './picture-bank.mts'; +import { draftsFromEsa, runPictures } from './pictures.mts'; +import { locateStar, registerPicture, taggedPixel } from './registration.mts'; + +const test = sourceTest(); +const WEBB = 'https://esawebb.org/images/weic0000a/'; +// The two layouts ESA's sites give a picture's page: Webb's names the shown color in a hidden span, Hubble's in the class alone. +const row = (band: string, wavelength: string, instrument: string) => `<tr><td>${band}</td><td>${wavelength}</td><td>\n Some Space Telescope\n <br/><span class="band_instrument">${instrument}</span></td></tr>`; +const page = (title: string, credit: string, rows: string) => `<html><head><title>${title} | ESA/WebbCredit:\n

${credit}

+
Release date:21 August 2023, 16:00
Size:40 x 30 px
+

Colours & filters

${rows}
BandWavelengthTelescope
`; +const WEBB_PAGE = page('A nebula (MIRI image)', 'ESA/Webb, NASA, CSA, A. Person', row('\n Infrared\n (Blue)
PAH', '7.7 μm', 'MIRI') + row(' Infrared (Red)', '18 μm', 'MIRI')); + +test('a picture\'s page gives its title, credit, date, size and colors, on either site\'s markup', () => { + const webb = parseEsaPage(WEBB, WEBB_PAGE); + assert.deepEqual({ ...webb, colors: undefined }, { id: 'weic0000a', telescope: 'Webb', page: WEBB, download: 'https://cdn.esawebb.org/archives/images/large/weic0000a.jpg', rights: 'https://esawebb.org/copyright/', + title: 'A nebula (MIRI image)', credit: 'ESA/Webb, NASA, CSA, A. Person', released: '21 August 2023', original: [40, 30], colors: undefined }); + assert.deepEqual(webb.colors, [{ band: 'Infrared', shown: 'Blue', line: 'PAH', wavelength: '7.7 µm', instrument: 'MIRI' }, { band: 'Infrared', shown: 'Red', wavelength: '18 µm', instrument: 'MIRI' }]); + const hubble = parseEsaPage('https://esahubble.org/images/heic0000a', page('A nebula', 'NASA, ESA, and A. O’Person (A University).', row('Optical
OIII', '502 nm', 'WFC3'))); + assert.deepEqual([hubble.telescope, hubble.page, hubble.credit.startsWith('NASA, ESA, and A. O'), hubble.credit.endsWith('(A University)')], ['Hubble', 'https://esahubble.org/images/heic0000a/', true, true]); + assert.deepEqual(hubble.colors, [{ band: 'Optical', shown: 'Green', line: 'OIII', wavelength: '502 nm', instrument: 'WFC3' }]); + assert.equal(colorsPhrase(['7.7 µm', '12 µm', '18 µm']), '7.7, 12 and 18 µm'); + assert.equal(colorsPhrase(['502 nm', '1.6 µm']), '502 nm and 1.6 µm'); +}); + +test('a page that lacks a field, or an address that is no picture\'s page, is refused by name', () => { + assert.throws(() => esaPictureAddress('https://esawebb.org/news/weic2320/'), /not a picture's page/u); + assert.throws(() => parseEsaPage(WEBB, WEBB_PAGE.replace('class="credit"', 'class="other"')), /weic0000a\/: the page has no credit/u); + assert.throws(() => parseEsaPage(WEBB, WEBB_PAGE.replace('40 x 30 px', 'large')), /no "Size: W x H px"/u); + assert.throws(() => parseEsaPage(WEBB, WEBB_PAGE.replace(//u, '')), /no "Colours & filters" table/u); +}); + +// ESA/Webb weic2320c, the Ring Nebula in the mid infrared: the sky tags its JPEG carries. +const RING_TAGS: SkyTags = { scaleDeg: 0.000030865735733094474, rotationDeg: -132.91999999999948, reference: [283.39639957968876, 33.029010843598876], referencePixel: [628.5, 507.5] }; +const list = (values: readonly number[]) => `${values.map(value => `\n ${value}`).join('')}\n`; +const xmp = (tags: SkyTags, [width, height]: readonly [number, number]) => `${list(tags.reference)}${list([width, height])}` + + `${list(tags.referencePixel)}${list([-tags.scaleDeg, tags.scaleDeg])}${tags.rotationDeg}`; + +test('a JPEG\'s sky tags are read for the file as it is, and a smaller copy takes its original\'s', () => { + const bytes = Buffer.from(xmp(RING_TAGS, [1257, 1015]), 'latin1'); + assert.deepEqual(skyTags(bytes, 1257, 1015, 'ring.jpg'), RING_TAGS); + const half = skyTags(Buffer.from(xmp(RING_TAGS, [2514, 2030]), 'latin1'), 1257, 1015, 'ring.jpg'); + assert.deepEqual([half.scaleDeg, half.referencePixel], [RING_TAGS.scaleDeg * 2, [314.25, 253.75]]); + assert.deepEqual(skyTags(Buffer.from(''), 10, 8, 'x.jpg'), + { scaleDeg: 0.5, rotationDeg: 3, reference: [1, 2], referencePixel: [5, 4] }); + assert.throws(() => skyTags(bytes, 1257, 900, 'ring.jpg'), /ring\.jpg is 1257 x 900 px; its sky tags describe 1257 x 1015/u); + assert.throws(() => skyTags(Buffer.from('no tags'), 10, 10, 'bare.jpg'), /bare\.jpg carries no avm:Spatial\.ReferenceDimension/u); +}); + +const RING = { centerRaDeg: 283.39624524513, centerDecDeg: 33.02914523473, distancePc: 790.014153719229 }; + +test('a picture is registered by its tags and one pixel: the Ring Nebula\'s mid-infrared recipe', () => { + const made = registerPicture(RING_TAGS, [1257, 1015], [627.5, 513.5], RING); + assert.deepEqual(made.observation, { centerRaDeg: 283.396433, centerDecDeg: 33.0290199, fieldOfViewDeg: [0.03879823, 0.03132872], northClockwiseDeg: 132.92 }); + assert.deepEqual(made.plane, { inclinationDeg: 0.001, lineOfNodesPaDeg: 218.521, thicknessKpc: 2.67e-7, supportRadiusKpc: 0.00021295, supportTaperFraction: 0.9 }); + // The tags' own pixel for the star's place is within half a pixel of the star the picture shows; and the reference pixel is its own place. + const tagged = taggedPixel(RING_TAGS, 1015, RING); + assert.ok(Math.hypot(tagged[0] - 627.5, tagged[1] - 513.5) < 0.5, tagged.join(', ')); + assert.deepEqual(taggedPixel(RING_TAGS, 1015, { centerRaDeg: RING_TAGS.reference[0], centerDecDeg: RING_TAGS.reference[1] }), [627.5, 507.5]); + assert.throws(() => registerPicture(RING_TAGS, [1257, 1015], [1300.2, 400], RING), /The picture, 1257 x 1015 px, does not hold the place it is to stand at: pixel 1300\.2, 400\.0/u); +}); + +/** A dark picture with one soft source: `peak` levels at its middle, and every pixel within `core` pixels of it saturated. */ +function sourceAt(width: number, height: number, [x, y]: readonly [number, number], peak: number, core = 0) { + const rgb = new Uint8Array(3 * width * height).fill(20); + for (let py = 0; py < height; py++) for (let px = 0; px < width; px++) { const from = Math.hypot(px - x, py - y), level = from <= core ? 255 : Math.min(255, 20 + peak * Math.exp(-from * from / 8)); rgb.fill(Math.round(level), 3 * (py * width + px), 3 * (py * width + px) + 3); } + return rgb; +} + +test('the star a picture shows is its saturated patch, or else its strongest peak, near where the tags put it', () => { + const patch = locateStar(sourceAt(80, 60, [41, 29], 200, 3), 80, 60, 0.1, [38, 27]); + assert.deepEqual(patch?.pixel, [41, 29]); assert.match(patch!.found, /largest patch of saturated pixels within 1 arcsec of where the tags put it, 29 px/u); + const peak = locateStar(sourceAt(80, 60, [41.3, 29.6], 120), 80, 60, 0.1, [38, 27]); + assert.ok(peak && Math.hypot(peak.pixel[0] - 41.3, peak.pixel[1] - 29.6) < 0.2, String(peak?.pixel)); assert.match(peak.found, /peak within 3 arcsec of where the tags put it/u); + assert.equal(locateStar(new Uint8Array(3 * 80 * 60).fill(20), 80, 60, 0.1, [38, 27]), undefined); + // A saturated star farther than an arcsecond from the place is another star. + assert.equal(locateStar(sourceAt(80, 60, [70, 50], 200, 3), 80, 60, 0.02, [20, 20]), undefined); +}); + +const ENTRY = { host: 'm57', image: 'https://esawebb.org/images/weic2320c/', bank: 'm57-miri-layers', like: 'm57-layers', dataset: { id: 'mid-infrared', label: 'Webb · mid infrared', title: 'A title', summary: 'A summary.', description: 'A description.' } }; + +test('a spec entry a draft left unfinished, or one with a wrong id, is refused by its field', () => { + assert.equal(parsePictures({ pictures: [ENTRY] })[0]!.bank, 'm57-miri-layers'); + assert.throws(() => parsePictures({ pictures: [{ ...ENTRY, dataset: { ...ENTRY.dataset, summary: `${TODO}: one sentence` } }] }), /pictures\[0\]\.dataset\.summary is still to write/u); + assert.throws(() => parsePictures({ pictures: [{ ...ENTRY, geometry: { shape: { basis: `${TODO}: why` } } }] }), /pictures\[0\]\.geometry\.shape\.basis is still to write/u); + assert.throws(() => parsePictures({ pictures: [{ ...ENTRY, bank: 'M57 layers' }] }), /pictures\[0\]\.bank "M57 layers" is not an object id/u); + assert.throws(() => parsePictures({ pictures: [{ ...ENTRY, star: [1] }] }), /pictures\[0\]\.star must be two numbers/u); + assert.throws(() => parsePictures({ pictures: [ENTRY, ENTRY] }), /Picture banks repeat: m57-miri-layers/u); +}); + +test('the generator makes the recipe and manifest the Ring Nebula\'s mid-infrared bank ships, from what differs from the Hubble bank', async () => { + const json = async (path: string) => JSON.parse(await readFile(new URL(`../../../../../${path}`, import.meta.url), 'utf8')) as Record; + const at = 'src/objects/m57-miri-layers', shipped = await json(`${at}/source/recipe.json`), like = { recipe: await json('src/objects/m57-layers/source/recipe.json'), manifest: await json('src/objects/m57-layers/source/manifest.json'), + presentation: await json('src/objects/m57-layers/source/presentation.json'), provenance: await json('src/objects/m57-layers/source/provenance.json') }; + // The entry says what this picture's colors take on the Hubble dataset's shell: the shell's source and reasoning, and the ring's and the lobe's speeds. + const [entry] = parsePictures({ pictures: [{ ...ENTRY, star: [627.5, 513.5], facePixels: 1257, geometry: { shape: { basis: shipped.geometry.shape.basis, ring: { expansionKmS: shipped.geometry.shape.ring.expansionKmS }, lobe: { expansionKmS: shipped.geometry.shape.lobe.expansionKmS } } } }] }); + const ring: EsaPage = { ...esaPictureAddress(ENTRY.image), title: 'Webb captures detailed beauty of Ring Nebula (MIRI image)', credit: 'ESA/Webb, NASA, CSA, M. Barlow, N. Cox, R. Wesson', released: '21 August 2023', original: [1257, 1015], + colors: [{ band: 'Infrared', shown: 'Blue', wavelength: '7.7 µm', instrument: 'MIRI' }] }; + const made = pictureFiles(entry!, { page: ring, tags: RING_TAGS, dimensions: [1257, 1015], tagged: taggedPixel(RING_TAGS, 1015, RING), like, host: { content: await json('src/objects/m57/source/content/object.json'), text: await json('src/objects/m57/text.json') }, checked: '2026-10-04' }); + const file = (path: string) => JSON.parse(String(made.files.get(path))) as Record; + assert.deepEqual(file(`${at}/source/recipe.json`), shipped); + assert.deepEqual(file(`${at}/source/manifest.json`), await json(`${at}/source/manifest.json`)); + assert.deepEqual(file('src/objects/m57/source/content/object.json'), await json('src/objects/m57/source/content/object.json')); + assert.deepEqual(made.carried, []); +}); + +/** A checkout holding one page and the layer bank it shows, and an archive that serves one picture's page and JPEG. */ +async function checkout() { + const root = await mkdtemp(join(tmpdir(), 'cssearth-pictures-')), put = async (path: string, value: unknown) => { await mkdir(dirname(join(root, path)), { recursive: true }); await writeFile(join(root, path), typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`); }; + const like = 'src/objects/nebula-layers'; + await put(`${like}/source/recipe.json`, { schema: 'cssearth-image-layer-recipe@1', id: 'nebula-layers', source: { path: 'starless.jpg' }, observation: {}, target: { centerRaDeg: 10, centerDecDeg: 20, distancePc: 1000 }, + geometry: { kind: 'inclined-disk', inclinationDeg: 1, unit: 'pc', shape: { source: 'publication-a', basis: 'The Hubble basis.', ring: { semiMajorArcsec: 1, expansionKmS: [1, 2, 3] }, old: true } }, bake: { maxFacePixels: 1024, flat: true }, provenance: { path: 'provenance.json' } }); + await put(`${like}/source/manifest.json`, { schema: 'cssearth-volume-source-manifest@2', pathBase: 'repository', inputs: [{ id: 'optical', path: `${like}/source/starless.jpg`, generator: 'a script' }, { id: 'speeds', path: `${like}/source/speeds.dat`, origin: 'https://example.test/speeds.dat' }], + documents: [{ id: 'recipe', path: `${like}/source/recipe.json` }], generatedIntermediates: [] }); + await put(`${like}/source/presentation.json`, { schema: 'cssearth-volume-presentation-source@2', objectId: 'nebula-layers', name: 'A Nebula', datasets: [{ id: 'optical' }] }); + await put(`${like}/source/provenance.json`, { displayModel: 'On the shell\'s two walls.' }); + await put(`${like}/source/speeds.dat`, '1 2 3\n'); + await put('.gitignore', `node_modules\n${like}/source/speeds.dat\noutput\n`); + await put('src/objects/nebula/source/content/object.json', { datasets: { defaultDataset: 'optical', controls: [{ id: 'optical', volume: { objectId: 'nebula-layers', datasetId: 'optical', surface: 'optical' } }] } }); + await put('src/objects/nebula/text.json', { datasets: { optical: { title: 'Hubble' } } }); + await put('src/sources/publication-a.json', { id: 'publication-a' }); + // 0.36 arcsec a pixel, north up, the tags' reference at the page's place: a saturated star stands 2.5 px right of where they put it. + const tags: SkyTags = { scaleDeg: 0.0001, rotationDeg: 0, reference: [10, 20], referencePixel: [20.5, 15.5] }; + const jpeg = Buffer.concat([await sharp(Buffer.from(sourceAt(40, 30, [22, 15], 200, 1.5)), { raw: { width: 40, height: 30, channels: 3 } }).jpeg({ quality: 100, chromaSubsampling: '4:4:4' }).toBuffer(), Buffer.from(xmp(tags, [40, 30]), 'latin1')]); + const asked: string[] = [], archive: Archive = { text: async url => { asked.push(url); if (url !== WEBB) throw new Error(`no ${url}`); return WEBB_PAGE; }, bytes: async url => { asked.push(url); return jpeg; }, exists: async () => true }; + return { root, archive, asked, context: { root, archive, progress: () => {} }, read: async (path: string) => readFile(join(root, path), 'utf8'), json: async (path: string) => JSON.parse(await readFile(join(root, path), 'utf8')) as Record, put }; +} + +test('a draft names the page\'s bank and the star, a run writes the bank and the page\'s dataset, and a second run keeps what a person wrote', async () => { + const { root, context, asked, read, json, put } = await checkout(); + try { + const draft = await draftsFromEsa([`nebula=${WEBB}`, 'nowhere'], context), [drafted] = draft.pictures as Record[]; + assert.deepEqual({ ...drafted, dataset: { ...drafted!.dataset, title: '', summary: '', description: '' } }, { host: 'nebula', image: WEBB, bank: 'nebula-miri-layers', like: 'nebula-layers', + dataset: { id: 'mid-infrared', label: 'Webb · mid infrared', title: '', summary: '', description: '' }, colors: ['7.7 µm', '18 µm'], star: [22, 15], starFound: 'the largest patch of saturated pixels within 1 arcsec of where the tags put it, 9 px', geometry: {}, sources: [], ledger: [] }); + assert.match(draft.report[0]!, /weic0000a on nebula, like nebula-layers: A nebula \(MIRI image\); 40 x 30 px, MIRI at 7\.7 and 18 µm\. The star: the largest patch of saturated pixels within 1 arcsec of where the tags put it, 9 px, at 22\.0, 15\.0, 0\.92 arcsec from where the tags put the page's place/u); + assert.match(draft.report[1]!, /nowhere: not drafted: nowhere: name a page and its picture as HOST=/u); + await put('spec.json', draft); + await assert.rejects(runPictures(join(root, 'spec.json'), context), /pictures\[0\]\.dataset\.title is still to write/u); + // A person writes the sentences and what this picture's light takes on the like bank's walls. + const entry = { ...drafted, dataset: { ...drafted!.dataset, title: 'A Nebula in the mid infrared', summary: 'One sentence.', description: 'What it shows.' }, geometry: { shape: { basis: 'The mid-infrared basis.', ring: { expansionKmS: [4, 5, 6] }, old: null } }, + sources: [{ catalogueId: 'publication-a', url: 'https://example.test/a', label: 'A et al. (2020)', locator: 'Sect. 2', checked: '2026-01-02' }], ledger: [{ id: 'speeds', subject: 'Speeds', status: 'included', finding: 'Chosen.', evidence: ['https://example.test/a'] }] }; + await put('spec.json', { pictures: [entry] }); + const [first] = await runPictures(join(root, 'spec.json'), context), at = 'src/objects/nebula-miri-layers'; + assert.deepEqual({ ...first, files: 0 }, { id: 'nebula-miri-layers', host: 'nebula', dataset: 'mid-infrared', files: 0, todo: [`write ${at}/README.md`, 'name the nebula-miri-layers bank in src/objects/nebula/README.md'] }); + assert.equal(asked.filter(url => url.endsWith('.jpg')).length, 1, 'the picture is read once, for the draft'); + const recipe = await json(`${at}/source/recipe.json`); + assert.deepEqual(recipe.source, { path: 'source.jpg', dimensions: [40, 30], originalDimensions: [40, 30], publisherUrl: WEBB, downloadUrl: 'https://cdn.esawebb.org/archives/images/large/weic0000a.jpg', credit: 'ESA/Webb, NASA, CSA, A. Person', license: 'CC-BY-4.0' }); + assert.deepEqual(recipe.geometry.shape, { source: 'publication-a', basis: 'The mid-infrared basis.', ring: { semiMajorArcsec: 1, expansionKmS: [4, 5, 6] } }); + assert.deepEqual([recipe.id, recipe.target.distancePc, recipe.bake, recipe.observation.northClockwiseDeg, recipe.geometry.kind], ['nebula-miri-layers', 1000, { maxFacePixels: 40, flat: true }, 0, 'inclined-disk']); + // The star, 2.5 px right of the frame's middle and half a pixel below it, stands at the page's place: with north up and east to the left, the frame's centre is that far east and north of it. + assert.ok(Math.abs(recipe.observation.centerRaDeg - (10 + 2.5 * 0.0001 / Math.cos(20 * Math.PI / 180))) < 2e-7 && Math.abs(recipe.observation.centerDecDeg - (20 + 0.5 * 0.0001)) < 2e-7, JSON.stringify(recipe.observation)); + const manifest = await json(`${at}/source/manifest.json`); + assert.deepEqual(manifest.inputs.map((input: { path: string }) => input.path), [`${at}/source/source.jpg`, `${at}/source/speeds.dat`]); + assert.deepEqual([manifest.inputs[0].sourceBinding.references[0].catalogueId, manifest.inputs[0].capture.attributions[0].facilityId, manifest.documents[0].path], ['esawebb-weic0000a', 'webb', `${at}/source/recipe.json`]); + assert.equal(await read(`${at}/source/speeds.dat`), '1 2 3\n'); + assert.deepEqual((await read('.gitignore')).split('\n').slice(1, 4), ['src/objects/nebula-layers/source/speeds.dat', `# The same file beside the nebula-miri-layers bank; restored from the same origin (${at}/source/manifest.json).`, `${at}/source/speeds.dat`]); + assert.deepEqual((await json(`${at}/object.json`)).properties, { preparation: { source: 'source/recipe.json' }, host: 'nebula' }); + const presentation = await json(`${at}/source/presentation.json`); + assert.deepEqual([presentation.objectId, presentation.name, presentation.datasets[0].id, presentation.datasets[0].label, presentation.datasets[0].facts[0].value], ['nebula-miri-layers', 'A Nebula', 'optical', 'Webb · mid infrared', '7.7 and 18 µm']); + const provenance = await json(`${at}/source/provenance.json`); + assert.equal(provenance.displayModel, 'On the shell\'s two walls.'); + assert.match(provenance.registration.method, /the star in the picture, the largest patch of saturated pixels within 1 arcsec of where the tags put it, 9 px, pixel 22, 15, is set at the recipe's target, so the frame's centre is [\d.]+°, [\d.]+°\. The tags alone put the star 0\.92 arcsec from where the picture shows it\./u); + assert.deepEqual((await json(`${at}/investigations.json`)).entries.map((one: { id: string }) => one.id), ['weic0000a', 'speeds']); + assert.equal((await json('src/sources/esawebb-weic0000a.json')).statements[0].text, 'ESA/Webb, NASA, CSA, A. Person'); + const page = (await json('src/objects/nebula/source/content/object.json')).datasets.controls; + assert.deepEqual(page[1], { id: 'mid-infrared', label: 'Webb · mid infrared', thumbnail: 'nebula-dataset-mid-infrared.webp', volume: { objectId: 'nebula-miri-layers', datasetId: 'optical', surface: 'mid-infrared' }, + source: { id: 'nebula-miri-layers-optical', path: '../../nebula-miri-layers/source/manifest.json', url: WEBB }, falseColor: true }); + const text = (await json('src/objects/nebula/text.json')).datasets; + assert.deepEqual([Object.keys(text), text['mid-infrared'].title, text['mid-infrared'].sources.map((source: { catalogueId: string }) => source.catalogueId)], [['optical', 'mid-infrared'], 'A Nebula in the mid infrared', ['esawebb-weic0000a', 'publication-a']]); + assert.deepEqual([text['mid-infrared'].sources[0].checked, text['mid-infrared'].sources[1].checked], [new Date().toISOString().slice(0, 10), '2026-01-02']); + const readme = await read(`${at}/README.md`); + assert.ok(readme.startsWith('# A Nebula, Webb · mid infrared\n') && readme.includes(TODO) && readme.includes('| [A et al. (2020)](https://example.test/a) | [Record](../../sources/publication-a.json). Sect. 2 |'), readme); + // Run again with a value changed: the records follow the spec, the README a person wrote stays, and the page holds one such dataset. + await put(`${at}/README.md`, '# Written by a person\n'); await put('src/objects/nebula/README.md', 'Its datasets show [one](../nebula-layers/README.md) and [another](../nebula-miri-layers/README.md).\n'); await put('spec.json', { pictures: [{ ...entry, facePixels: 32 }] }); + const dated = await json('src/objects/nebula/text.json'); dated.datasets['mid-infrared'].sources[0].checked = '2026-01-01'; await put('src/objects/nebula/text.json', dated); + const [second] = await runPictures(join(root, 'spec.json'), context); + assert.deepEqual([second!.todo, (await json(`${at}/source/recipe.json`)).bake.maxFacePixels, await read(`${at}/README.md`), (await json('src/objects/nebula/source/content/object.json')).datasets.controls.length, (await read('.gitignore')).split('\n').length], + [[], 32, '# Written by a person\n', 2, 6]); + assert.equal((await json('src/objects/nebula/text.json')).datasets['mid-infrared'].sources[0].checked, '2026-01-01', 'a source cited in the same words keeps the day it was read'); + // A bank that shows another picture is never rewritten; a source with no record and no paper link is refused by its path. + await put(`${at}/source/recipe.json`, { ...recipe, source: { ...recipe.source, publisherUrl: 'https://esawebb.org/images/other/' } }); + assert.match((await runPictures(join(root, 'spec.json'), context))[0]!.failed!, /src\/objects\/nebula-miri-layers already exists and shows another picture/u); + await put('spec.json', { pictures: [{ ...entry, bank: 'nebula-other-layers', sources: [{ catalogueId: 'publication-b', url: 'https://example.test/b', label: 'B', locator: 'p. 1' }] }] }); + assert.match((await runPictures(join(root, 'spec.json'), context))[0]!.failed!, /nebula-other-layers: src\/sources\/publication-b\.json does not exist, and https:\/\/example\.test\/b is not an arXiv or DOI link/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/telescope-cli/src/new-object/pictures/registration.mts b/packages/telescope-cli/src/new-object/pictures/registration.mts new file mode 100644 index 00000000000..d883d5f6dfe --- /dev/null +++ b/packages/telescope-cli/src/new-object/pictures/registration.mts @@ -0,0 +1,73 @@ +/** Where a published picture lies on the sky and in its layer bank's recipe. The file's sky tags give its scale and the + * direction of north; one pixel set at one place on the sky gives its position: the star the picture shows, or, where it + * shows none, the pixel the tags themselves give that place. The recipe's plane is the picture's own tangent plane. */ +import type { SkyTags } from './esa-image.mts'; + +const RAD = Math.PI / 180; +/** The picture fades out over the outer tenth of the largest circle about the star that the frame holds. */ +export const RIM_FROM = 0.9; +/** A star's saturated patch starts within this many arcseconds of where the tags put it; a peak is looked for this far. */ +const PATCH_WITHIN_ARCSEC = 1, PEAK_WITHIN_ARCSEC = 3, SATURATED = 250; + +export interface Place { readonly centerRaDeg: number; readonly centerDecDeg: number; readonly distancePc: number } +export interface Registration { + /** The recipe's `observation`. */ + readonly observation: { readonly centerRaDeg: number; readonly centerDecDeg: number; readonly fieldOfViewDeg: readonly [number, number]; readonly northClockwiseDeg: number }; + /** The recipe's plane and rim: the tangent plane at the frame's centre, and the largest circle about the star in the frame. */ + readonly plane: { readonly inclinationDeg: number; readonly lineOfNodesPaDeg: number; readonly thicknessKpc: number; readonly supportRadiusKpc: number; readonly supportTaperFraction: number }; + readonly pixelArcsec: number; readonly starFromCentreArcsec: number; readonly circleArcsec: number; readonly widthPc: number; +} + +/** The pixel, counted from the picture's top left, where its sky tags put a place on the sky. The tags count pixels from + * the bottom left with the first pixel's middle at 1, as FITS does. */ +export function taggedPixel(tags: SkyTags, height: number, place: Pick): [number, number] { + const rot = tags.rotationDeg * RAD, east = ((place.centerRaDeg - tags.reference[0] + 540) % 360 - 180) * Math.cos(place.centerDecDeg * RAD) / tags.scaleDeg, north = (place.centerDecDeg - tags.reference[1]) / tags.scaleDeg; + return [tags.referencePixel[0] - 1 - east * Math.cos(rot) - north * Math.sin(rot), height - (tags.referencePixel[1] - east * Math.sin(rot) + north * Math.cos(rot))]; +} + +/** The star a picture shows near `near`, a pixel: the largest patch of saturated pixels that starts within 1 arcsec of it, + * at the patch's middle; or, where nothing there is saturated, the peak within 3 arcsec that stands highest above its + * surroundings, at the middle of its light. Undefined when the picture shows neither. `rgb` is three bytes a pixel. */ +export function locateStar(rgb: Uint8Array, width: number, height: number, pixelArcsec: number, near: readonly [number, number]): { readonly pixel: [number, number]; readonly found: string } | undefined { + const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < width && y < height, saturated = (p: number) => rgb[3 * p]! >= SATURATED && rgb[3 * p + 1]! >= SATURATED && rgb[3 * p + 2]! >= SATURATED; + const within = PATCH_WITHIN_ARCSEC / pixelArcsec, seen = new Uint8Array(width * height); let patch = { pixels: 0, x: 0, y: 0 }; + for (let y = Math.max(0, Math.round(near[1] - within)); y <= Math.min(height - 1, near[1] + within); y++) for (let x = Math.max(0, Math.round(near[0] - within)); x <= Math.min(width - 1, near[0] + within); x++) { + const start = y * width + x; if (seen[start] || !saturated(start) || Math.hypot(x - near[0], y - near[1]) > within) continue; + let pixels = 0, sumX = 0, sumY = 0; const stack = [start]; seen[start] = 1; + for (let p = stack.pop(); p !== undefined; p = stack.pop()) { const px = p % width, py = (p - px) / width; pixels++; sumX += px; sumY += py; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { const q = (py + dy) * width + px + dx; if (!inside(px + dx, py + dy) || seen[q] || !saturated(q)) continue; seen[q] = 1; stack.push(q); } } + if (pixels > patch.pixels) patch = { pixels, x: sumX / pixels, y: sumY / pixels }; + } + if (patch.pixels) return { pixel: [patch.x, patch.y], found: `the largest patch of saturated pixels within ${PATCH_WITHIN_ARCSEC} arcsec of where the tags put it, ${patch.pixels} px` }; + // A peak tops the 5 by 5 pixels about it; it is weighed by how far it stands above the mean of the 15 by 15 about it. A saturated + // one is a star already passed over: farther than the first rule looks. + const light = (x: number, y: number) => { const p = 3 * (y * width + x); return (rgb[p]! + rgb[p + 1]! + rgb[p + 2]!) / 3; }, reach = Math.round(PEAK_WITHIN_ARCSEC / pixelArcsec); let peak = { above: 10, x: -1, y: -1 }; + for (let y = Math.round(near[1]) - reach; y <= near[1] + reach; y++) for (let x = Math.round(near[0]) - reach; x <= near[0] + reach; x++) { + if (!inside(x - 7, y - 7) || !inside(x + 7, y + 7) || saturated(y * width + x)) continue; + const here = light(x, y); let top = true, sum = 0; + for (let dy = -7; dy <= 7 && top; dy++) for (let dx = -7; dx <= 7; dx++) { const value = light(x + dx, y + dy); sum += value; if (Math.abs(dx) <= 2 && Math.abs(dy) <= 2 && value > here) { top = false; break; } } + if (top && here - sum / 225 > peak.above) peak = { above: here - sum / 225, x, y }; + } + if (peak.x < 0) return undefined; + let sumX = 0, sumY = 0, weights = 0; const floor = light(peak.x, peak.y) - 0.6 * peak.above; + for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) { const weight = Math.max(0, light(peak.x + dx, peak.y + dy) - floor); sumX += weight * (peak.x + dx); sumY += weight * (peak.y + dy); weights += weight; } + return { pixel: [sumX / weights, sumY / weights], found: `the peak within ${PEAK_WITHIN_ARCSEC} arcsec of where the tags put it that stands highest above its surroundings, by ${peak.above.toFixed(0)} of 255` }; +} + +/** A picture `width` by `height` pixels whose pixel `star` stands at `place`: the recipe's observation, plane and rim. */ +export function registerPicture(tags: SkyTags, [width, height]: readonly [number, number], star: readonly [number, number], place: Place): Registration { + // The picture fades out in a circle about that pixel: a frame that does not hold it is a picture of somewhere else in the object. + if (!(star[0] >= 1 && star[1] >= 1 && star[0] <= width - 2 && star[1] <= height - 2)) throw new RangeError(`The picture, ${width} x ${height} px, does not hold the place it is to stand at: pixel ${star.map(value => value.toFixed(1)).join(', ')}.`); + const rot = tags.rotationDeg * RAD, north = [-Math.sin(rot), -Math.cos(rot)] as const, east = [-Math.cos(rot), Math.sin(rot)] as const, dx = star[0] - (width - 1) / 2, dy = star[1] - (height - 1) / 2; + const pixelArcsec = tags.scaleDeg * 3600, starEast = pixelArcsec * (dx * east[0] + dy * east[1]), starNorth = pixelArcsec * (dx * north[0] + dy * north[1]); + const fieldOfViewDeg = [Number((width * tags.scaleDeg).toPrecision(7)), Number((height * tags.scaleDeg).toPrecision(7))] as const; + const kpcPerDeg = place.distancePc / 1e3 * RAD, pcPerArcsec = kpcPerDeg * 1000 / 3600, widthPc = fieldOfViewDeg[0] * kpcPerDeg * 1000, starFromCentreArcsec = Math.hypot(starEast, starNorth); + const circleArcsec = Math.floor(10 * pixelArcsec * Math.min(star[0], width - 1 - star[0], star[1], height - 1 - star[1])) / 10; + return { + observation: { centerRaDeg: Number((place.centerRaDeg - starEast / 3600 / Math.cos(place.centerDecDeg * RAD)).toFixed(7)), centerDecDeg: Number((place.centerDecDeg - starNorth / 3600).toFixed(7)), fieldOfViewDeg, northClockwiseDeg: Number((-tags.rotationDeg).toFixed(3)) }, + // The plane faces the Sun along the sight line through the frame's centre: its tilt at the star is the star's distance from that centre. + plane: { inclinationDeg: Number(Math.max(starFromCentreArcsec / 3600, 0.001).toPrecision(4)), lineOfNodesPaDeg: Number((((Math.atan2(-starEast, -starNorth) / RAD + 90) % 360 + 360) % 360).toFixed(3)), + thicknessKpc: Number((widthPc / 2e6).toPrecision(3)), supportRadiusKpc: Number((circleArcsec * pcPerArcsec / 1000).toPrecision(5)), supportTaperFraction: RIM_FROM }, + pixelArcsec, starFromCentreArcsec, circleArcsec, widthPc, + }; +} diff --git a/src/objects/local-group-structures/README.md b/src/objects/local-group-structures/README.md index 5fb4d3d3c4f..68242f171fb 100644 --- a/src/objects/local-group-structures/README.md +++ b/src/objects/local-group-structures/README.md @@ -1,9 +1,8 @@ # The Local Group's structures Dots for the structures of the [Local Group](../local-group/README.md) that are large enough to see from outside a -galaxy: the globular cluster systems of M31 and the Milky Way, the star clusters of the Magellanic Clouds and the -Sagittarius stream. It has no page. The world draws it once the camera is outside the Milky Way, like the -[Nearby Universe's galaxies](../nearby-universe-galaxies/README.md). It holds 3,959 dots in 33 KB. +galaxy: the globular cluster systems of M31 and the Milky Way and the Sagittarius stream. It has no page. The world draws it once the camera is outside the Milky Way, like the +[Nearby Universe's galaxies](../nearby-universe-galaxies/README.md). It holds 1,245 dots in 13 KB. ## Sources @@ -11,48 +10,47 @@ Sagittarius stream. It has no page. The world draws it once the camera is outsid | --- | --- | --- | | 441 | [Caldwell & Romanowsky (2016)](https://cdsarc.cds.unistra.fr/viz-bin/cat/J/ApJ/824/42) | [Record](../../sources/caldwell-2016-m31-globular-clusters.json). The J2000 positions of M31's globular clusters. Their depths follow the published profile as [M31's own bank](../m31-globular-clusters/README.md) draws them. | | 165 | [Baumgardt & Vasiliev (2021)](https://doi.org/10.1093/mnras/stab1474) | [Record](../../sources/baumgardt-vasiliev-2021-gc-distances.json). The positions and distances of the Milky Way's globular clusters, the table the [galaxy's own bank](../milky-way-volume/README.md) reads. | -| 2,270 and 444 | [Bica et al. (2008)](https://cdsarc.cds.unistra.fr/viz-bin/cat/J/MNRAS/389/678) | [Record](../../sources/bica-2008-magellanic-extended-objects.json). Table 3: the J2000 positions of the ordinary star clusters (type C) of the Large and the Small Magellanic Cloud. Sect. 3 gives the split of the sky: 4 h < RA < 6 h 40 min is the Large Cloud, 23 h 40 min < RA < 1 h 20 min the Small one. | -| | [Pietrzyński et al. (2019)](https://ui.adsabs.harvard.edu/abs/2019Natur.567..200P) | [Record](../../sources/publication-pietrzynski2019natur-567-200p.json). The Large Cloud's distance, 49.59 kpc. | -| | [Graczyk et al. (2020)](https://arxiv.org/abs/2010.08754) | [Record](../../sources/publication-graczyk2020apj-904-13g.json). The Small Cloud's distance, 62.44 kpc. | | 639 | [Ramos et al. (2022)](https://cdsarc.cds.unistra.fr/viz-bin/cat/J/A+A/666/A64) | [Record](../../sources/ramos-2022-sagittarius-stream.json). Table C1: Gaia EDR3 stars with the probability that each belongs to the Sagittarius stream (`ProbSGR`), and a photometric distance for the RR Lyrae (`DistWGBPRP`). | ## Processing -1. `packages/bake/cli/prepare-catalogue-points.mts` prepares five banks, one per [recipe](source/). M31's and the Milky - Way's read the tables their galaxies' own banks read. The Magellanic and stream tables are CDS TAP queries written in - each recipe; the downloaded files are not tracked. +1. `packages/bake/cli/prepare-catalogue-points.mts` prepares three banks, one per [recipe](source/). M31's and the Milky + Way's read the tables their galaxies' own banks read. The stream's table is a CDS TAP query written in its recipe; the + downloaded file is not tracked. 2. M31's clusters take the depths of M31's own bank: the recipe copies its profile and seeds the draws with that bank's id (`frame.seed`, [catalogue-spheroid.ts](../../../packages/bake/src/volume/node/catalogue-spheroid.ts)), so a cluster is at the same place in both: every dot here is within 0.1 pc of its twin in that bank (decoded and compared on 2026-10-04). The Milky Way's clusters are at their measured distances. -3. Every Magellanic cluster sits at its Cloud's distance. -4. The stream keeps the 2,553 RR Lyrae with a distance above zero and `ProbSGR` above 0.9, each at its own distance. +3. The stream keeps the 2,553 RR Lyrae with a distance above zero and `ProbSGR` above 0.9, each at its own distance. They lie between 7 and 92 kpc from the Sun, half of them within 29 kpc. -5. `merge-catalogue-points.mts` joins the banks ([merge](source/field/merge.json)) and keeps one stream star in four, in +4. `merge-catalogue-points.mts` joins the banks ([merge](source/field/merge.json)) and keeps one stream star in four, in catalogue order. `stack-catalogue-points.mts` writes the one file the world draws ([stack](source/dots/stack.json)). -Values chosen here, not measured: the 4,000 dot ceiling and the one-in-four thinning that keeps the field under it; the -0.9 probability threshold; the colors (orange for globular clusters, blue-white for the Magellanic clusters, warm white -for the stream, each the color the Milky Way's dots of that kind already have); the dot sizes. +Values chosen here, not measured: the one-in-four thinning of the stream; the 0.9 probability threshold; the colors +(orange for globular clusters, warm white for the stream, each the color the Milky Way's dots of that kind already +have); the dot sizes. ## Evidence -![The Local Group page as it opens, M31 pulled back, and the Milky Way pulled back](evidence/2026-10-04/app-views.jpg) +![The Local Group page as it opens, M31 pulled back, and the Milky Way pulled back](evidence/2026-10-05/app-views.jpg) -The app on 2026-10-04. Left: the Local Group page as it opens, 7.3 million light-years from the Sun, with the Milky Way +The app on 2026-10-05. Left: the Local Group page as it opens, 7.3 million light-years from the Sun, with the Milky Way at the left and M31 at the lower right. Middle: M31 pulled back one wheel step, with the Milky Way and the Magellanic Clouds beyond it. Right: the Milky Way pulled back one wheel step, 289,000 light-years from the Sun, with its globular -clusters in orange, the Sagittarius stream to the right of the centre and the Large Magellanic Cloud's clusters at the -top. +clusters in orange and the Sagittarius stream to the right of the centre. + +![The Magellanic Clouds' markers with the field's former clusters, and without them](evidence/2026-10-05/clouds.jpg) + +The two Clouds from ten wheel steps out of the Milky Way's page, in headless Chromium on 2026-10-05. Left: with the 2,714 +Magellanic clusters the field drew until that day, which covered the Clouds' markers. Right: the field as it is now. ## Known problems - The field is drawn only from outside the Milky Way. Inside it the galaxy draws its own clusters and stars. -- No Magellanic cluster has a distance of its own here, so each Cloud is a flat sheet of clusters at one distance. The - catalogue's 55 ordinary clusters of the Bridge between the Clouds are not drawn. +- The Magellanic Clouds' star clusters are not drawn. Until 2026-10-05 the field held the 2,714 ordinary clusters of + Bica et al. (2008), every one at its Cloud's distance: at the Local Group's scale they covered the two Clouds' markers. - M31's depths are drawn from a profile, not measured; see [M31's bank](../m31-globular-clusters/README.md). - A stream star's distance is good to about 9%, which stretches every clump along the line to the Sun. Only RR Lyrae have a distance in the table, so the stream's other stars are not drawn. -- The Bica et al. catalogue lists the clusters known in 2008. [Inputs](source/manifest.json) · [Delivered files](inventory.json) diff --git a/src/objects/local-group-structures/evidence/2026-10-04/app-views.jpg b/src/objects/local-group-structures/evidence/2026-10-04/app-views.jpg deleted file mode 100644 index 0a89b82b031..00000000000 Binary files a/src/objects/local-group-structures/evidence/2026-10-04/app-views.jpg and /dev/null differ diff --git a/src/objects/local-group-structures/evidence/2026-10-05/app-views.jpg b/src/objects/local-group-structures/evidence/2026-10-05/app-views.jpg new file mode 100644 index 00000000000..d69c5e91f79 Binary files /dev/null and b/src/objects/local-group-structures/evidence/2026-10-05/app-views.jpg differ diff --git a/src/objects/local-group-structures/evidence/2026-10-05/clouds.jpg b/src/objects/local-group-structures/evidence/2026-10-05/clouds.jpg new file mode 100644 index 00000000000..21ddd81f397 Binary files /dev/null and b/src/objects/local-group-structures/evidence/2026-10-05/clouds.jpg differ diff --git a/src/objects/local-group-structures/inventory.json b/src/objects/local-group-structures/inventory.json index 90a51d0e4c0..3117e511d80 100644 --- a/src/objects/local-group-structures/inventory.json +++ b/src/objects/local-group-structures/inventory.json @@ -4,8 +4,8 @@ { "location": "prepared", "filename": "dots.bin", - "bytes": 33582, - "sha256": "55890fc29d241187597fa66552f85c1e7189dab87b3608977e715b8b23503860" + "bytes": 12588, + "sha256": "73cb414d2ec6e592882fc8a36e3ce4cb40399d64d6eef3b19c1f31338604134e" } ] } diff --git a/src/objects/local-group-structures/source/dots/stack.json b/src/objects/local-group-structures/source/dots/stack.json index f2aa4c5307c..7644c12c363 100644 --- a/src/objects/local-group-structures/source/dots/stack.json +++ b/src/objects/local-group-structures/source/dots/stack.json @@ -2,7 +2,7 @@ "schema": "cssearth-catalogue-points-stack@1", "id": "dots", "published": true, - "meaning": "The Local Group's structures as dots: the globular cluster systems of M31 and the Milky Way, the star clusters of the Magellanic Clouds and the Sagittarius stream, drawn once the camera is outside the Milky Way.", + "meaning": "The Local Group's structures as dots: the globular cluster systems of M31 and the Milky Way and the Sagittarius stream, drawn once the camera is outside the Milky Way.", "levels": [ { "bank": "field", diff --git a/src/objects/local-group-structures/source/field/merge.json b/src/objects/local-group-structures/source/field/merge.json index 3955d88946e..7a936243044 100644 --- a/src/objects/local-group-structures/source/field/merge.json +++ b/src/objects/local-group-structures/source/field/merge.json @@ -1,16 +1,14 @@ { "schema": "cssearth-catalogue-points-merge@1", "id": "field", - "meaning": "The Local Group's structures that are large enough to see from outside the Milky Way: the globular cluster systems of M31 and the Milky Way, the star clusters of the Magellanic Clouds and the Sagittarius stream.", + "meaning": "The Local Group's structures that are large enough to see from outside the Milky Way: the globular cluster systems of M31 and the Milky Way and the Sagittarius stream.", "banks": [ "m31-globular-clusters", "milky-way-globular-clusters", - "lmc-clusters", - "smc-clusters", { "bank": "sagittarius-stream", "keepEvery": 4, - "basis": "Presentation choice: the field holds at most 4,000 dots, so of the 2,553 stream stars it keeps one in 4, in catalogue order (Gaia source ids, which follow the sky), after every cluster." + "basis": "Presentation choice: of the 2,553 stream stars the field keeps one in 4, in catalogue order (Gaia source ids, which follow the sky), after every cluster." } ] } diff --git a/src/objects/local-group-structures/source/lmc-clusters/points.json b/src/objects/local-group-structures/source/lmc-clusters/points.json deleted file mode 100644 index ee04458f09c..00000000000 --- a/src/objects/local-group-structures/source/lmc-clusters/points.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "schema": "cssearth-catalogue-points-source@1", - "id": "lmc-clusters", - "table": { - "path": "table.csv", - "origin": "https://tapvizier.cds.unistra.fr/TAPVizieR/tap/sync?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&MAXREC=100000&QUERY=SELECT+recno%2C+RAJ2000%2C+DEJ2000%2C+49590.6727505+AS+distpc+FROM+%22J%2FMNRAS%2F389%2F678%2Ftable3%22+WHERE+Type+%3D+'C'+AND+RAJ2000+BETWEEN+60+AND+100+ORDER+BY+recno", - "bytes": 123991, - "format": "csv", - "columns": { - "name": 1, - "raDeg": 2, - "decDeg": 3, - "distance": 4 - }, - "distanceUnit": "pc", - "note": "The query keeps the ordinary clusters (type C) the catalogue's own rule gives to the Large Magellanic Cloud (4 h < RA < 6 h 40 min, Bica et al. 2008, Sect. 3) and writes the Cloud's distance, Pietrzyński et al.'s (2019) 49.59 kpc, into each row. No cluster's own distance is measured: every one sits at its Cloud's distance, so the Cloud's depth is not drawn.", - "distanceSource": "publication-pietrzynski2019natur-567-200p" - }, - "filters": [], - "frame": { - "input": "icrs", - "output": "sun-icrf", - "epochJdTt": 2461286.5, - "converter": "Astropy SkyCoord ICRS, heliocentric Cartesian" - }, - "source": "bica-2008-magellanic-extended-objects", - "meaning": "Star clusters of the Large Magellanic Cloud from Bica et al.'s (2008) catalogue of extended objects: one dot per ordinary cluster at its catalogue sky position, at the Cloud's distance.", - "appearance": { - "colorCss": "#9fd4ff", - "radiusPx": 0.75, - "opacity": 1, - "note": "Presentation only: the Milky Way open-cluster layer's blue-white and size." - } -} diff --git a/src/objects/local-group-structures/source/manifest.json b/src/objects/local-group-structures/source/manifest.json index d2e32f0e969..e6abe168202 100644 --- a/src/objects/local-group-structures/source/manifest.json +++ b/src/objects/local-group-structures/source/manifest.json @@ -54,58 +54,6 @@ "dependencies": [], "license": "Original catalogue terms apply; cite Baumgardt & Vasiliev (2021)." }, - { - "id": "lmc-clusters-table", - "path": "src/objects/local-group-structures/source/lmc-clusters/table.csv", - "origin": "https://tapvizier.cds.unistra.fr/TAPVizieR/tap/sync?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&MAXREC=100000&QUERY=SELECT+recno%2C+RAJ2000%2C+DEJ2000%2C+49590.6727505+AS+distpc+FROM+%22J%2FMNRAS%2F389%2F678%2Ftable3%22+WHERE+Type+%3D+'C'+AND+RAJ2000+BETWEEN+60+AND+100+ORDER+BY+recno", - "sourceUrl": "https://cdsarc.cds.unistra.fr/viz-bin/cat/J/MNRAS/389/678", - "title": "A general catalogue of extended objects in the Magellanic System, table3: the Large Magellanic Cloud's ordinary clusters", - "credit": "E. Bica, C. Bonatto, C. M. Dutra, J. F. C. Santos Jr; MNRAS 389, 678; CDS J/MNRAS/389/678", - "acquisition": "Rows selected by the CDS TAP query in the origin, downloaded unchanged; read by packages/bake/cli/prepare-catalogue-points.mts through lmc-clusters/points.json.", - "sourceBinding": { - "kind": "catalogued", - "references": [ - { - "catalogueId": "bica-2008-magellanic-extended-objects", - "role": "material", - "evidence": "https://cdsarc.cds.unistra.fr/ftp/J/MNRAS/389/678/ReadMe" - }, - { - "catalogueId": "publication-pietrzynski2019natur-567-200p", - "role": "reference", - "evidence": "src/objects/local-group-structures/source/lmc-clusters/points.json#/table/note" - } - ] - }, - "dependencies": [], - "license": "Original catalogue terms apply; cite the authors and CDS/VizieR." - }, - { - "id": "smc-clusters-table", - "path": "src/objects/local-group-structures/source/smc-clusters/table.csv", - "origin": "https://tapvizier.cds.unistra.fr/TAPVizieR/tap/sync?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&MAXREC=100000&QUERY=SELECT+recno%2C+RAJ2000%2C+DEJ2000%2C+62440+AS+distpc+FROM+%22J%2FMNRAS%2F389%2F678%2Ftable3%22+WHERE+Type+%3D+'C'+AND+(RAJ2000+%3E+355+OR+RAJ2000+%3C+20)+ORDER+BY+recno", - "sourceUrl": "https://cdsarc.cds.unistra.fr/viz-bin/cat/J/MNRAS/389/678", - "title": "A general catalogue of extended objects in the Magellanic System, table3: the Small Magellanic Cloud's ordinary clusters", - "credit": "E. Bica, C. Bonatto, C. M. Dutra, J. F. C. Santos Jr; MNRAS 389, 678; CDS J/MNRAS/389/678", - "acquisition": "Rows selected by the CDS TAP query in the origin, downloaded unchanged; read by packages/bake/cli/prepare-catalogue-points.mts through smc-clusters/points.json.", - "sourceBinding": { - "kind": "catalogued", - "references": [ - { - "catalogueId": "bica-2008-magellanic-extended-objects", - "role": "material", - "evidence": "https://cdsarc.cds.unistra.fr/ftp/J/MNRAS/389/678/ReadMe" - }, - { - "catalogueId": "publication-graczyk2020apj-904-13g", - "role": "reference", - "evidence": "src/objects/local-group-structures/source/smc-clusters/points.json#/table/note" - } - ] - }, - "dependencies": [], - "license": "Original catalogue terms apply; cite the authors and CDS/VizieR." - }, { "id": "sagittarius-stream-table", "path": "src/objects/local-group-structures/source/sagittarius-stream/table.csv", @@ -143,20 +91,6 @@ "reason": "Project-authored processing configuration." } }, - { - "path": "src/objects/local-group-structures/source/lmc-clusters/points.json", - "sourceBinding": { - "kind": "local", - "reason": "Project-authored processing configuration." - } - }, - { - "path": "src/objects/local-group-structures/source/smc-clusters/points.json", - "sourceBinding": { - "kind": "local", - "reason": "Project-authored processing configuration." - } - }, { "path": "src/objects/local-group-structures/source/sagittarius-stream/points.json", "sourceBinding": { diff --git a/src/objects/local-group-structures/source/presentation.json b/src/objects/local-group-structures/source/presentation.json index 32c5fb2799f..8f22cbb62e5 100644 --- a/src/objects/local-group-structures/source/presentation.json +++ b/src/objects/local-group-structures/source/presentation.json @@ -42,48 +42,6 @@ "The distances carry the errors the table gives, a few per cent for most clusters." ] }, - { - "id": "lmc-clusters", - "label": "Bica et al. (2008) star clusters of the Large Magellanic Cloud, CDS J/MNRAS/389/678", - "process": "Place each ordinary cluster at its sky position, at the Cloud's distance (Pietrzyński et al.'s (2019) 49.59 kpc).", - "recipe": "source/lmc-clusters/points.json", - "selector": "", - "inputs": [ - "lmc-clusters-table" - ], - "outputs": [ - "prepared/dots.bin" - ], - "interpretation": { - "kind": "catalogue-positions", - "sourceKind": "published-catalogue" - }, - "limitations": [ - "No cluster's own distance is used: the Cloud's depth along the sight line is not drawn.", - "The catalogue's 55 ordinary clusters of the Bridge between the Clouds are not drawn." - ] - }, - { - "id": "smc-clusters", - "label": "Bica et al. (2008) star clusters of the Small Magellanic Cloud, CDS J/MNRAS/389/678", - "process": "Place each ordinary cluster at its sky position, at the Cloud's distance (Graczyk et al.'s (2020) 62.44 kpc).", - "recipe": "source/smc-clusters/points.json", - "selector": "", - "inputs": [ - "smc-clusters-table" - ], - "outputs": [ - "prepared/dots.bin" - ], - "interpretation": { - "kind": "catalogue-positions", - "sourceKind": "published-catalogue" - }, - "limitations": [ - "No cluster's own distance is used: the Cloud's depth along the sight line is not drawn.", - "The catalogue's 55 ordinary clusters of the Bridge between the Clouds are not drawn." - ] - }, { "id": "sagittarius-stream", "label": "Ramos et al. (2022) RR Lyrae of the Sagittarius stream, CDS J/A+A/666/A64", @@ -107,15 +65,13 @@ }, { "id": "field", - "label": "The five banks as one field", - "process": "Join the five banks; of the stream stars keep one in four, in catalogue order, so the field stays under 4,000 dots.", + "label": "The three banks as one field", + "process": "Join the three banks; of the stream stars keep one in four, in catalogue order.", "recipe": "source/field/merge.json", "selector": "", "inputs": [ "m31-clusters", "milky-way-clusters", - "lmc-clusters-table", - "smc-clusters-table", "sagittarius-stream-table" ], "outputs": [ @@ -126,7 +82,7 @@ "sourceKind": "published-catalogue" }, "limitations": [ - "The 4,000 dot ceiling is a presentation choice." + "Keeping one stream star in four is a presentation choice." ] }, { @@ -138,8 +94,6 @@ "inputs": [ "m31-clusters", "milky-way-clusters", - "lmc-clusters-table", - "smc-clusters-table", "sagittarius-stream-table" ], "outputs": [ diff --git a/src/objects/local-group-structures/source/smc-clusters/points.json b/src/objects/local-group-structures/source/smc-clusters/points.json deleted file mode 100644 index 96a44e08d43..00000000000 --- a/src/objects/local-group-structures/source/smc-clusters/points.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "schema": "cssearth-catalogue-points-source@1", - "id": "smc-clusters", - "table": { - "path": "table.csv", - "origin": "https://tapvizier.cds.unistra.fr/TAPVizieR/tap/sync?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&MAXREC=100000&QUERY=SELECT+recno%2C+RAJ2000%2C+DEJ2000%2C+62440+AS+distpc+FROM+%22J%2FMNRAS%2F389%2F678%2Ftable3%22+WHERE+Type+%3D+'C'+AND+(RAJ2000+%3E+355+OR+RAJ2000+%3C+20)+ORDER+BY+recno", - "bytes": 20791, - "format": "csv", - "columns": { - "name": 1, - "raDeg": 2, - "decDeg": 3, - "distance": 4 - }, - "distanceUnit": "pc", - "note": "The query keeps the ordinary clusters (type C) the catalogue's own rule gives to the Small Magellanic Cloud (23 h 40 min < RA < 1 h 20 min, Bica et al. 2008, Sect. 3) and writes the Cloud's distance, Graczyk et al.'s (2020) 62.44 kpc, into each row. No cluster's own distance is measured: every one sits at its Cloud's distance, so the Cloud's depth is not drawn.", - "distanceSource": "publication-graczyk2020apj-904-13g" - }, - "filters": [], - "frame": { - "input": "icrs", - "output": "sun-icrf", - "epochJdTt": 2461286.5, - "converter": "Astropy SkyCoord ICRS, heliocentric Cartesian" - }, - "source": "bica-2008-magellanic-extended-objects", - "meaning": "Star clusters of the Small Magellanic Cloud from Bica et al.'s (2008) catalogue of extended objects: one dot per ordinary cluster at its catalogue sky position, at the Cloud's distance.", - "appearance": { - "colorCss": "#9fd4ff", - "radiusPx": 0.75, - "opacity": 1, - "note": "Presentation only: the Milky Way open-cluster layer's blue-white and size." - } -} diff --git a/src/objects/m57-miri-layers/README.md b/src/objects/m57-miri-layers/README.md index 5f41818f8d2..dd099d9fde5 100644 --- a/src/objects/m57-miri-layers/README.md +++ b/src/objects/m57-miri-layers/README.md @@ -13,7 +13,7 @@ ESA/Webb's mid-infrared picture of the Ring Nebula, on the walls of its ionised ## The picture -- **Registration:** the file's embedded sky tags give scale and direction, 0.1111″ per pixel and north 132.9° right of vertical. The star gives the place: the central star in the picture is set at the place the Ring Nebula's page stands at. The tags alone put it 0.21″ away. +- **Registration:** the file's embedded sky tags give scale and direction, 0.1111″ per pixel and north 132.9° right of vertical. The star gives the place: the central star in the picture is set at the place the Ring Nebula's page stands at. The tags alone put it 0.04″ away. - **The shell:** the Hubble dataset's: 44″ by 30″ on the sky with its long axis at position angle 60°, its pole tipped 6.5° from the sight line, and a lobe through its opening within 19.7″ of the star. - **In the Main Ring:** the red and green channels are ionised gas, [S III] its brightest line. They take the ring's [N II] and [S II] speed, 19 km/s, which is 29.2″ along the sight line: [S III] is the shell's lower-ionisation light, against the cavity's [S IV]. The blue channel is molecular hydrogen and takes the molecular shell's speed along the sight line, 24.6 km/s, 37.8″. - **Through the opening:** the red channel is [O IV], made by the 55 eV that also makes He II; it takes the Lobes' He II speed, 20 km/s, 30.8″ along the sight line. The green and blue channels hold [S IV], made by the 35 eV that also makes [O III]; they take the Lobes' [O III] speed, 28 km/s, 43.1″. diff --git a/src/objects/m57-miri-layers/inventory.json b/src/objects/m57-miri-layers/inventory.json index c1b71d2f2f2..31c0984fbc3 100644 --- a/src/objects/m57-miri-layers/inventory.json +++ b/src/objects/m57-miri-layers/inventory.json @@ -10,8 +10,8 @@ { "location": "prepared", "filename": "image-layers.json", - "bytes": 301398, - "sha256": "c43e388331f55df1f14caffd5e580aa8071c8f787b2867d0e5688e42e629322d" + "bytes": 301474, + "sha256": "8777e77c12593244daabc8fa680fbce323008d49cb9b128cac4b15826f7978ce" }, { "location": "prepared", diff --git a/src/objects/m57-miri-layers/investigations.json b/src/objects/m57-miri-layers/investigations.json index ef3ade1b95e..ca0a9bb15f9 100644 --- a/src/objects/m57-miri-layers/investigations.json +++ b/src/objects/m57-miri-layers/investigations.json @@ -6,7 +6,7 @@ "id": "weic2320c", "subject": "ESA/Webb weic2320c", "status": "included", - "finding": "Webb captures detailed beauty of Ring Nebula (MIRI image): MIRI at 5.6, 7.7, 10, 11, 12, 15, 18, 21 and 25 µm; 1257 × 1015 px over 2.33 × 1.88 arcmin, released 21 August 2023. The file's embedded sky tags for scale and direction, 0.1111 arcsec per pixel and north 132.9° right of vertical, and the star for place: the central star in the picture, pixel 627.5, 513.5, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.396433°, 33.0290199°. The tags alone put the star 0.21 arcsec from where the picture shows it.", + "finding": "Webb captures detailed beauty of Ring Nebula (MIRI image): MIRI at 5.6, 7.7, 10, 11, 12, 15, 18, 21 and 25 µm; 1257 × 1015 px over 2.33 × 1.88 arcmin, released 21 August 2023. The file's embedded sky tags for scale and direction, 0.1111 arcsec per pixel and north 132.9° right of vertical, and the star for place: the star in the picture, the peak within 3 arcsec of where the tags put it that stands highest above its surroundings, by 94 of 255, pixel 627.5, 513.5, is set at the recipe's target, so the frame's centre is 283.396433°, 33.0290199°. The tags alone put the star 0.04 arcsec from where the picture shows it.", "evidence": [ "https://esawebb.org/images/weic2320c/", "https://esawebb.org/copyright/" diff --git a/src/objects/m57-miri-layers/source/provenance.json b/src/objects/m57-miri-layers/source/provenance.json index 890fac48c91..1f8728d49c3 100644 --- a/src/objects/m57-miri-layers/source/provenance.json +++ b/src/objects/m57-miri-layers/source/provenance.json @@ -40,15 +40,15 @@ "geometryEvidence": { "inclinationDeg": 0.001, "lineOfNodesPaDeg": 218.521, - "reference": "A picture of the sky: the plane is the picture's own tangent plane, perpendicular to the sight line through the picture's centre, so it faces the Sun. That centre is 0.7 arcsec from the star, which is the whole of the recipe's inclination. Not the orientation of the nebula, which is the shell's." + "reference": "A picture of the sky: the plane is the picture's own tangent plane, perpendicular to the sight line through the picture's centre, so it faces the Sun. That centre is 0.7 arcsec from the star, which is the whole of the recipe's inclination. Not the orientation of the object itself." }, "registration": { - "method": "The file's embedded sky tags for scale and direction, 0.1111 arcsec per pixel and north 132.9° right of vertical, and the star for place: the central star in the picture, pixel 627.5, 513.5, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.396433°, 33.0290199°. The tags alone put the star 0.21 arcsec from where the picture shows it.", + "method": "The file's embedded sky tags for scale and direction, 0.1111 arcsec per pixel and north 132.9° right of vertical, and the star for place: the star in the picture, the peak within 3 arcsec of where the tags put it that stands highest above its surroundings, by 94 of 255, pixel 627.5, 513.5, is set at the recipe's target, so the frame's centre is 283.396433°, 33.0290199°. The tags alone put the star 0.04 arcsec from where the picture shows it.", "pixelArcsec": 0.1111, "northClockwiseDeg": 132.92 }, "displayModel": "Inside the outline of the ionised shell of O'Dell et al. (2013) the picture's light lies on that shell's two walls, in front of the star and behind it, each color at the depth of the speed of the gas it shows; inside the shell's opening it lies on the walls of the lobes. The walls share the smooth light; the fine detail is on the nearer one. Outside the shell's outline the halo lies on the picture's plane.", - "coverage": "The published frame inside a circle about the star: the picture fades out between 50.0 and 55.6 arcsec, the largest circle the frame holds, so no straight edge shows.", + "coverage": "The published frame inside a circle about the star: the picture fades out between 50.0 and 55.6 arcsec from the star, the largest circle the frame holds, so no straight edge shows.", "acquisition": { "url": "https://cdn.esawebb.org/archives/images/large/weic2320c.jpg", "dimensions": [ diff --git a/src/objects/m57-nircam-layers/README.md b/src/objects/m57-nircam-layers/README.md index 6697ced991e..dff1e9ae9a1 100644 --- a/src/objects/m57-nircam-layers/README.md +++ b/src/objects/m57-nircam-layers/README.md @@ -13,7 +13,7 @@ ESA/Webb's near-infrared picture of the Ring Nebula, laid on the shell of molecu ## The picture -- **Registration:** the file's embedded sky tags give scale and direction, 0.0314″ per pixel and north 140.2° right of vertical. The star gives the place: the central star in the picture is set at the place the Ring Nebula's page stands at. The tags alone put it 0.13″ away. +- **Registration:** the file's embedded sky tags give scale and direction, 0.0314″ per pixel and north 140.2° right of vertical. The star gives the place: the central star in the picture is set at the place the Ring Nebula's page stands at. The tags alone put it 0.10″ away. - **The shell:** an ellipsoid about the star, 47.0″ by 32.5″ on the sky with its long axis at position angle 70°, and 38.5″ along the sight line: 0.82 of the long axis, 24.6 km/s under the shell's law of 30 km/s at 47.0″. The red and green channels, molecular hydrogen, lie on its two walls, in front of the star and behind it. - **The ionised ring:** the blue channel holds the 1.62 µm filter's ionised gas. Inside the shell's outline it takes the Main Ring's [N II] speed, 19 km/s, which is 29.8″ along the sight line. A choice made here: hydrogen shines through the whole ionised ring, and [N II], the faster of the ring's two printed speeds, is its outer part. - **Through the opening:** the shell is open at both poles. Within 19.7″ of the star, the radius of the ellipsoid Kastner et al. cut the opening with, the picture shows the ionised gas of the central cavity. There every channel takes the Lobes' [O III] speed, 28 km/s, 43.9″ along the sight line. A choice made here: the middle of the three speeds O'Dell et al. give the Lobes. diff --git a/src/objects/m57-nircam-layers/inventory.json b/src/objects/m57-nircam-layers/inventory.json index 9c55636bb10..d38af8cff5c 100644 --- a/src/objects/m57-nircam-layers/inventory.json +++ b/src/objects/m57-nircam-layers/inventory.json @@ -10,8 +10,8 @@ { "location": "prepared", "filename": "image-layers.json", - "bytes": 301894, - "sha256": "0e68534f9124385f4d6016b59187f7bf2d9016a4a200f8f1597f470aedd9cbce" + "bytes": 301913, + "sha256": "d326d713c63a3d4b1f7f91adbb8342a6b75ecc419e9418e4f97735854a35ab6f" }, { "location": "prepared", diff --git a/src/objects/m57-nircam-layers/investigations.json b/src/objects/m57-nircam-layers/investigations.json index 8a86b6ad2ba..dda64fccf87 100644 --- a/src/objects/m57-nircam-layers/investigations.json +++ b/src/objects/m57-nircam-layers/investigations.json @@ -6,7 +6,7 @@ "id": "weic2320b", "subject": "ESA/Webb weic2320b", "status": "included", - "finding": "Webb captures detailed beauty of Ring Nebula (NIRCam image): NIRCam at 1.62, 2.12, 3.0 and 3.35 µm; 4097 × 4156 px over 2.14 × 2.17 arcmin, released 21 August 2023. The file's embedded sky tags for scale and direction, 0.0314 arcsec per pixel and north 140.2° right of vertical, and the star for place: the central star in the picture, pixel 2123, 1991.4, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.3950727°, 33.0293034°. The tags alone put the star 0.13 arcsec from where the picture shows it.", + "finding": "Webb captures detailed beauty of Ring Nebula (NIRCam image): NIRCam at 1.62, 2.12, 3.0 and 3.35 µm; 4097 × 4156 px over 2.14 × 2.17 arcmin, released 21 August 2023. The file's embedded sky tags for scale and direction, 0.0314 arcsec per pixel and north 140.2° right of vertical, and the star for place: the star in the picture, the middle of the central star's light, pixel 2123, 1991.4, is set at the recipe's target, so the frame's centre is 283.3950727°, 33.0293034°. The tags alone put the star 0.10 arcsec from where the picture shows it.", "evidence": [ "https://esawebb.org/images/weic2320b/", "https://esawebb.org/copyright/" @@ -57,8 +57,7 @@ "finding": "Kastner et al. (2025, Sect. III.2.2) find that some filaments seen inside the ring in Webb's pictures are molecular knots moving at 45 to 50 km/s along the sight line. They print no table of them; here they lie on the lobe's near wall with the rest of the detail.", "evidence": [ "https://arxiv.org/abs/2501.12223" - ], - "revisitWhen": "The knots' places and speeds are published as a table." + ] }, { "id": "weic2320c", diff --git a/src/objects/m57-nircam-layers/source/provenance.json b/src/objects/m57-nircam-layers/source/provenance.json index f32b6a31bfc..7135a280072 100644 --- a/src/objects/m57-nircam-layers/source/provenance.json +++ b/src/objects/m57-nircam-layers/source/provenance.json @@ -15,10 +15,10 @@ "bands": [ "Webb", "NIRCam", - "F162M", - "F212N", - "F300M", - "F335M" + "1.62 µm", + "2.12 µm", + "3.0 µm", + "3.35 µm" ], "fieldOfViewDeg": [ 0.035726, @@ -35,15 +35,15 @@ "geometryEvidence": { "inclinationDeg": 0.001, "lineOfNodesPaDeg": 9.142, - "reference": "A picture of the sky: the plane is the picture's own tangent plane, perpendicular to the sight line through the picture's centre, so it faces the Sun. That centre is 3.6 arcsec from the star, which is the whole of the recipe's inclination. Not the orientation of the nebula, which is the shell's." + "reference": "A picture of the sky: the plane is the picture's own tangent plane, perpendicular to the sight line through the picture's centre, so it faces the Sun. That centre is 3.6 arcsec from the star, which is the whole of the recipe's inclination. Not the orientation of the object itself." }, "registration": { - "method": "The file's embedded sky tags for scale and direction, 0.0314 arcsec per pixel and north 140.2° right of vertical, and the star for place: the central star in the picture, pixel 2123, 1991.4, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.3950727°, 33.0293034°. The tags alone put the star 0.13 arcsec from where the picture shows it.", + "method": "The file's embedded sky tags for scale and direction, 0.0314 arcsec per pixel and north 140.2° right of vertical, and the star for place: the star in the picture, the middle of the central star's light, pixel 2123, 1991.4, is set at the recipe's target, so the frame's centre is 283.3950727°, 33.0293034°. The tags alone put the star 0.10 arcsec from where the picture shows it.", "pixelArcsec": 0.03139, "northClockwiseDeg": 140.2 }, "displayModel": "Inside the outline of the molecular shell of Kastner et al. (2025) the picture's light lies on that shell's two walls, in front of the star and behind it; inside the shell's polar opening it lies on the walls of the ionised lobes (O'Dell et al. 2013). The walls share the smooth light; the fine detail, the knots, is on the nearer one. Outside the shell's outline the halo lies on the picture's plane.", - "coverage": "The published frame inside a circle about the star: the picture fades out between 55.7 and 61.9 arcsec, the largest circle the frame holds, so no straight edge shows.", + "coverage": "The published frame inside a circle about the star: the picture fades out between 55.7 and 61.9 arcsec from the star, the largest circle the frame holds, so no straight edge shows.", "acquisition": { "url": "https://cdn.esawebb.org/archives/images/large/weic2320b.jpg", "dimensions": [ diff --git a/src/objects/ngc-3132-miri-layers/README.md b/src/objects/ngc-3132-miri-layers/README.md index 98f7e9ddf9a..3d8a09cb8b7 100644 --- a/src/objects/ngc-3132-miri-layers/README.md +++ b/src/objects/ngc-3132-miri-layers/README.md @@ -12,7 +12,7 @@ ESA/Webb's mid-infrared picture of the Southern Ring Nebula (NGC 3132), at the d ## The picture -- **Registration:** the file's embedded sky tags give scale and direction, 0.1099″ per pixel and north 124.65° right of vertical. The star gives the place: the largest patch of saturated pixels within 1″ of where the tags put SIMBAD's place, 29 pixels, is set at that place. The tags alone put it 0.29″ away. +- **Registration:** the file's embedded sky tags give scale and direction, 0.1099″ per pixel and north 124.65° right of vertical. The star gives the place: the largest patch of saturated pixels within 1″ of where the tags put SIMBAD's place, 29 pixels, is set at that place. The tags alone put it 0.26″ away. - **The walls:** the near-infrared dataset's, unchanged: the grid's first axis the sight line with its high end toward the Sun, its second axis at position angle 325.5°, its third at 55.5°, its middle 1″ west and 0.5″ south of the star. On each sight line the nearer half of the gas's emission and the farther half each stand at their own middle, and share the picture's smooth light. - **A second check of the placement:** [grid-fit.mts](../../../packages/bake/authoring/ngc-3132/grid-fit.mts), run on this picture, finds the grid's second axis at 324.5° and its third at 54.5°, its middle 0.5″ west and 1″ south of the star. The grid's emission correlates 0.580 with this picture; half a turn on gives 0.136 and the other handedness 0.316 at best. Another instrument, other wavelengths, the same turn within a degree. - **The gas and this light:** the grid is the ionised gas, from H-alpha and [N II]. Whatever makes this picture's light, dust and molecules included, takes the same depths here. diff --git a/src/objects/ngc-3132-miri-layers/inventory.json b/src/objects/ngc-3132-miri-layers/inventory.json index b7cb97899dc..7bc4a8be730 100644 --- a/src/objects/ngc-3132-miri-layers/inventory.json +++ b/src/objects/ngc-3132-miri-layers/inventory.json @@ -10,8 +10,8 @@ { "location": "prepared", "filename": "image-layers.json", - "bytes": 301634, - "sha256": "adf333ebbf4a988eb0e87af1f99f0f05d7b6977e72c6e0ddf04fd17d78d634a3" + "bytes": 301698, + "sha256": "5a9c9c0d5eecf75ae310abe265d7352f0951a43a1dfcb07e60eb6cf9f9259d03" }, { "location": "prepared", diff --git a/src/objects/ngc-3132-miri-layers/investigations.json b/src/objects/ngc-3132-miri-layers/investigations.json index 600faa8061a..6989e0ae0fd 100644 --- a/src/objects/ngc-3132-miri-layers/investigations.json +++ b/src/objects/ngc-3132-miri-layers/investigations.json @@ -6,7 +6,7 @@ "id": "weic2207c", "subject": "ESA/Webb weic2207c", "status": "included", - "finding": "Southern Ring Nebula (MIRI Image): MIRI at 7.7, 11.3, 12 and 18 µm; 1306 × 1133 px over 2.39 × 2.07 arcmin, released 12 July 2022. The file's embedded sky tags for scale and direction, 0.1099 arcsec per pixel and north 124.65° right of vertical, and the star for place: the middle of the bright central star's saturated pixels, pixel 663.3, 553.5, is set at SIMBAD's place for NGC 3132, so the frame's centre is 151.7566982°, -40.4364794°. The tags alone put the star 0.29 arcsec away.", + "finding": "Southern Ring Nebula (MIRI Image): MIRI at 7.7, 11.3, 12 and 18 µm; 1306 × 1133 px over 2.39 × 2.07 arcmin, released 12 July 2022. The file's embedded sky tags for scale and direction, 0.1099 arcsec per pixel and north 124.7° right of vertical, and the star for place: the star in the picture, the largest patch of saturated pixels within 1 arcsec of where the tags put it, 29 px, pixel 663.3, 553.5, is set at the recipe's target, so the frame's centre is 151.7566982°, -40.4364794°. The tags alone put the star 0.26 arcsec from where the picture shows it.", "evidence": [ "https://esawebb.org/images/weic2207c/", "https://esawebb.org/copyright/" diff --git a/src/objects/ngc-3132-miri-layers/source/provenance.json b/src/objects/ngc-3132-miri-layers/source/provenance.json index 968a7535696..6f56e809042 100644 --- a/src/objects/ngc-3132-miri-layers/source/provenance.json +++ b/src/objects/ngc-3132-miri-layers/source/provenance.json @@ -35,15 +35,15 @@ "geometryEvidence": { "inclinationDeg": 0.001, "lineOfNodesPaDeg": 353.825, - "reference": "A picture of the sky: the plane is the picture's own tangent plane, perpendicular to the sight line through the picture's centre, so it faces the Sun. That centre is 1.8 arcsec from the star, which is the whole of the recipe's inclination. Not the orientation of the nebula, which is the density grid's." + "reference": "A picture of the sky: the plane is the picture's own tangent plane, perpendicular to the sight line through the picture's centre, so it faces the Sun. That centre is 1.8 arcsec from the star, which is the whole of the recipe's inclination. Not the orientation of the object itself." }, "registration": { - "method": "The file's embedded sky tags for scale and direction, 0.1099 arcsec per pixel and north 124.65° right of vertical, and the star for place: the middle of the bright central star's saturated pixels, pixel 663.3, 553.5, is set at SIMBAD's place for NGC 3132, so the frame's centre is 151.7566982°, -40.4364794°. The tags alone put the star 0.29 arcsec away.", + "method": "The file's embedded sky tags for scale and direction, 0.1099 arcsec per pixel and north 124.7° right of vertical, and the star for place: the star in the picture, the largest patch of saturated pixels within 1 arcsec of where the tags put it, 29 px, pixel 663.3, 553.5, is set at the recipe's target, so the frame's centre is 151.7566982°, -40.4364794°. The tags alone put the star 0.26 arcsec from where the picture shows it.", "pixelArcsec": 0.1099, "northClockwiseDeg": 124.652 }, "displayModel": "Inside the outline of the density grid of Monteiro et al. (2025), each sight line's light lies on two walls: the nearer half of the gas's emission along it and the farther half, each at its own middle. The two share the picture's smooth light; its fine detail is on the nearer one. Over the outer tenth of the way from the star to the gas's outline the walls are led onto the picture's plane, where the picture outside the outline lies. The central star's own light, which ends 2.1 arcsec from it in this picture, stays at the star: all of it within 1.05 arcsec, less and less out to 2.1.", - "coverage": "The published frame inside a circle about the star: the picture fades out between 54.7 and 60.8 arcsec, the largest circle the frame holds, so no straight edge shows.", + "coverage": "The published frame inside a circle about the star: the picture fades out between 54.7 and 60.8 arcsec from the star, the largest circle the frame holds, so no straight edge shows.", "acquisition": { "url": "https://cdn.esawebb.org/archives/images/large/weic2207c.jpg", "dimensions": [ diff --git a/src/sources/bica-2008-magellanic-extended-objects.json b/src/sources/bica-2008-magellanic-extended-objects.json deleted file mode 100644 index 3f7c344e3bd..00000000000 --- a/src/sources/bica-2008-magellanic-extended-objects.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "id": "bica-2008-magellanic-extended-objects", - "kind": "publication", - "identityLevel": "work", - "title": "A general catalogue of extended objects in the Magellanic System", - "identifiers": [ - { - "type": "ads", - "value": "2008MNRAS.389..678B" - }, - { - "type": "CDS/VizieR", - "value": "J/MNRAS/389/678" - } - ], - "links": [ - { - "role": "landing", - "url": "https://ui.adsabs.harvard.edu/abs/2008MNRAS.389..678B", - "label": "2008MNRAS.389..678B" - }, - { - "role": "archive", - "url": "https://cdsarc.cds.unistra.fr/viz-bin/cat/J/MNRAS/389/678", - "label": "CDS J/MNRAS/389/678" - } - ], - "evidence": [ - { - "url": "https://cdsarc.cds.unistra.fr/ftp/J/MNRAS/389/678/ReadMe", - "checkedOn": "2026-10-04", - "locator": "table3.dat: 3,740 star clusters of the SMC, Bridge and LMC with J2000 positions and a type; 2,770 are ordinary clusters (type C). The paper's Sect. 3 takes as SMC objects those at 23 h 40 min < RA < 1 h 20 min and as LMC objects those at 4 h < RA < 6 h 40 min, the Bridge between: 444 and 2,270 ordinary clusters are drawn; the 55 of the Bridge are not." - } - ], - "creators": [ - "E. Bica", - "C. Bonatto", - "C. M. Dutra", - "J. F. C. Santos Jr" - ], - "publisher": "MNRAS 389, 678", - "publicationDate": "2008", - "relations": [], - "statements": [ - { - "kind": "credit", - "text": "Cite Bica et al. (2008), MNRAS 389, 678.", - "scope": "The Magellanic Clouds' star clusters", - "evidence": "https://cdsarc.cds.unistra.fr/ftp/J/MNRAS/389/678/ReadMe" - } - ] -} diff --git a/src/sources/esawebb-weic2207c.json b/src/sources/esawebb-weic2207c.json index f2b9275af7f..2874cf79dd4 100644 --- a/src/sources/esawebb-weic2207c.json +++ b/src/sources/esawebb-weic2207c.json @@ -30,7 +30,7 @@ { "url": "https://esawebb.org/images/weic2207c/", "checkedOn": "2026-10-04", - "locator": "1306 x 1133 px over 2.39 × 2.07 arcmin; MIRI 7.7, 11.3, 12 and 18 µm (the page prints the second as 1.13 µm); released 12 July 2022. The file's embedded sky tags for scale and direction, 0.1099 arcsec per pixel and north 124.65° right of vertical, and the star for place: the middle of the bright central star's saturated pixels, pixel 663.3, 553.5, is set at SIMBAD's place for NGC 3132, so the frame's centre is 151.7566982°, -40.4364794°. The tags alone put the star 0.29 arcsec away." + "locator": "1306 x 1133 px over 2.39 × 2.07 arcmin; MIRI 7.7, 11.3, 12 and 18 µm (the page prints the second as 1.13 µm); released 12 July 2022. The file's embedded sky tags for scale and direction, 0.1099 arcsec per pixel and north 124.65° right of vertical, and the star for place: the middle of the bright central star's saturated pixels, pixel 663.3, 553.5, is set at SIMBAD's place for NGC 3132, so the frame's centre is 151.7566982°, -40.4364794°. The tags alone put the star 0.26 arcsec away." } ], "publisher": "ESA/Webb", diff --git a/src/sources/esawebb-weic2320b.json b/src/sources/esawebb-weic2320b.json index ee0c3e52e97..549fc4ea7cd 100644 --- a/src/sources/esawebb-weic2320b.json +++ b/src/sources/esawebb-weic2320b.json @@ -30,7 +30,7 @@ { "url": "https://esawebb.org/images/weic2320b/", "checkedOn": "2026-10-04", - "locator": "4097 x 4156 px over 2.14 × 2.17 arcmin; NIRCam 1.62, 2.12, 3.0 and 3.35 µm; released 21 August 2023. The file's embedded sky tags for scale and direction, 0.0314 arcsec per pixel and north 140.2° right of vertical, and the star for place: the central star in the picture, pixel 2123, 1991.4, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.3950727°, 33.0293034°. The tags alone put the star 0.13 arcsec from where the picture shows it." + "locator": "4097 x 4156 px over 2.14 × 2.17 arcmin; NIRCam 1.62, 2.12, 3.0 and 3.35 µm; released 21 August 2023. The file's embedded sky tags for scale and direction, 0.0314 arcsec per pixel and north 140.2° right of vertical, and the star for place: the central star in the picture, pixel 2123, 1991.4, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.3950727°, 33.0293034°. The tags alone put the star 0.10 arcsec from where the picture shows it." } ], "publisher": "ESA/Webb", diff --git a/src/sources/esawebb-weic2320c.json b/src/sources/esawebb-weic2320c.json index e9ff8970530..55c5c4fe41a 100644 --- a/src/sources/esawebb-weic2320c.json +++ b/src/sources/esawebb-weic2320c.json @@ -30,7 +30,7 @@ { "url": "https://esawebb.org/images/weic2320c/", "checkedOn": "2026-10-04", - "locator": "1257 x 1015 px over 2.33 × 1.88 arcmin; MIRI 5.6, 7.7, 10, 11, 12, 15, 18, 21 and 25 µm; released 21 August 2023. The file's embedded sky tags for scale and direction, 0.1111 arcsec per pixel and north 132.9° right of vertical, and the star for place: the central star in the picture, pixel 627.5, 513.5, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.396433°, 33.0290199°. The tags alone put the star 0.21 arcsec from where the picture shows it." + "locator": "1257 x 1015 px over 2.33 × 1.88 arcmin; MIRI 5.6, 7.7, 10, 11, 12, 15, 18, 21 and 25 µm; released 21 August 2023. The file's embedded sky tags for scale and direction, 0.1111 arcsec per pixel and north 132.9° right of vertical, and the star for place: the central star in the picture, pixel 627.5, 513.5, is set at the place the Ring Nebula's page stands at, so the frame's centre is 283.396433°, 33.0290199°. The tags alone put the star 0.04 arcsec from where the picture shows it." } ], "publisher": "ESA/Webb",