@photivra/engine 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/CITATION.cff +1 -1
  3. package/NOTICE +5 -4
  4. package/README.md +9 -7
  5. package/SECURITY.md +1 -1
  6. package/THIRD_PARTY.md +15 -11
  7. package/dist/core/version.d.ts +1 -1
  8. package/dist/core/version.js +1 -1
  9. package/dist/core/version.js.map +1 -1
  10. package/dist/facade/simulate-capture.d.ts +1 -1
  11. package/dist/facade/simulate-capture.js +15 -4
  12. package/dist/facade/simulate-capture.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/optics/defocus-disc-kernel.d.ts +90 -1
  17. package/dist/optics/defocus-disc-kernel.js +58 -5
  18. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  19. package/dist/optics/diffraction-psf-profile.d.ts +9 -4
  20. package/dist/optics/diffraction-psf-profile.js +14 -6
  21. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  22. package/dist/optics/lens-psf-kernels.d.ts +13 -2
  23. package/dist/optics/lens-psf-kernels.js +23 -6
  24. package/dist/optics/lens-psf-kernels.js.map +1 -1
  25. package/dist/optics/site-diffraction-kernel.d.ts +1 -1
  26. package/dist/optics/site-diffraction-kernel.js +37 -17
  27. package/dist/optics/site-diffraction-kernel.js.map +1 -1
  28. package/dist/output/print-contrast-reference.js +2 -2
  29. package/dist/output/print-contrast-reference.js.map +1 -1
  30. package/docs/AGENT_GUIDE.md +1 -0
  31. package/docs/API_REFERENCE.md +12 -1
  32. package/docs/QUICK_START.md +20 -4
  33. package/docs/USAGE.md +17 -0
  34. package/docs/api/core-version.md +1 -1
  35. package/docs/api/exports.json +22 -2
  36. package/docs/api/facade-simulate-capture.md +1 -1
  37. package/docs/api/optics-defocus-disc-kernel.md +86 -0
  38. package/docs/api/optics-diffraction-psf-profile.md +6 -3
  39. package/docs/api/optics-lens-psf-kernels.md +3 -2
  40. package/llms.txt +1 -0
  41. package/package.json +1 -1
@@ -34,8 +34,11 @@ import { requireAllowlistedRecord } from "../core/record-validation.js";
34
34
  import { IRRADIANCE_CONVOLUTION_LIMITS } from "./irradiance-convolution.js";
35
35
  import { markEngineOwnedLensSampledPsfProfile, resolveLensSampledPsf } from "./lens-psf-profile.js";
36
36
  export const LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION = "0.1.0";
37
- /** At most this many wavelengths per call. */
38
- export const LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS = 64;
37
+ /**
38
+ * At most this many wavelengths per call: 400, the finest spectral sampling of `simulateCapture` (1 nm over 380–780 nm),
39
+ * as `DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS.maximumWavelengths` (64 before 2.15.0). The work is linear in the count.
40
+ */
41
+ export const LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS = 400;
39
42
  function positive(value, message) {
40
43
  if (typeof value !== "number" || !(value > 0 && value <= Number.MAX_VALUE))
41
44
  throw new InvalidConfigurationError(message);
@@ -53,6 +56,19 @@ function integer(value, minimum, maximum, message) {
53
56
  * @see docs/WEBGPU.md, "Kernels from a lens PSF profile".
54
57
  */
55
58
  export function resolveLensPsfConvolutionKernels(input) {
59
+ return resolveKernels(input, undefined);
60
+ }
61
+ /**
62
+ * Internal (not exported from the package): `resolveLensPsfConvolutionKernels` with `ownedProfile` used in place of a fresh
63
+ * private clone of `input.psf.profile`. `ownedProfile` must be a structured clone of that profile that no caller code can
64
+ * reach, already passed to `markEngineOwnedLensSampledPsfProfile`; repeated calls then share one parse (the clone is frozen
65
+ * and its parse cached on the first resolve), so N one-wavelength calls cost O(N) instead of O(N²) (2.15.0).
66
+ * The values are the same: the public function parses its own clone the same way.
67
+ */
68
+ export function resolveLensPsfConvolutionKernelsWithOwnedProfile(input, ownedProfile) {
69
+ return resolveKernels(input, ownedProfile);
70
+ }
71
+ function resolveKernels(input, ownedProfile) {
56
72
  const r = requireAllowlistedRecord(input, ["schemaVersion", "psf", "spectralWeights", "grid", "patchWidth", "patchHeight", "kernelRadius"], "Invalid lens PSF convolution kernel input.");
57
73
  if (r.schemaVersion !== LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION)
58
74
  throw new InvalidConfigurationError("Unsupported lens PSF convolution kernel schema.");
@@ -71,7 +87,7 @@ export function resolveLensPsfConvolutionKernels(input) {
71
87
  throw new InvalidConfigurationError("Too many patches.");
72
88
  const list = r.spectralWeights;
73
89
  if (!Array.isArray(list) || list.length < 1 || list.length > LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS) {
74
- throw new InvalidConfigurationError("spectralWeights must list 1 to 64 wavelengths.");
90
+ throw new InvalidConfigurationError("spectralWeights must list 1 to " + LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS + " wavelengths.");
75
91
  }
76
92
  const spectral = list.map((entry) => {
77
93
  const e = requireAllowlistedRecord(entry, ["wavelengthNm", "weight"], "Invalid spectral weight.");
@@ -83,9 +99,10 @@ export function resolveLensPsfConvolutionKernels(input) {
83
99
  if (!(totalWeight > 0 && totalWeight <= Number.MAX_VALUE))
84
100
  throw new InvalidConfigurationError("At least one spectral weight must be positive.");
85
101
  const psf = requireAllowlistedRecord(r.psf, ["profile", "focalLengthMm", "focus", "apertureFNumber", "signedDefocusImagePlaneMicrometers"], "Invalid PSF state.");
86
- // One private parse of the profile for every patch and wavelength.
87
- const profile = structuredClone(psf.profile);
88
- markEngineOwnedLensSampledPsfProfile(profile);
102
+ // One private parse of the profile for every patch and wavelength (and, with an owned profile, for every call).
103
+ const profile = ownedProfile ?? structuredClone(psf.profile);
104
+ if (!ownedProfile)
105
+ markEngineOwnedLensSampledPsfProfile(profile);
89
106
  const state = { ...psf, profile };
90
107
  // Resolve every patch and wavelength, splatting straight into one scratch displacement map sized for the largest
91
108
  // allowed radius; each patch keeps a copy cropped to its own reach. The common radius is known only at the end.
@@ -1 +1 @@
1
- {"version":3,"file":"lens-psf-kernels.js","sourceRoot":"","sources":["../../src/optics/lens-psf-kernels.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Samples the engine's lens PSFs (`LensSampledPsfProfile`, resolved with `resolveLensSampledPsf`) into\n * the per-patch kernels of the isoplanatic convolution (`calculateIrradianceConvolution`, #277).\n *\n * For each patch, the PSF is resolved at the patch centre's field point, in the per-site engine's own\n * coordinates: native sensor millimetres from the optical axis (+Y down), field point (x, −y). Every\n * PSF tap then maps to a site displacement with the per-site quadrature's inverse support\n * (`sensorPsfSourcePoint`): tap (kx, ky) is the source at native offset (−(kx − cx)·pₓ, +(ky − cy)·p_y)\n * from the destination, so its weight lands at displacement u = (kx − cx)·pₓ/sₓ, v = −(ky − cy)·p_y/s_y\n * sites (p: PSF sample pitch, s: site pitch). When p equals s the displacement is taken as the tap offset itself\n * (kx − cx, −(ky − cy)): the rounded quotient (n·p)/p misses the integer n by one unit in the last place for some\n * pitches (for example 3 · 1.6 / 1.6), which split the tap bilinearly or reached one site past the window (2.2.1).\n *\n * - **Exact site sampling.** When every displacement is an integer (equal pitches, integer centre), each\n * tap's weight goes to one kernel cell. For a destination whose PSF equals its patch's, the\n * convolution then equals the per-site PSF quadrature at site-centre sources term by term.\n * - **Otherwise (labelled approximation): bilinear splatting.** Each weight is split over the four\n * surrounding integer displacements with bilinear weights. This is the exact adjoint of bilinear\n * interpolation, so the convolution equals the per-site quadrature applied to the bilinear interpolant of\n * the site-centre samples (tested). Weights are conserved.\n *\n * With several wavelengths the kernel is the weight-normalized sum of the per-wavelength kernels. For a\n * spectrally neutral scene that is exactly the per-site engine's spectral sum when the weights are its\n * spectral quadrature weights times the scene spectrum (see docs/WEBGPU.md).\n *\n * The patch approximation (one PSF per patch, indexed by source site) is the caller's choice of patch\n * size; its method error is measured, not bounded (docs/WEBGPU.md, \"Per-fixture rollout\"). Relative\n * pupil throughput stays a separate factor and is reported, never folded into the unit-energy kernels.\n */\n\nimport { approximationResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { requireAllowlistedRecord } from \"../core/record-validation.js\";\nimport { IRRADIANCE_CONVOLUTION_LIMITS } from \"./irradiance-convolution.js\";\nimport { markEngineOwnedLensSampledPsfProfile, resolveLensSampledPsf, type ResolveLensSampledPsfInput } from \"./lens-psf-profile.js\";\n\nexport const LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION = \"0.1.0\" as const;\n/** At most this many wavelengths per call. */\nexport const LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS = 64;\n\nexport interface LensPsfConvolutionKernelsInput {\n schemaVersion: typeof LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION;\n /** The optical state, as for `resolveLensSampledPsf`, without the field point and wavelength. */\n psf: Omit<ResolveLensSampledPsfInput, \"fieldPointMm\" | \"wavelengthNm\">;\n /** One or more wavelengths with non-negative weights (at least one positive). */\n spectralWeights: readonly { wavelengthNm: number; weight: number }[];\n /** The convolution grid: sites, their pitch, and the optical axis in site units (site centres at i + 0.5). */\n grid: { width: number; height: number; sitePitchMicrometers: { x: number; y: number }; opticalAxisSite: { x: number; y: number } };\n /** Patch size in sites; the last row and column of patches may be partial. */\n patchWidth: number;\n patchHeight: number;\n /** Kernel radius to use; omitted, the smallest radius that holds every tap. A radius too small to hold the PSF is rejected. */\n kernelRadius?: number;\n}\n\nexport interface LensPsfConvolutionKernels {\n schemaVersion: typeof LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION;\n kernelRadius: number;\n patchWidth: number;\n patchHeight: number;\n patchColumns: number;\n patchRows: number;\n /** `[patchRow][patchColumn][v + r][u + r]`, ready for `IrradianceConvolutionInput.kernels`. */\n kernels: Float64Array;\n /** True when every tap landed on one integer displacement (no bilinear splatting). */\n exactSiteSampling: boolean;\n maximumKernelSum: number;\n /** Per patch, row-major: the field point used and the weight-averaged relative pupil throughput (not applied). */\n patches: readonly { fieldPointMm: { x: number; y: number }; relativePupilThroughputFactor: number; interpolated: boolean }[];\n}\n\nfunction positive(value: unknown, message: string): number {\n if (typeof value !== \"number\" || !(value > 0 && value <= Number.MAX_VALUE)) throw new InvalidConfigurationError(message);\n return value;\n}\nfunction integer(value: unknown, minimum: number, maximum: number, message: string): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new InvalidConfigurationError(message);\n return value;\n}\n\n/**\n * Builds the per-patch convolution kernels from a sampled lens PSF profile.\n * @param input - See `LensPsfConvolutionKernelsInput`.\n * @returns The kernels and per-patch field points. Success does not imply calibration.\n * @see docs/WEBGPU.md, \"Kernels from a lens PSF profile\".\n */\nexport function resolveLensPsfConvolutionKernels(input: LensPsfConvolutionKernelsInput): CalculationResult<LensPsfConvolutionKernels> {\n const r = requireAllowlistedRecord(input, [\"schemaVersion\", \"psf\", \"spectralWeights\", \"grid\", \"patchWidth\", \"patchHeight\", \"kernelRadius\"],\n \"Invalid lens PSF convolution kernel input.\");\n if (r.schemaVersion !== LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION) throw new InvalidConfigurationError(\"Unsupported lens PSF convolution kernel schema.\");\n const L = IRRADIANCE_CONVOLUTION_LIMITS;\n const g = requireAllowlistedRecord(r.grid, [\"width\", \"height\", \"sitePitchMicrometers\", \"opticalAxisSite\"], \"Invalid convolution grid.\");\n const width = integer(g.width, 1, L.maximumDimension, \"Invalid convolution grid size.\"), height = integer(g.height, 1, L.maximumDimension, \"Invalid convolution grid size.\");\n const pitch = requireAllowlistedRecord(g.sitePitchMicrometers, [\"x\", \"y\"], \"Invalid site pitch.\");\n const sx = positive(pitch.x, \"Site pitch must be positive.\"), sy = positive(pitch.y, \"Site pitch must be positive.\");\n const axis = requireAllowlistedRecord(g.opticalAxisSite, [\"x\", \"y\"], \"Invalid optical axis.\");\n if (typeof axis.x !== \"number\" || typeof axis.y !== \"number\" || !Number.isFinite(axis.x) || !Number.isFinite(axis.y)) {\n throw new InvalidConfigurationError(\"The optical axis must be finite site coordinates.\");\n }\n const patchWidth = integer(r.patchWidth, 1, width, \"Invalid patch size.\"), patchHeight = integer(r.patchHeight, 1, height, \"Invalid patch size.\");\n const columns = Math.ceil(width / patchWidth), rows = Math.ceil(height / patchHeight);\n if (columns * rows > L.maximumPatchCount) throw new InvalidConfigurationError(\"Too many patches.\");\n const list = r.spectralWeights;\n if (!Array.isArray(list) || list.length < 1 || list.length > LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS) {\n throw new InvalidConfigurationError(\"spectralWeights must list 1 to 64 wavelengths.\");\n }\n const spectral = list.map((entry: unknown) => {\n const e = requireAllowlistedRecord(entry, [\"wavelengthNm\", \"weight\"], \"Invalid spectral weight.\");\n if (typeof e.weight !== \"number\" || !(e.weight >= 0 && e.weight <= Number.MAX_VALUE)) throw new InvalidConfigurationError(\"Spectral weights must be finite and non-negative.\");\n return { wavelengthNm: positive(e.wavelengthNm, \"Wavelengths must be positive.\"), weight: e.weight };\n });\n const totalWeight = spectral.reduce((s, e) => s + e.weight, 0);\n if (!(totalWeight > 0 && totalWeight <= Number.MAX_VALUE)) throw new InvalidConfigurationError(\"At least one spectral weight must be positive.\");\n const psf = requireAllowlistedRecord(r.psf, [\"profile\", \"focalLengthMm\", \"focus\", \"apertureFNumber\", \"signedDefocusImagePlaneMicrometers\"], \"Invalid PSF state.\");\n // One private parse of the profile for every patch and wavelength.\n const profile = structuredClone(psf.profile);\n markEngineOwnedLensSampledPsfProfile(profile);\n const state = { ...(psf as unknown as Omit<ResolveLensSampledPsfInput, \"fieldPointMm\" | \"wavelengthNm\">), profile } as Omit<ResolveLensSampledPsfInput, \"fieldPointMm\" | \"wavelengthNm\">;\n\n // Resolve every patch and wavelength, splatting straight into one scratch displacement map sized for the largest\n // allowed radius; each patch keeps a copy cropped to its own reach. The common radius is known only at the end.\n // Every cell receives its weights in the same order as a direct sum (taps in kernel order, then the four\n // bilinear corners), so the kernels do not depend on the scratch layout.\n const M = L.maximumKernelRadius, scratchSize = 2 * M + 1, scratch = new Float64Array(scratchSize * scratchSize);\n const cropped: { reach: number; values: Float64Array }[] = [], patches: LensPsfConvolutionKernels[\"patches\"][number][] = [];\n let reach = 0, exact = true, overflow = false;\n for (let pr = 0; pr < rows; pr++) for (let pc = 0; pc < columns; pc++) {\n const x0 = pc * patchWidth, y0 = pr * patchHeight, x1 = Math.min(width, x0 + patchWidth), y1 = Math.min(height, y0 + patchHeight);\n const nativeX = ((x0 + x1) / 2 - axis.x) * sx / 1000, nativeY = ((y0 + y1) / 2 - axis.y) * sy / 1000;\n const fieldPointMm = { x: nativeX, y: -nativeY };\n let throughput = 0, interpolated = false, patchReach = 0;\n for (const { wavelengthNm, weight } of spectral) {\n if (weight === 0) continue;\n const resolved = resolveLensSampledPsf({ ...state, fieldPointMm, wavelengthNm }).value, k = resolved.kernel, share = weight / totalWeight;\n throughput += share * resolved.relativePupilThroughputFactor;\n if (resolved.resolutionStatus !== \"exact-grid-sample\") interpolated = true;\n const equalX = k.samplePitchMicrometersX === sx, equalY = k.samplePitchMicrometersY === sy;\n for (let ky = 0; ky < k.heightSamples; ky++) for (let kx = 0; kx < k.widthSamples; kx++) {\n const intensity = k.normalizedIntensity[ky * k.widthSamples + kx]!;\n if (intensity === 0) continue;\n // With equal pitches the displacement is the tap offset itself: (n·p)/p can miss n by one unit in the last place.\n const u = equalX ? kx - k.centerSampleX : (kx - k.centerSampleX) * k.samplePitchMicrometersX / sx;\n const v = equalY ? -(ky - k.centerSampleY) : -(ky - k.centerSampleY) * k.samplePitchMicrometersY / sy;\n const u0 = Math.floor(u), v0 = Math.floor(v), fu = u - u0, fv = v - v0, w = share * intensity;\n if (fu !== 0 || fv !== 0) exact = false;\n for (let corner = 0; corner < 4; corner++) {\n const du = corner & 1, dv = corner >> 1;\n const b = du === 0 ? (dv === 0 ? (1 - fu) * (1 - fv) : (1 - fu) * fv) : (dv === 0 ? fu * (1 - fv) : fu * fv);\n if (b === 0) continue;\n const cu = u0 + du, cv = v0 + dv;\n patchReach = Math.max(patchReach, Math.abs(cu), Math.abs(cv));\n // Past the largest radius the call is rejected below; resolving continues so errors keep their order.\n if (patchReach > M) { overflow = true; continue; }\n if (!overflow) scratch[(cv + M) * scratchSize + cu + M]! += w * b;\n }\n }\n }\n reach = Math.max(reach, patchReach);\n if (!overflow) {\n const n = 2 * patchReach + 1, values = new Float64Array(n * n);\n for (let v = -patchReach; v <= patchReach; v++) {\n const row = (v + M) * scratchSize + M;\n values.set(scratch.subarray(row - patchReach, row + patchReach + 1), (v + patchReach) * n);\n scratch.fill(0, row - patchReach, row + patchReach + 1);\n }\n cropped.push({ reach: patchReach, values });\n }\n patches.push(Object.freeze({ fieldPointMm: Object.freeze(fieldPointMm), relativePupilThroughputFactor: throughput, interpolated }));\n }\n const radius = r.kernelRadius === undefined ? reach : integer(r.kernelRadius, 0, L.maximumKernelRadius, \"Invalid kernel radius.\");\n if (reach > radius) throw new InvalidConfigurationError(\"The kernel radius is too small for the PSF support; no tap is dropped.\");\n if (radius > L.maximumKernelRadius) throw new InvalidConfigurationError(\"The PSF support needs a kernel radius above 32 sites.\");\n const size = 2 * radius + 1, taps = size * size;\n if (columns * rows * taps > L.maximumKernelWeights) throw new InvalidConfigurationError(\"Too many kernel weights.\");\n const kernels = new Float64Array(columns * rows * taps);\n let maximumKernelSum = 0;\n cropped.forEach(({ reach: q, values }, p) => {\n const n = 2 * q + 1;\n let sum = 0;\n for (let v = 0; v < n; v++) kernels.set(values.subarray(v * n, (v + 1) * n), p * taps + (v - q + radius) * size + radius - q);\n for (let t = 0; t < taps; t++) sum += kernels[p * taps + t]!;\n maximumKernelSum = Math.max(maximumKernelSum, sum);\n });\n if (maximumKernelSum > L.maximumKernelSum) throw new InvalidConfigurationError(\"Sampled kernels exceed unit energy.\");\n return approximationResult({ schemaVersion: LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION, kernelRadius: radius, patchWidth, patchHeight,\n patchColumns: columns, patchRows: rows, kernels, exactSiteSampling: exact, maximumKernelSum, patches: Object.freeze(patches) },\n \"lens-psf-convolution-kernels\", LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION, [\n \"One PSF per patch, resolved at the patch centre with resolveLensSampledPsf; indexed by source site in the convolution (isoplanatic patches).\",\n exact ? \"Every PSF tap maps to one integer site displacement (exact site sampling).\" :\n \"PSF taps between sites are split bilinearly (the adjoint of bilinear interpolation of site-centre samples); a labelled approximation.\",\n \"Relative pupil throughput is reported per patch and not applied to the unit-energy kernels.\"\n ]);\n}\n"]}
1
+ {"version":3,"file":"lens-psf-kernels.js","sourceRoot":"","sources":["../../src/optics/lens-psf-kernels.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Samples the engine's lens PSFs (`LensSampledPsfProfile`, resolved with `resolveLensSampledPsf`) into\n * the per-patch kernels of the isoplanatic convolution (`calculateIrradianceConvolution`, #277).\n *\n * For each patch, the PSF is resolved at the patch centre's field point, in the per-site engine's own\n * coordinates: native sensor millimetres from the optical axis (+Y down), field point (x, −y). Every\n * PSF tap then maps to a site displacement with the per-site quadrature's inverse support\n * (`sensorPsfSourcePoint`): tap (kx, ky) is the source at native offset (−(kx − cx)·pₓ, +(ky − cy)·p_y)\n * from the destination, so its weight lands at displacement u = (kx − cx)·pₓ/sₓ, v = −(ky − cy)·p_y/s_y\n * sites (p: PSF sample pitch, s: site pitch). When p equals s the displacement is taken as the tap offset itself\n * (kx − cx, −(ky − cy)): the rounded quotient (n·p)/p misses the integer n by one unit in the last place for some\n * pitches (for example 3 · 1.6 / 1.6), which split the tap bilinearly or reached one site past the window (2.2.1).\n *\n * - **Exact site sampling.** When every displacement is an integer (equal pitches, integer centre), each\n * tap's weight goes to one kernel cell. For a destination whose PSF equals its patch's, the\n * convolution then equals the per-site PSF quadrature at site-centre sources term by term.\n * - **Otherwise (labelled approximation): bilinear splatting.** Each weight is split over the four\n * surrounding integer displacements with bilinear weights. This is the exact adjoint of bilinear\n * interpolation, so the convolution equals the per-site quadrature applied to the bilinear interpolant of\n * the site-centre samples (tested). Weights are conserved.\n *\n * With several wavelengths the kernel is the weight-normalized sum of the per-wavelength kernels. For a\n * spectrally neutral scene that is exactly the per-site engine's spectral sum when the weights are its\n * spectral quadrature weights times the scene spectrum (see docs/WEBGPU.md).\n *\n * The patch approximation (one PSF per patch, indexed by source site) is the caller's choice of patch\n * size; its method error is measured, not bounded (docs/WEBGPU.md, \"Per-fixture rollout\"). Relative\n * pupil throughput stays a separate factor and is reported, never folded into the unit-energy kernels.\n */\n\nimport { approximationResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { requireAllowlistedRecord } from \"../core/record-validation.js\";\nimport { IRRADIANCE_CONVOLUTION_LIMITS } from \"./irradiance-convolution.js\";\nimport { markEngineOwnedLensSampledPsfProfile, resolveLensSampledPsf, type ResolveLensSampledPsfInput } from \"./lens-psf-profile.js\";\n\nexport const LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION = \"0.1.0\" as const;\n/**\n * At most this many wavelengths per call: 400, the finest spectral sampling of `simulateCapture` (1 nm over 380–780 nm),\n * as `DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS.maximumWavelengths` (64 before 2.15.0). The work is linear in the count.\n */\nexport const LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS = 400;\n\nexport interface LensPsfConvolutionKernelsInput {\n schemaVersion: typeof LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION;\n /** The optical state, as for `resolveLensSampledPsf`, without the field point and wavelength. */\n psf: Omit<ResolveLensSampledPsfInput, \"fieldPointMm\" | \"wavelengthNm\">;\n /** One or more wavelengths with non-negative weights (at least one positive). */\n spectralWeights: readonly { wavelengthNm: number; weight: number }[];\n /** The convolution grid: sites, their pitch, and the optical axis in site units (site centres at i + 0.5). */\n grid: { width: number; height: number; sitePitchMicrometers: { x: number; y: number }; opticalAxisSite: { x: number; y: number } };\n /** Patch size in sites; the last row and column of patches may be partial. */\n patchWidth: number;\n patchHeight: number;\n /** Kernel radius to use; omitted, the smallest radius that holds every tap. A radius too small to hold the PSF is rejected. */\n kernelRadius?: number;\n}\n\nexport interface LensPsfConvolutionKernels {\n schemaVersion: typeof LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION;\n kernelRadius: number;\n patchWidth: number;\n patchHeight: number;\n patchColumns: number;\n patchRows: number;\n /** `[patchRow][patchColumn][v + r][u + r]`, ready for `IrradianceConvolutionInput.kernels`. */\n kernels: Float64Array;\n /** True when every tap landed on one integer displacement (no bilinear splatting). */\n exactSiteSampling: boolean;\n maximumKernelSum: number;\n /** Per patch, row-major: the field point used and the weight-averaged relative pupil throughput (not applied). */\n patches: readonly { fieldPointMm: { x: number; y: number }; relativePupilThroughputFactor: number; interpolated: boolean }[];\n}\n\nfunction positive(value: unknown, message: string): number {\n if (typeof value !== \"number\" || !(value > 0 && value <= Number.MAX_VALUE)) throw new InvalidConfigurationError(message);\n return value;\n}\nfunction integer(value: unknown, minimum: number, maximum: number, message: string): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new InvalidConfigurationError(message);\n return value;\n}\n\n/**\n * Builds the per-patch convolution kernels from a sampled lens PSF profile.\n * @param input - See `LensPsfConvolutionKernelsInput`.\n * @returns The kernels and per-patch field points. Success does not imply calibration.\n * @see docs/WEBGPU.md, \"Kernels from a lens PSF profile\".\n */\nexport function resolveLensPsfConvolutionKernels(input: LensPsfConvolutionKernelsInput): CalculationResult<LensPsfConvolutionKernels> {\n return resolveKernels(input, undefined);\n}\n\n/**\n * Internal (not exported from the package): `resolveLensPsfConvolutionKernels` with `ownedProfile` used in place of a fresh\n * private clone of `input.psf.profile`. `ownedProfile` must be a structured clone of that profile that no caller code can\n * reach, already passed to `markEngineOwnedLensSampledPsfProfile`; repeated calls then share one parse (the clone is frozen\n * and its parse cached on the first resolve), so N one-wavelength calls cost O(N) instead of O(N²) (2.15.0).\n * The values are the same: the public function parses its own clone the same way.\n */\nexport function resolveLensPsfConvolutionKernelsWithOwnedProfile(input: LensPsfConvolutionKernelsInput, ownedProfile: object): CalculationResult<LensPsfConvolutionKernels> {\n return resolveKernels(input, ownedProfile);\n}\n\nfunction resolveKernels(input: LensPsfConvolutionKernelsInput, ownedProfile: object | undefined): CalculationResult<LensPsfConvolutionKernels> {\n const r = requireAllowlistedRecord(input, [\"schemaVersion\", \"psf\", \"spectralWeights\", \"grid\", \"patchWidth\", \"patchHeight\", \"kernelRadius\"],\n \"Invalid lens PSF convolution kernel input.\");\n if (r.schemaVersion !== LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION) throw new InvalidConfigurationError(\"Unsupported lens PSF convolution kernel schema.\");\n const L = IRRADIANCE_CONVOLUTION_LIMITS;\n const g = requireAllowlistedRecord(r.grid, [\"width\", \"height\", \"sitePitchMicrometers\", \"opticalAxisSite\"], \"Invalid convolution grid.\");\n const width = integer(g.width, 1, L.maximumDimension, \"Invalid convolution grid size.\"), height = integer(g.height, 1, L.maximumDimension, \"Invalid convolution grid size.\");\n const pitch = requireAllowlistedRecord(g.sitePitchMicrometers, [\"x\", \"y\"], \"Invalid site pitch.\");\n const sx = positive(pitch.x, \"Site pitch must be positive.\"), sy = positive(pitch.y, \"Site pitch must be positive.\");\n const axis = requireAllowlistedRecord(g.opticalAxisSite, [\"x\", \"y\"], \"Invalid optical axis.\");\n if (typeof axis.x !== \"number\" || typeof axis.y !== \"number\" || !Number.isFinite(axis.x) || !Number.isFinite(axis.y)) {\n throw new InvalidConfigurationError(\"The optical axis must be finite site coordinates.\");\n }\n const patchWidth = integer(r.patchWidth, 1, width, \"Invalid patch size.\"), patchHeight = integer(r.patchHeight, 1, height, \"Invalid patch size.\");\n const columns = Math.ceil(width / patchWidth), rows = Math.ceil(height / patchHeight);\n if (columns * rows > L.maximumPatchCount) throw new InvalidConfigurationError(\"Too many patches.\");\n const list = r.spectralWeights;\n if (!Array.isArray(list) || list.length < 1 || list.length > LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS) {\n throw new InvalidConfigurationError(\"spectralWeights must list 1 to \" + LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS + \" wavelengths.\");\n }\n const spectral = list.map((entry: unknown) => {\n const e = requireAllowlistedRecord(entry, [\"wavelengthNm\", \"weight\"], \"Invalid spectral weight.\");\n if (typeof e.weight !== \"number\" || !(e.weight >= 0 && e.weight <= Number.MAX_VALUE)) throw new InvalidConfigurationError(\"Spectral weights must be finite and non-negative.\");\n return { wavelengthNm: positive(e.wavelengthNm, \"Wavelengths must be positive.\"), weight: e.weight };\n });\n const totalWeight = spectral.reduce((s, e) => s + e.weight, 0);\n if (!(totalWeight > 0 && totalWeight <= Number.MAX_VALUE)) throw new InvalidConfigurationError(\"At least one spectral weight must be positive.\");\n const psf = requireAllowlistedRecord(r.psf, [\"profile\", \"focalLengthMm\", \"focus\", \"apertureFNumber\", \"signedDefocusImagePlaneMicrometers\"], \"Invalid PSF state.\");\n // One private parse of the profile for every patch and wavelength (and, with an owned profile, for every call).\n const profile = ownedProfile ?? structuredClone(psf.profile);\n if (!ownedProfile) markEngineOwnedLensSampledPsfProfile(profile);\n const state = { ...(psf as unknown as Omit<ResolveLensSampledPsfInput, \"fieldPointMm\" | \"wavelengthNm\">), profile } as Omit<ResolveLensSampledPsfInput, \"fieldPointMm\" | \"wavelengthNm\">;\n\n // Resolve every patch and wavelength, splatting straight into one scratch displacement map sized for the largest\n // allowed radius; each patch keeps a copy cropped to its own reach. The common radius is known only at the end.\n // Every cell receives its weights in the same order as a direct sum (taps in kernel order, then the four\n // bilinear corners), so the kernels do not depend on the scratch layout.\n const M = L.maximumKernelRadius, scratchSize = 2 * M + 1, scratch = new Float64Array(scratchSize * scratchSize);\n const cropped: { reach: number; values: Float64Array }[] = [], patches: LensPsfConvolutionKernels[\"patches\"][number][] = [];\n let reach = 0, exact = true, overflow = false;\n for (let pr = 0; pr < rows; pr++) for (let pc = 0; pc < columns; pc++) {\n const x0 = pc * patchWidth, y0 = pr * patchHeight, x1 = Math.min(width, x0 + patchWidth), y1 = Math.min(height, y0 + patchHeight);\n const nativeX = ((x0 + x1) / 2 - axis.x) * sx / 1000, nativeY = ((y0 + y1) / 2 - axis.y) * sy / 1000;\n const fieldPointMm = { x: nativeX, y: -nativeY };\n let throughput = 0, interpolated = false, patchReach = 0;\n for (const { wavelengthNm, weight } of spectral) {\n if (weight === 0) continue;\n const resolved = resolveLensSampledPsf({ ...state, fieldPointMm, wavelengthNm }).value, k = resolved.kernel, share = weight / totalWeight;\n throughput += share * resolved.relativePupilThroughputFactor;\n if (resolved.resolutionStatus !== \"exact-grid-sample\") interpolated = true;\n const equalX = k.samplePitchMicrometersX === sx, equalY = k.samplePitchMicrometersY === sy;\n for (let ky = 0; ky < k.heightSamples; ky++) for (let kx = 0; kx < k.widthSamples; kx++) {\n const intensity = k.normalizedIntensity[ky * k.widthSamples + kx]!;\n if (intensity === 0) continue;\n // With equal pitches the displacement is the tap offset itself: (n·p)/p can miss n by one unit in the last place.\n const u = equalX ? kx - k.centerSampleX : (kx - k.centerSampleX) * k.samplePitchMicrometersX / sx;\n const v = equalY ? -(ky - k.centerSampleY) : -(ky - k.centerSampleY) * k.samplePitchMicrometersY / sy;\n const u0 = Math.floor(u), v0 = Math.floor(v), fu = u - u0, fv = v - v0, w = share * intensity;\n if (fu !== 0 || fv !== 0) exact = false;\n for (let corner = 0; corner < 4; corner++) {\n const du = corner & 1, dv = corner >> 1;\n const b = du === 0 ? (dv === 0 ? (1 - fu) * (1 - fv) : (1 - fu) * fv) : (dv === 0 ? fu * (1 - fv) : fu * fv);\n if (b === 0) continue;\n const cu = u0 + du, cv = v0 + dv;\n patchReach = Math.max(patchReach, Math.abs(cu), Math.abs(cv));\n // Past the largest radius the call is rejected below; resolving continues so errors keep their order.\n if (patchReach > M) { overflow = true; continue; }\n if (!overflow) scratch[(cv + M) * scratchSize + cu + M]! += w * b;\n }\n }\n }\n reach = Math.max(reach, patchReach);\n if (!overflow) {\n const n = 2 * patchReach + 1, values = new Float64Array(n * n);\n for (let v = -patchReach; v <= patchReach; v++) {\n const row = (v + M) * scratchSize + M;\n values.set(scratch.subarray(row - patchReach, row + patchReach + 1), (v + patchReach) * n);\n scratch.fill(0, row - patchReach, row + patchReach + 1);\n }\n cropped.push({ reach: patchReach, values });\n }\n patches.push(Object.freeze({ fieldPointMm: Object.freeze(fieldPointMm), relativePupilThroughputFactor: throughput, interpolated }));\n }\n const radius = r.kernelRadius === undefined ? reach : integer(r.kernelRadius, 0, L.maximumKernelRadius, \"Invalid kernel radius.\");\n if (reach > radius) throw new InvalidConfigurationError(\"The kernel radius is too small for the PSF support; no tap is dropped.\");\n if (radius > L.maximumKernelRadius) throw new InvalidConfigurationError(\"The PSF support needs a kernel radius above 32 sites.\");\n const size = 2 * radius + 1, taps = size * size;\n if (columns * rows * taps > L.maximumKernelWeights) throw new InvalidConfigurationError(\"Too many kernel weights.\");\n const kernels = new Float64Array(columns * rows * taps);\n let maximumKernelSum = 0;\n cropped.forEach(({ reach: q, values }, p) => {\n const n = 2 * q + 1;\n let sum = 0;\n for (let v = 0; v < n; v++) kernels.set(values.subarray(v * n, (v + 1) * n), p * taps + (v - q + radius) * size + radius - q);\n for (let t = 0; t < taps; t++) sum += kernels[p * taps + t]!;\n maximumKernelSum = Math.max(maximumKernelSum, sum);\n });\n if (maximumKernelSum > L.maximumKernelSum) throw new InvalidConfigurationError(\"Sampled kernels exceed unit energy.\");\n return approximationResult({ schemaVersion: LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION, kernelRadius: radius, patchWidth, patchHeight,\n patchColumns: columns, patchRows: rows, kernels, exactSiteSampling: exact, maximumKernelSum, patches: Object.freeze(patches) },\n \"lens-psf-convolution-kernels\", LENS_PSF_CONVOLUTION_KERNELS_SCHEMA_VERSION, [\n \"One PSF per patch, resolved at the patch centre with resolveLensSampledPsf; indexed by source site in the convolution (isoplanatic patches).\",\n exact ? \"Every PSF tap maps to one integer site displacement (exact site sampling).\" :\n \"PSF taps between sites are split bilinearly (the adjoint of bilinear interpolation of site-centre samples); a labelled approximation.\",\n \"Relative pupil throughput is reported per patch and not applied to the unit-energy kernels.\"\n ]);\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { resolveLensPsfConvolutionKernels } from "./lens-psf-kernels.js";
1
+ import { type resolveLensPsfConvolutionKernels } from "./lens-psf-kernels.js";
2
2
  type Psf = Parameters<typeof resolveLensPsfConvolutionKernels>[0]["psf"];
3
3
  /** PSF records kept by value: at most 20 kernels of (2·32 + 1)² doubles each (676 KB) per record at the default spectral sampling. */
4
4
  export declare const SITE_DIFFRACTION_KERNEL_CACHE_SIZE = 4;
@@ -11,17 +11,27 @@
11
11
  * own fields (everything the kernel is a function of besides the wavelength, pitch and radius of the query). Records with an equal
12
12
  * string share their kernels, in a least-recently-used cache of `SITE_DIFFRACTION_KERNEL_CACHE_SIZE` records. A record that was not
13
13
  * registered (a caller's own profile) keeps its kernels while it lives, by identity, as before.
14
+ *
15
+ * 2.15.0: the most recently used entry also keeps one private, engine-owned clone of the profile, taken on its first resolve,
16
+ * and every later wavelength resolves from it (`resolveLensPsfConvolutionKernelsWithOwnedProfile`), so a request's profile is
17
+ * cloned and parsed once rather than once per wavelength; only one clone is held at a time. Before, N wavelengths cost N clones
18
+ * and parses of an N-wavelength profile, O(N²): at radius 32, 124 s for the 400 nodes of a 1 nm spectral step and 5.1 s for 80
19
+ * nodes, against 1.4 s and 0.12 s now (one machine, docs/PSF_FOUNDATION.md "Spectral nodes (2.15.0)"). The kernels are the same
20
+ * numbers (test/diffraction-spectral-grid.test.ts): both paths parse a structured clone of the same profile, and the clone is never changed.
14
21
  */
15
22
  import { InvalidConfigurationError } from "../core/configuration-error.js";
16
- import { resolveLensPsfConvolutionKernels } from "./lens-psf-kernels.js";
23
+ import { resolveLensPsfConvolutionKernelsWithOwnedProfile } from "./lens-psf-kernels.js";
24
+ import { markEngineOwnedLensSampledPsfProfile } from "./lens-psf-profile.js";
17
25
  /** PSF records kept by value: at most 20 kernels of (2·32 + 1)² doubles each (676 KB) per record at the default spectral sampling. */
18
26
  export const SITE_DIFFRACTION_KERNEL_CACHE_SIZE = 4;
19
27
  /** Canonical value key of each registered PSF record. */
20
28
  const keys = new WeakMap();
21
- /** Kernels per registered value key (least recently used first), then per wavelength, pitch and radius. */
29
+ /** Entries per registered value key (least recently used first). */
22
30
  const byValue = new Map();
23
- /** Kernels per unregistered PSF record (by identity: the record is large, so it is not serialized), then per wavelength, pitch and radius. */
31
+ /** Entries per unregistered PSF record (by identity: the record is large, so it is not serialized). */
24
32
  const byIdentity = new WeakMap();
33
+ /** The one entry that holds its private clone of the profile. */
34
+ let lastOwner;
25
35
  /** Kernels resolved so far (test hook). */
26
36
  let resolves = 0;
27
37
  /** JSON with every number spelled exactly: shortest round-trip digits, and -0, NaN and ±Infinity kept apart from 0 and null. */
@@ -37,34 +47,43 @@ export function registerDiffractionPsfKey(psf, profileInput) {
37
47
  const fields = Object.entries(psf).filter(([name]) => name !== "profile").sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
38
48
  keys.set(psf, canonical(["createDiffractionLimitedPsfProfile", profileInput, fields]));
39
49
  }
40
- /** The kernels kept for `psf`, made the most recently used. */
41
- function kernelsOf(psf) {
50
+ /** The entry kept for `psf`, made the most recently used. */
51
+ function entryOf(psf) {
42
52
  const key = keys.get(psf);
43
53
  if (key === undefined) {
44
- let kernels = byIdentity.get(psf);
45
- if (!kernels)
46
- byIdentity.set(psf, (kernels = new Map()));
47
- return kernels;
54
+ let entry = byIdentity.get(psf);
55
+ if (!entry)
56
+ byIdentity.set(psf, (entry = { kernels: new Map() }));
57
+ return entry;
48
58
  }
49
- let kernels = byValue.get(key);
50
- if (kernels)
59
+ let entry = byValue.get(key);
60
+ if (entry)
51
61
  byValue.delete(key);
52
62
  else
53
- kernels = new Map();
54
- byValue.set(key, kernels);
63
+ entry = { kernels: new Map() };
64
+ byValue.set(key, entry);
55
65
  while (byValue.size > SITE_DIFFRACTION_KERNEL_CACHE_SIZE)
56
66
  byValue.delete(byValue.keys().next().value);
57
- return kernels;
67
+ return entry;
58
68
  }
59
69
  /** The (2·radius + 1)² kernel of `psf` at `wavelengthNm` on a square site grid of `pitchMicrometers`; `failure` is the error message when the kernel cannot sample the pitch exactly. */
60
70
  export function siteDiffractionKernel(psf, wavelengthNm, pitchMicrometers, radius, failure) {
61
- const kernels = kernelsOf(psf);
71
+ const entry = entryOf(psf), kernels = entry.kernels;
62
72
  const key = canonical([wavelengthNm, pitchMicrometers, radius]);
63
73
  let kernel = kernels.get(key);
64
74
  if (!kernel) {
65
- const k = resolveLensPsfConvolutionKernels({ schemaVersion: "0.1.0", psf, spectralWeights: [{ wavelengthNm, weight: 1 }],
75
+ if (!entry.ownedProfile) {
76
+ const owned = structuredClone(psf.profile);
77
+ markEngineOwnedLensSampledPsfProfile(owned);
78
+ // Only the most recent entry keeps its clone (a request resolves its wavelengths in one run), so at most one is held.
79
+ if (lastOwner)
80
+ delete lastOwner.ownedProfile;
81
+ entry.ownedProfile = owned;
82
+ lastOwner = entry;
83
+ }
84
+ const k = resolveLensPsfConvolutionKernelsWithOwnedProfile({ schemaVersion: "0.1.0", psf, spectralWeights: [{ wavelengthNm, weight: 1 }],
66
85
  grid: { width: 1, height: 1, sitePitchMicrometers: { x: pitchMicrometers, y: pitchMicrometers }, opticalAxisSite: { x: 0.5, y: 0.5 } }, patchWidth: 1, patchHeight: 1,
67
- kernelRadius: radius }).value;
86
+ kernelRadius: radius }, entry.ownedProfile).value;
68
87
  if (!k.exactSiteSampling || k.kernelRadius !== radius)
69
88
  throw new InvalidConfigurationError(failure);
70
89
  kernel = Float64Array.from(k.kernels);
@@ -80,5 +99,6 @@ export function siteDiffractionKernelCacheState(psf) {
80
99
  /** Test hook: forgets every kernel kept by value. */
81
100
  export function clearSiteDiffractionKernelCache() {
82
101
  byValue.clear();
102
+ lastOwner = undefined;
83
103
  }
84
104
  //# sourceMappingURL=site-diffraction-kernel.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"site-diffraction-kernel.js","sourceRoot":"","sources":["../../src/optics/site-diffraction-kernel.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Internal (not exported from the package): one wavelength's diffraction kernel sampled on the site pitch, as the object-space\n * convolution (facade/diffraction-provider.ts) and the tabulated expected signal (facade/expected-signal-setup.ts) use it.\n *\n * `resolveLensPsfConvolutionKernels` parses and samples the PSF profile on every call (about 10 ms per wavelength), and both users\n * ask for the same kernels. The kernel is a function of its inputs alone; each caller gets its own copy.\n *\n * The facade builds a new PSF record for every request, so kernels are kept by value: `registerDiffractionPsfKey` records, for a\n * record whose profile `createDiffractionLimitedPsfProfile` built, a canonical string of that call's whole input and of the record's\n * own fields (everything the kernel is a function of besides the wavelength, pitch and radius of the query). Records with an equal\n * string share their kernels, in a least-recently-used cache of `SITE_DIFFRACTION_KERNEL_CACHE_SIZE` records. A record that was not\n * registered (a caller's own profile) keeps its kernels while it lives, by identity, as before.\n */\n\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { resolveLensPsfConvolutionKernels } from \"./lens-psf-kernels.js\";\n\ntype Psf = Parameters<typeof resolveLensPsfConvolutionKernels>[0][\"psf\"];\n\n/** PSF records kept by value: at most 20 kernels of (2·32 + 1)² doubles each (676 KB) per record at the default spectral sampling. */\nexport const SITE_DIFFRACTION_KERNEL_CACHE_SIZE = 4;\n\n/** Canonical value key of each registered PSF record. */\nconst keys = new WeakMap<object, string>();\n/** Kernels per registered value key (least recently used first), then per wavelength, pitch and radius. */\nconst byValue = new Map<string, Map<string, Float64Array>>();\n/** Kernels per unregistered PSF record (by identity: the record is large, so it is not serialized), then per wavelength, pitch and radius. */\nconst byIdentity = new WeakMap<object, Map<string, Float64Array>>();\n/** Kernels resolved so far (test hook). */\nlet resolves = 0;\n\n/** JSON with every number spelled exactly: shortest round-trip digits, and -0, NaN and ±Infinity kept apart from 0 and null. */\nfunction canonical(value: unknown): string {\n return JSON.stringify(value, (_key, v: unknown) => (typeof v === \"number\" && (Object.is(v, -0) || !Number.isFinite(v)) ? \"number:\" + (Object.is(v, -0) ? \"-0\" : String(v)) : v));\n}\n\n/**\n * Records that `psf.profile` is the profile `createDiffractionLimitedPsfProfile(profileInput)` returned, unmodified, so that the\n * kernels of `psf` are those of any record with the same `profileInput` and the same other fields. Call it right after building the\n * record; neither the record nor `profileInput` may change afterwards.\n */\nexport function registerDiffractionPsfKey(psf: Psf, profileInput: object): void {\n const fields = Object.entries(psf as object).filter(([name]) => name !== \"profile\").sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n keys.set(psf as object, canonical([\"createDiffractionLimitedPsfProfile\", profileInput, fields]));\n}\n\n/** The kernels kept for `psf`, made the most recently used. */\nfunction kernelsOf(psf: Psf): Map<string, Float64Array> {\n const key = keys.get(psf as object);\n if (key === undefined) {\n let kernels = byIdentity.get(psf as object);\n if (!kernels) byIdentity.set(psf as object, (kernels = new Map()));\n return kernels;\n }\n let kernels = byValue.get(key);\n if (kernels) byValue.delete(key);\n else kernels = new Map();\n byValue.set(key, kernels);\n while (byValue.size > SITE_DIFFRACTION_KERNEL_CACHE_SIZE) byValue.delete(byValue.keys().next().value!);\n return kernels;\n}\n\n/** The (2·radius + 1)² kernel of `psf` at `wavelengthNm` on a square site grid of `pitchMicrometers`; `failure` is the error message when the kernel cannot sample the pitch exactly. */\nexport function siteDiffractionKernel(psf: Psf, wavelengthNm: number, pitchMicrometers: number, radius: number, failure: string): Float64Array {\n const kernels = kernelsOf(psf);\n const key = canonical([wavelengthNm, pitchMicrometers, radius]);\n let kernel = kernels.get(key);\n if (!kernel) {\n const k = resolveLensPsfConvolutionKernels({ schemaVersion: \"0.1.0\", psf, spectralWeights: [{ wavelengthNm, weight: 1 }],\n grid: { width: 1, height: 1, sitePitchMicrometers: { x: pitchMicrometers, y: pitchMicrometers }, opticalAxisSite: { x: 0.5, y: 0.5 } }, patchWidth: 1, patchHeight: 1,\n kernelRadius: radius }).value;\n if (!k.exactSiteSampling || k.kernelRadius !== radius) throw new InvalidConfigurationError(failure);\n kernel = Float64Array.from(k.kernels);\n resolves++;\n kernels.set(key, kernel);\n }\n return Float64Array.from(kernel);\n}\n\n/** Test hook: the number of PSF records kept by value, the kernels resolved so far, and whether `psf` is registered. */\nexport function siteDiffractionKernelCacheState(psf?: Psf): { valueEntries: number; resolves: number; registered: boolean } {\n return { valueEntries: byValue.size, resolves, registered: psf !== undefined && keys.has(psf as object) };\n}\n\n/** Test hook: forgets every kernel kept by value. */\nexport function clearSiteDiffractionKernelCache(): void {\n byValue.clear();\n}\n"]}
1
+ {"version":3,"file":"site-diffraction-kernel.js","sourceRoot":"","sources":["../../src/optics/site-diffraction-kernel.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Internal (not exported from the package): one wavelength's diffraction kernel sampled on the site pitch, as the object-space\n * convolution (facade/diffraction-provider.ts) and the tabulated expected signal (facade/expected-signal-setup.ts) use it.\n *\n * `resolveLensPsfConvolutionKernels` parses and samples the PSF profile on every call (about 10 ms per wavelength), and both users\n * ask for the same kernels. The kernel is a function of its inputs alone; each caller gets its own copy.\n *\n * The facade builds a new PSF record for every request, so kernels are kept by value: `registerDiffractionPsfKey` records, for a\n * record whose profile `createDiffractionLimitedPsfProfile` built, a canonical string of that call's whole input and of the record's\n * own fields (everything the kernel is a function of besides the wavelength, pitch and radius of the query). Records with an equal\n * string share their kernels, in a least-recently-used cache of `SITE_DIFFRACTION_KERNEL_CACHE_SIZE` records. A record that was not\n * registered (a caller's own profile) keeps its kernels while it lives, by identity, as before.\n *\n * 2.15.0: the most recently used entry also keeps one private, engine-owned clone of the profile, taken on its first resolve,\n * and every later wavelength resolves from it (`resolveLensPsfConvolutionKernelsWithOwnedProfile`), so a request's profile is\n * cloned and parsed once rather than once per wavelength; only one clone is held at a time. Before, N wavelengths cost N clones\n * and parses of an N-wavelength profile, O(N²): at radius 32, 124 s for the 400 nodes of a 1 nm spectral step and 5.1 s for 80\n * nodes, against 1.4 s and 0.12 s now (one machine, docs/PSF_FOUNDATION.md \"Spectral nodes (2.15.0)\"). The kernels are the same\n * numbers (test/diffraction-spectral-grid.test.ts): both paths parse a structured clone of the same profile, and the clone is never changed.\n */\n\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { resolveLensPsfConvolutionKernelsWithOwnedProfile, type resolveLensPsfConvolutionKernels } from \"./lens-psf-kernels.js\";\nimport { markEngineOwnedLensSampledPsfProfile } from \"./lens-psf-profile.js\";\n\ntype Psf = Parameters<typeof resolveLensPsfConvolutionKernels>[0][\"psf\"];\n\n/** PSF records kept by value: at most 20 kernels of (2·32 + 1)² doubles each (676 KB) per record at the default spectral sampling. */\nexport const SITE_DIFFRACTION_KERNEL_CACHE_SIZE = 4;\n\n/** Canonical value key of each registered PSF record. */\nconst keys = new WeakMap<object, string>();\n/** One entry's kernels per wavelength, pitch and radius, and its private clone of the profile (taken on the first resolve). */\ninterface Entry { kernels: Map<string, Float64Array>; ownedProfile?: object }\n/** Entries per registered value key (least recently used first). */\nconst byValue = new Map<string, Entry>();\n/** Entries per unregistered PSF record (by identity: the record is large, so it is not serialized). */\nconst byIdentity = new WeakMap<object, Entry>();\n/** The one entry that holds its private clone of the profile. */\nlet lastOwner: Entry | undefined;\n/** Kernels resolved so far (test hook). */\nlet resolves = 0;\n\n/** JSON with every number spelled exactly: shortest round-trip digits, and -0, NaN and ±Infinity kept apart from 0 and null. */\nfunction canonical(value: unknown): string {\n return JSON.stringify(value, (_key, v: unknown) => (typeof v === \"number\" && (Object.is(v, -0) || !Number.isFinite(v)) ? \"number:\" + (Object.is(v, -0) ? \"-0\" : String(v)) : v));\n}\n\n/**\n * Records that `psf.profile` is the profile `createDiffractionLimitedPsfProfile(profileInput)` returned, unmodified, so that the\n * kernels of `psf` are those of any record with the same `profileInput` and the same other fields. Call it right after building the\n * record; neither the record nor `profileInput` may change afterwards.\n */\nexport function registerDiffractionPsfKey(psf: Psf, profileInput: object): void {\n const fields = Object.entries(psf as object).filter(([name]) => name !== \"profile\").sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n keys.set(psf as object, canonical([\"createDiffractionLimitedPsfProfile\", profileInput, fields]));\n}\n\n/** The entry kept for `psf`, made the most recently used. */\nfunction entryOf(psf: Psf): Entry {\n const key = keys.get(psf as object);\n if (key === undefined) {\n let entry = byIdentity.get(psf as object);\n if (!entry) byIdentity.set(psf as object, (entry = { kernels: new Map() }));\n return entry;\n }\n let entry = byValue.get(key);\n if (entry) byValue.delete(key);\n else entry = { kernels: new Map() };\n byValue.set(key, entry);\n while (byValue.size > SITE_DIFFRACTION_KERNEL_CACHE_SIZE) byValue.delete(byValue.keys().next().value!);\n return entry;\n}\n\n/** The (2·radius + 1)² kernel of `psf` at `wavelengthNm` on a square site grid of `pitchMicrometers`; `failure` is the error message when the kernel cannot sample the pitch exactly. */\nexport function siteDiffractionKernel(psf: Psf, wavelengthNm: number, pitchMicrometers: number, radius: number, failure: string): Float64Array {\n const entry = entryOf(psf), kernels = entry.kernels;\n const key = canonical([wavelengthNm, pitchMicrometers, radius]);\n let kernel = kernels.get(key);\n if (!kernel) {\n if (!entry.ownedProfile) {\n const owned = structuredClone((psf as { profile: unknown }).profile) as object;\n markEngineOwnedLensSampledPsfProfile(owned);\n // Only the most recent entry keeps its clone (a request resolves its wavelengths in one run), so at most one is held.\n if (lastOwner) delete lastOwner.ownedProfile;\n entry.ownedProfile = owned;\n lastOwner = entry;\n }\n const k = resolveLensPsfConvolutionKernelsWithOwnedProfile({ schemaVersion: \"0.1.0\", psf, spectralWeights: [{ wavelengthNm, weight: 1 }],\n grid: { width: 1, height: 1, sitePitchMicrometers: { x: pitchMicrometers, y: pitchMicrometers }, opticalAxisSite: { x: 0.5, y: 0.5 } }, patchWidth: 1, patchHeight: 1,\n kernelRadius: radius }, entry.ownedProfile).value;\n if (!k.exactSiteSampling || k.kernelRadius !== radius) throw new InvalidConfigurationError(failure);\n kernel = Float64Array.from(k.kernels);\n resolves++;\n kernels.set(key, kernel);\n }\n return Float64Array.from(kernel);\n}\n\n/** Test hook: the number of PSF records kept by value, the kernels resolved so far, and whether `psf` is registered. */\nexport function siteDiffractionKernelCacheState(psf?: Psf): { valueEntries: number; resolves: number; registered: boolean } {\n return { valueEntries: byValue.size, resolves, registered: psf !== undefined && keys.has(psf as object) };\n}\n\n/** Test hook: forgets every kernel kept by value. */\nexport function clearSiteDiffractionKernelCache(): void {\n byValue.clear();\n lastOwner = undefined;\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // castleCSF numerical model material: Copyright (c) 2023 Graphics and Displays
3
- // group - University of Cambridge. MIT terms retained in NOTICE and
4
- // docs/licenses/castleCSF-MIT.txt. Original bounded TypeScript adapter.
3
+ // group - University of Cambridge. MIT terms retained in NOTICE (the full
4
+ // licence text). Original bounded TypeScript adapter.
5
5
  /** Contrast detection threshold for a static, neutral (D65 white) Gabor patch, from the castleCSF
6
6
  * contrast sensitivity function (CSF) model. A Gabor patch is a sine grating under a Gaussian envelope.
7
7
  * It does not say whether detail in a photograph will be visible.
@@ -1 +1 @@
1
- {"version":3,"file":"print-contrast-reference.js","sourceRoot":"","sources":["../../src/output/print-contrast-reference.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,+EAA+E;AAC/E,oEAAoE;AACpE,wEAAwE;AAExE;;;;GAIG;AACH,OAAO,EAAE,eAAe,EAA0B,MAAM,+BAA+B,CAAC;AACxF,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,wBAAwB,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAC/F,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAExD,MAAM,CAAC,MAAM,sCAAsC,GAAG,OAAgB,CAAC;AACvE,qFAAqF;AACrF,MAAM,CAAC,MAAM,0CAA0C,GAAG,0CAAmD,CAAC;AAkC9G,oHAAoH;AACpH,MAAM,UAAU,gCAAgC,CAAC,KAAc;IAC7D,MAAM,CAAC,GAAG,wBAAwB,CAAC,KAAK,EAAE,CAAC,aAAa,EAAE,UAAU,EAAE,UAAU,EAAE,+BAA+B,EAAE,iCAAiC;QAClJ,8BAA8B,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,qBAAqB,CAAC,EAAE,0CAA0C,CAAC,CAAC;IACpJ,MAAM,MAAM,GAAG,CAAC,GAAW,EAAE,QAAiB,EAAU,EAAE;QACxD,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;QACjB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;YAAE,MAAM,IAAI,yBAAyB,CAAC,iFAAiF,CAAC,CAAC;QACxM,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACzB,CAAC,CAAC;IACF,IAAI,CAAC,CAAC,QAAQ,KAAK,0BAA0B,IAAI,CAAC,CAAC,QAAQ,KAAK,6CAA6C;QAAE,MAAM,IAAI,yBAAyB,CAAC,4CAA4C,CAAC,CAAC;IACjM,MAAM,mBAAmB,GAAG,MAAM,CAAC,qBAAqB,EAAE,KAAK,CAAC,CAAC;IACjE,IAAI,mBAAmB,GAAG,CAAC;QAAE,MAAM,IAAI,yBAAyB,CAAC,qDAAqD,CAAC,CAAC;IACxH,OAAO,EAAE,WAAW,EAAE,qBAAqB,CAAC,CAAC,CAAC,WAAW,EAAE,mDAAmD,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ;QACzJ,6BAA6B,EAAE,MAAM,CAAC,+BAA+B,EAAE,IAAI,CAAC,EAAE,+BAA+B,EAAE,MAAM,CAAC,iCAAiC,EAAE,IAAI,CAAC;QAC9J,4BAA4B,EAAE,MAAM,CAAC,8BAA8B,EAAE,IAAI,CAAC,EAAE,mBAAmB,EAAE,MAAM,CAAC,qBAAqB,EAAE,KAAK,CAAC;QACrI,mBAAmB,EAAE,MAAM,CAAC,qBAAqB,EAAE,KAAK,CAAC,EAAE,mBAAmB,EAAE,CAAC;AACrF,CAAC;AAED,2EAA2E;AAC3E,yEAAyE;AACzE,SAAS,mBAAmB,CAAC,CAAoB,EAAE,SAAiB;IAClE,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC,CAAC,CAAE,GAAG,SAAS,IAAI,CAAC,CAAC,CAAC,CAAE,CAAC;IACtD,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,CAAE,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,SAAS,CAAC,CAAC,CAAC;IACvE,OAAO,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,SAAS,CAAC,CAAC,CAAC;AAC9F,CAAC;AACD,SAAS,SAAS,CAAC,SAAiB,EAAE,IAAY,EAAE,IAAY,EAAE,aAAqB,EAAE,SAAiB,EAAE,iBAAyB,EAAE,EAAU,EAAE,EAAU;IAC3J,MAAM,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;IACxE,MAAM,QAAQ,GAAG,EAAE,IAAI,CAAC,CAAC,aAAa,GAAG,aAAa,GAAG,CAAC,IAAI,SAAS,CAAC,CAAC;IACzE,MAAM,KAAK,GAAG,SAAS,GAAG,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,iBAAiB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC3F,MAAM,YAAY,GAAG,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,SAAS,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IACtD,OAAO,IAAI,GAAG,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC,GAAG,YAAY,GAAG,IAAI,CAAC,CAAC,GAAG,SAAS,CAAC;AACxF,CAAC;AACD,SAAS,wBAAwB,CAAC,SAAiB,EAAE,SAAiB;IACpE,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,GAAG,GAAG,IAAI,CAAC,CAAC;IAChC,MAAM,SAAS,GAAG,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,mBAAmB,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,SAAS,CAAC,EAC7H,mBAAmB,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,SAAS,CAAC,EAAE,UAAU,EAAE,CAAC,GAAG,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC1G,MAAM,SAAS,GAAG,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,mBAAmB,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,SAAS,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,GAAG,UAAU,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IACvJ,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,GAAG,OAAO,GAAG,MAAM,CAAC;IAC3D,MAAM,UAAU,GAAG,SAAS,GAAG,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC;IAC7F,MAAM,QAAQ,GAAG,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,mBAAmB,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,SAAS,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IAClJ,MAAM,YAAY,GAAG,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,mBAAmB,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;IAC1J,qFAAqF;IACrF,mFAAmF;IACnF,MAAM,kBAAkB,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC;IAC5D,MAAM,sBAAsB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG,KAAK,GAAG,OAAO,GAAG,KAAK,CAAC,CAAC;IAC1E,OAAO,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,kBAAkB,GAAG,QAAQ,EAAE,sBAAsB,GAAG,YAAY,CAAC,CAAC;AACtG,CAAC;AAED,iKAAiK;AACjK,MAAM,UAAU,+BAA+B,CAAC,KAAkC;IAChF,MAAM,KAAK,GAAG,gCAAgC,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,6BAA6B,EAAE,CAAC,GAAG,KAAK,CAAC,+BAA+B,CAAC;IAC1I,MAAM,MAAM,GAA2B,EAAE,WAAW,EAAE,KAAK,CAAC,WAAW,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,YAAY,EAAE,sCAAsC;QACjK,gBAAgB,EAAE,0CAA0C,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI;QACxH,qBAAqB,EAAE,gDAAgD,EAAE,sBAAsB,EAAE,YAAY,EAAE,kBAAkB,EAAE,YAAY;QAC/I,WAAW,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,aAAa,EAAE,CAAC;IACtE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,KAAK,CAAC,4BAA4B,KAAK,GAAG,IAAI,KAAK,CAAC,mBAAmB,KAAK,CAAC,IAAI,KAAK,CAAC,mBAAmB,KAAK,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,yCAAyC,CAAC,CAAC;SAC3N,CAAC;QACJ,MAAM,WAAW,GAAG,wBAAwB,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,SAAS,GAAG,CAAC,GAAG,WAAW,EAAE,KAAK,GAAG,KAAK,CAAC,mBAAmB,GAAG,WAAW,CAAC;QACjI,IAAI,CAAC,CAAC,WAAW,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,WAAW,IAAI,CAAC,IAAI,SAAS,IAAI,CAAC;YAAE,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,8CAA8C,CAAC,CAAC;aACnK,CAAC;YAAC,MAAM,CAAC,MAAM,GAAG,gBAAgB,CAAC;YAAC,MAAM,CAAC,QAAQ,GAAG,EAAE,mBAAmB,EAAE,WAAW,EAAE,4BAA4B,EAAE,SAAS,EAAE,uCAAuC,EAAE,KAAK,EAAE,CAAC;QAAC,CAAC;IAC7L,CAAC;IACD,OAAO,eAAe,CAAC,eAAe,CAAC,MAAM,EAAE,wCAAwC,EAAE,sCAAsC,EAC7H,CAAC,6FAA6F;QAC5F,sIAAsI;QACtI,sIAAsI,CAAC,EAAE;QACzI,WAAW,EAAE;YACX,EAAE,aAAa,EAAE,+BAA+B,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,qEAAqE,EAAE;YAC1K,EAAE,aAAa,EAAE,iCAAiC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,EAAE;YACtG,EAAE,aAAa,EAAE,8BAA8B,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE;YAC7F,EAAE,aAAa,EAAE,qBAAqB,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE;YAC5E,EAAE,aAAa,EAAE,qBAAqB,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE;SACjF,EAAE,KAAK,EAAE,CAAC,wGAAwG,CAAC;KACrH,CAAC,CAAC,CAAC;AACR,CAAC","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n// castleCSF numerical model material: Copyright (c) 2023 Graphics and Displays\n// group - University of Cambridge. MIT terms retained in NOTICE and\n// docs/licenses/castleCSF-MIT.txt. Original bounded TypeScript adapter.\n\n/** Contrast detection threshold for a static, neutral (D65 white) Gabor patch, from the castleCSF\n * contrast sensitivity function (CSF) model. A Gabor patch is a sine grating under a Gaussian envelope.\n * It does not say whether detail in a photograph will be visible.\n * @see docs/PRINT_CONTRAST_REFERENCE.md for exact source, applicability and review gates.\n */\nimport { estimatedResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { freezeOwnedData } from \"../core/owned-data.js\";\nimport { requireAllowlistedRecord, requirePublicOpaqueId } from \"../core/record-validation.js\";\nimport { ENGINE_API_VERSION } from \"../core/version.js\";\n\nexport const PRINT_CONTRAST_REFERENCE_MODEL_VERSION = \"0.1.0\" as const;\n/** Identifies the exact MIT-licensed model source. It is not a local calibration. */\nexport const PRINT_CONTRAST_REFERENCE_UPSTREAM_REVISION = \"f4b0b722af83001d7af979281e06ca642d36e4e8\" as const;\n\n/** The narrow reference stimulus. It is separate from any measured photographic region of interest (ROI).\n * Positive values outside the model's range return \"unsupported\" instead of being extrapolated.\n */\nexport interface PrintContrastReferenceInput {\n referenceId: string;\n stimulus: \"static-neutral-d65-gabor\";\n observer: \"published-binocular-natural-pupil-reference\";\n /** Mean luminance of the uniform D65 background and grating. Not the average of a photographic ROI. */\n meanLuminanceCdPerSquareMeter: number;\n spatialFrequencyCyclesPerDegree: number;\n gaussianEnvelopeSigmaDegrees: number;\n temporalFrequencyHz: number;\n eccentricityDegrees: number;\n /** Fractional amplitude of the neutral grating before the Gaussian envelope. Not taken from ROI minimum and maximum values. */\n modulationMichelson: number;\n}\nexport interface PrintContrastReference {\n referenceId: string;\n engineApiVersion: string;\n modelVersion: typeof PRINT_CONTRAST_REFERENCE_MODEL_VERSION;\n upstreamRevision: typeof PRINT_CONTRAST_REFERENCE_UPSTREAM_REVISION;\n input: PrintContrastReferenceInput;\n status: \"model-estimate\" | \"unsupported\";\n blockers: string[];\n estimate: { contrastSensitivity: number; thresholdModulationMichelson: number; suppliedModulationToModelThresholdRatio: number } | null;\n observerApplicability: \"population-reference-unverified-for-individual\";\n naturalImageVisibility: \"unassessed\";\n printStimulusMatch: \"unassessed\";\n uncertainty: \"not-quantified\";\n overallPrintVerdict: \"not-offered\";\n}\n\n/** Strictly parses and copies the explicit reference conditions. No defaults are filled in to imply an observer. */\nexport function parsePrintContrastReferenceInput(value: unknown): PrintContrastReferenceInput {\n const r = requireAllowlistedRecord(value, [\"referenceId\", \"stimulus\", \"observer\", \"meanLuminanceCdPerSquareMeter\", \"spatialFrequencyCyclesPerDegree\",\n \"gaussianEnvelopeSigmaDegrees\", \"temporalFrequencyHz\", \"eccentricityDegrees\", \"modulationMichelson\"], \"Invalid Print contrast reference fields.\");\n const number = (key: string, positive: boolean): number => {\n const v = r[key];\n if (typeof v !== \"number\" || !Number.isFinite(v) || (positive ? v <= 0 : v < 0)) throw new InvalidConfigurationError(\"Print contrast reference quantities must be finite and in their numeric domain.\");\n return v === 0 ? 0 : v;\n };\n if (r.stimulus !== \"static-neutral-d65-gabor\" || r.observer !== \"published-binocular-natural-pupil-reference\") throw new InvalidConfigurationError(\"Unknown Print reference stimulus/observer.\");\n const modulationMichelson = number(\"modulationMichelson\", false);\n if (modulationMichelson > 1) throw new InvalidConfigurationError(\"Reference Michelson modulation must be at most one.\");\n return { referenceId: requirePublicOpaqueId(r.referenceId, \"Invalid public Print contrast reference identity.\"), stimulus: r.stimulus, observer: r.observer,\n meanLuminanceCdPerSquareMeter: number(\"meanLuminanceCdPerSquareMeter\", true), spatialFrequencyCyclesPerDegree: number(\"spatialFrequencyCyclesPerDegree\", true),\n gaussianEnvelopeSigmaDegrees: number(\"gaussianEnvelopeSigmaDegrees\", true), temporalFrequencyHz: number(\"temporalFrequencyHz\", false),\n eccentricityDegrees: number(\"eccentricityDegrees\", false), modulationMichelson };\n}\n\n// Independently written scalar evaluation of the licensed numerical model.\n// Using log1p/expm1 keeps the small hyperbolic saturation term accurate.\nfunction luminanceDependency(p: readonly number[], luminance: number): number {\n if (p.length === 2) return p[1]! * luminance ** p[0]!;\n const first = p[0]! * Math.exp(-p[2]! * Math.log1p(p[1]! / luminance));\n return p.length === 3 ? first : first * -Math.expm1(-p[4]! * Math.log1p(p[3]! / luminance));\n}\nfunction component(frequency: number, area: number, peak: number, peakFrequency: number, bandwidth: number, lowFrequencyFloor: number, a0: number, f0: number): number {\n const logDifference = Math.log10(frequency) - Math.log10(peakFrequency);\n const parabola = 10 ** (-logDifference * logDifference / 2 ** bandwidth);\n const shape = frequency < peakFrequency ? Math.max(parabola, lowFrequencyFloor) : parabola;\n const criticalArea = a0 / (1 + (frequency / f0) ** 2);\n return peak * shape * Math.sqrt(criticalArea / (1 + criticalArea / area)) * frequency;\n}\nfunction staticNeutralSensitivity(luminance: number, frequency: number): number {\n const area = Math.PI * 1.5 ** 2;\n const sustained = component(frequency, area, luminanceDependency([56.4947, 7.54726, .144532, 5.58341e-7, 9.66862e9], luminance),\n luminanceDependency([1.78119, 91.5718, .256682], luminance), .000213047, 1 - .100207, 157.103, .702338);\n const transient = component(frequency, area, luminanceDependency([.193434, 2748.09], luminance), .000316696, 2.6761, 1 - .000241177, 3.81611, 3.01389);\n const omegaPeak = Math.log10(luminance) * 2.41482 + 4.7036;\n const achromatic = sustained + transient * Math.exp(-((omegaPeak ** .1898) ** 2) / .0844836);\n const redGreen = component(frequency, area, luminanceDependency([681.434, 38.0038, .480386], luminance), .0178364, 2.42104, 1, 2816.44, .0711058);\n const yellowViolet = component(frequency, area, luminanceDependency([166.683, 62.8974, .41193], luminance), .00425753, 2.68197, 1, 2.82789e7, .000635093);\n // Modulate each cone channel of the D65 LMS background [L,M,S] by the same fraction.\n // All three mechanism responses are kept, including nonzero colour-opponent parts.\n const redGreenProjection = Math.abs(.6991 - 2.3112 * .3009);\n const yellowVioletProjection = Math.abs(-.6991 - .3009 + 50.9875 * .0198);\n return Math.hypot(achromatic, redGreenProjection * redGreen, yellowVioletProjection * yellowViolet);\n}\n\n/** Estimates the detection threshold from the published model, within its limited range. It does not claim that detail is invisible to any particular person. */\nexport function calculatePrintContrastReference(value: PrintContrastReferenceInput): CalculationResult<PrintContrastReference> {\n const input = parsePrintContrastReferenceInput(value), l = input.meanLuminanceCdPerSquareMeter, f = input.spatialFrequencyCyclesPerDegree;\n const result: PrintContrastReference = { referenceId: input.referenceId, engineApiVersion: ENGINE_API_VERSION, modelVersion: PRINT_CONTRAST_REFERENCE_MODEL_VERSION,\n upstreamRevision: PRINT_CONTRAST_REFERENCE_UPSTREAM_REVISION, input, status: \"unsupported\", blockers: [], estimate: null,\n observerApplicability: \"population-reference-unverified-for-individual\", naturalImageVisibility: \"unassessed\", printStimulusMatch: \"unassessed\",\n uncertainty: \"not-quantified\", overallPrintVerdict: \"not-offered\" };\n if (l < 1 || l > 1000 || f < .25 || f > 16 || input.gaussianEnvelopeSigmaDegrees !== 1.5 || input.temporalFrequencyHz !== 0 || input.eccentricityDegrees !== 0) result.blockers.push(\"outside-static-neutral-reference-domain\");\n else {\n const sensitivity = staticNeutralSensitivity(l, f), threshold = 1 / sensitivity, ratio = input.modulationMichelson * sensitivity;\n if (![sensitivity, threshold, ratio].every(Number.isFinite) || sensitivity <= 0 || threshold <= 0) result.blockers.push(\"contrast-reference-numeric-range-unsupported\");\n else { result.status = \"model-estimate\"; result.estimate = { contrastSensitivity: sensitivity, thresholdModulationMichelson: threshold, suppliedModulationToModelThresholdRatio: ratio }; }\n }\n return freezeOwnedData(estimatedResult(result, \"castlecsf-static-neutral-d65-reference\", PRINT_CONTRAST_REFERENCE_MODEL_VERSION,\n [\"Static foveal D65 equal-fraction LMS Gabor; Gaussian envelope sigma is exactly 1.5 degrees.\",\n \"Published population model reference; personal eyesight, natural-image masking and actual print-stimulus matching remain unassessed.\",\n \"The numerical envelope is restricted; published model-fit errors do not become an individual confidence interval or a print verdict.\"], {\n validRanges: [\n { parameterPath: \"meanLuminanceCdPerSquareMeter\", unit: \"cd/m^2\", minimum: 1, maximum: 1000, note: \"Restricted numerical reference envelope; no individual calibration.\" },\n { parameterPath: \"spatialFrequencyCyclesPerDegree\", unit: \"cycles/degree\", minimum: .25, maximum: 16 },\n { parameterPath: \"gaussianEnvelopeSigmaDegrees\", unit: \"degree\", minimum: 1.5, maximum: 1.5 },\n { parameterPath: \"temporalFrequencyHz\", unit: \"Hz\", minimum: 0, maximum: 0 },\n { parameterPath: \"eccentricityDegrees\", unit: \"degree\", minimum: 0, maximum: 0 }\n ], notes: [\"Uncertainty is not quantified; numerical conformance is distinct from stimulus/observer qualification.\"]\n }));\n}\n"]}
1
+ {"version":3,"file":"print-contrast-reference.js","sourceRoot":"","sources":["../../src/output/print-contrast-reference.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n// castleCSF numerical model material: Copyright (c) 2023 Graphics and Displays\n// group - University of Cambridge. MIT terms retained in NOTICE (the full\n// licence text). Original bounded TypeScript adapter.\n\n/** Contrast detection threshold for a static, neutral (D65 white) Gabor patch, from the castleCSF\n * contrast sensitivity function (CSF) model. A Gabor patch is a sine grating under a Gaussian envelope.\n * It does not say whether detail in a photograph will be visible.\n * @see docs/PRINT_CONTRAST_REFERENCE.md for exact source, applicability and review gates.\n */\nimport { estimatedResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { freezeOwnedData } from \"../core/owned-data.js\";\nimport { requireAllowlistedRecord, requirePublicOpaqueId } from \"../core/record-validation.js\";\nimport { ENGINE_API_VERSION } from \"../core/version.js\";\n\nexport const PRINT_CONTRAST_REFERENCE_MODEL_VERSION = \"0.1.0\" as const;\n/** Identifies the exact MIT-licensed model source. It is not a local calibration. */\nexport const PRINT_CONTRAST_REFERENCE_UPSTREAM_REVISION = \"f4b0b722af83001d7af979281e06ca642d36e4e8\" as const;\n\n/** The narrow reference stimulus. It is separate from any measured photographic region of interest (ROI).\n * Positive values outside the model's range return \"unsupported\" instead of being extrapolated.\n */\nexport interface PrintContrastReferenceInput {\n referenceId: string;\n stimulus: \"static-neutral-d65-gabor\";\n observer: \"published-binocular-natural-pupil-reference\";\n /** Mean luminance of the uniform D65 background and grating. Not the average of a photographic ROI. */\n meanLuminanceCdPerSquareMeter: number;\n spatialFrequencyCyclesPerDegree: number;\n gaussianEnvelopeSigmaDegrees: number;\n temporalFrequencyHz: number;\n eccentricityDegrees: number;\n /** Fractional amplitude of the neutral grating before the Gaussian envelope. Not taken from ROI minimum and maximum values. */\n modulationMichelson: number;\n}\nexport interface PrintContrastReference {\n referenceId: string;\n engineApiVersion: string;\n modelVersion: typeof PRINT_CONTRAST_REFERENCE_MODEL_VERSION;\n upstreamRevision: typeof PRINT_CONTRAST_REFERENCE_UPSTREAM_REVISION;\n input: PrintContrastReferenceInput;\n status: \"model-estimate\" | \"unsupported\";\n blockers: string[];\n estimate: { contrastSensitivity: number; thresholdModulationMichelson: number; suppliedModulationToModelThresholdRatio: number } | null;\n observerApplicability: \"population-reference-unverified-for-individual\";\n naturalImageVisibility: \"unassessed\";\n printStimulusMatch: \"unassessed\";\n uncertainty: \"not-quantified\";\n overallPrintVerdict: \"not-offered\";\n}\n\n/** Strictly parses and copies the explicit reference conditions. No defaults are filled in to imply an observer. */\nexport function parsePrintContrastReferenceInput(value: unknown): PrintContrastReferenceInput {\n const r = requireAllowlistedRecord(value, [\"referenceId\", \"stimulus\", \"observer\", \"meanLuminanceCdPerSquareMeter\", \"spatialFrequencyCyclesPerDegree\",\n \"gaussianEnvelopeSigmaDegrees\", \"temporalFrequencyHz\", \"eccentricityDegrees\", \"modulationMichelson\"], \"Invalid Print contrast reference fields.\");\n const number = (key: string, positive: boolean): number => {\n const v = r[key];\n if (typeof v !== \"number\" || !Number.isFinite(v) || (positive ? v <= 0 : v < 0)) throw new InvalidConfigurationError(\"Print contrast reference quantities must be finite and in their numeric domain.\");\n return v === 0 ? 0 : v;\n };\n if (r.stimulus !== \"static-neutral-d65-gabor\" || r.observer !== \"published-binocular-natural-pupil-reference\") throw new InvalidConfigurationError(\"Unknown Print reference stimulus/observer.\");\n const modulationMichelson = number(\"modulationMichelson\", false);\n if (modulationMichelson > 1) throw new InvalidConfigurationError(\"Reference Michelson modulation must be at most one.\");\n return { referenceId: requirePublicOpaqueId(r.referenceId, \"Invalid public Print contrast reference identity.\"), stimulus: r.stimulus, observer: r.observer,\n meanLuminanceCdPerSquareMeter: number(\"meanLuminanceCdPerSquareMeter\", true), spatialFrequencyCyclesPerDegree: number(\"spatialFrequencyCyclesPerDegree\", true),\n gaussianEnvelopeSigmaDegrees: number(\"gaussianEnvelopeSigmaDegrees\", true), temporalFrequencyHz: number(\"temporalFrequencyHz\", false),\n eccentricityDegrees: number(\"eccentricityDegrees\", false), modulationMichelson };\n}\n\n// Independently written scalar evaluation of the licensed numerical model.\n// Using log1p/expm1 keeps the small hyperbolic saturation term accurate.\nfunction luminanceDependency(p: readonly number[], luminance: number): number {\n if (p.length === 2) return p[1]! * luminance ** p[0]!;\n const first = p[0]! * Math.exp(-p[2]! * Math.log1p(p[1]! / luminance));\n return p.length === 3 ? first : first * -Math.expm1(-p[4]! * Math.log1p(p[3]! / luminance));\n}\nfunction component(frequency: number, area: number, peak: number, peakFrequency: number, bandwidth: number, lowFrequencyFloor: number, a0: number, f0: number): number {\n const logDifference = Math.log10(frequency) - Math.log10(peakFrequency);\n const parabola = 10 ** (-logDifference * logDifference / 2 ** bandwidth);\n const shape = frequency < peakFrequency ? Math.max(parabola, lowFrequencyFloor) : parabola;\n const criticalArea = a0 / (1 + (frequency / f0) ** 2);\n return peak * shape * Math.sqrt(criticalArea / (1 + criticalArea / area)) * frequency;\n}\nfunction staticNeutralSensitivity(luminance: number, frequency: number): number {\n const area = Math.PI * 1.5 ** 2;\n const sustained = component(frequency, area, luminanceDependency([56.4947, 7.54726, .144532, 5.58341e-7, 9.66862e9], luminance),\n luminanceDependency([1.78119, 91.5718, .256682], luminance), .000213047, 1 - .100207, 157.103, .702338);\n const transient = component(frequency, area, luminanceDependency([.193434, 2748.09], luminance), .000316696, 2.6761, 1 - .000241177, 3.81611, 3.01389);\n const omegaPeak = Math.log10(luminance) * 2.41482 + 4.7036;\n const achromatic = sustained + transient * Math.exp(-((omegaPeak ** .1898) ** 2) / .0844836);\n const redGreen = component(frequency, area, luminanceDependency([681.434, 38.0038, .480386], luminance), .0178364, 2.42104, 1, 2816.44, .0711058);\n const yellowViolet = component(frequency, area, luminanceDependency([166.683, 62.8974, .41193], luminance), .00425753, 2.68197, 1, 2.82789e7, .000635093);\n // Modulate each cone channel of the D65 LMS background [L,M,S] by the same fraction.\n // All three mechanism responses are kept, including nonzero colour-opponent parts.\n const redGreenProjection = Math.abs(.6991 - 2.3112 * .3009);\n const yellowVioletProjection = Math.abs(-.6991 - .3009 + 50.9875 * .0198);\n return Math.hypot(achromatic, redGreenProjection * redGreen, yellowVioletProjection * yellowViolet);\n}\n\n/** Estimates the detection threshold from the published model, within its limited range. It does not claim that detail is invisible to any particular person. */\nexport function calculatePrintContrastReference(value: PrintContrastReferenceInput): CalculationResult<PrintContrastReference> {\n const input = parsePrintContrastReferenceInput(value), l = input.meanLuminanceCdPerSquareMeter, f = input.spatialFrequencyCyclesPerDegree;\n const result: PrintContrastReference = { referenceId: input.referenceId, engineApiVersion: ENGINE_API_VERSION, modelVersion: PRINT_CONTRAST_REFERENCE_MODEL_VERSION,\n upstreamRevision: PRINT_CONTRAST_REFERENCE_UPSTREAM_REVISION, input, status: \"unsupported\", blockers: [], estimate: null,\n observerApplicability: \"population-reference-unverified-for-individual\", naturalImageVisibility: \"unassessed\", printStimulusMatch: \"unassessed\",\n uncertainty: \"not-quantified\", overallPrintVerdict: \"not-offered\" };\n if (l < 1 || l > 1000 || f < .25 || f > 16 || input.gaussianEnvelopeSigmaDegrees !== 1.5 || input.temporalFrequencyHz !== 0 || input.eccentricityDegrees !== 0) result.blockers.push(\"outside-static-neutral-reference-domain\");\n else {\n const sensitivity = staticNeutralSensitivity(l, f), threshold = 1 / sensitivity, ratio = input.modulationMichelson * sensitivity;\n if (![sensitivity, threshold, ratio].every(Number.isFinite) || sensitivity <= 0 || threshold <= 0) result.blockers.push(\"contrast-reference-numeric-range-unsupported\");\n else { result.status = \"model-estimate\"; result.estimate = { contrastSensitivity: sensitivity, thresholdModulationMichelson: threshold, suppliedModulationToModelThresholdRatio: ratio }; }\n }\n return freezeOwnedData(estimatedResult(result, \"castlecsf-static-neutral-d65-reference\", PRINT_CONTRAST_REFERENCE_MODEL_VERSION,\n [\"Static foveal D65 equal-fraction LMS Gabor; Gaussian envelope sigma is exactly 1.5 degrees.\",\n \"Published population model reference; personal eyesight, natural-image masking and actual print-stimulus matching remain unassessed.\",\n \"The numerical envelope is restricted; published model-fit errors do not become an individual confidence interval or a print verdict.\"], {\n validRanges: [\n { parameterPath: \"meanLuminanceCdPerSquareMeter\", unit: \"cd/m^2\", minimum: 1, maximum: 1000, note: \"Restricted numerical reference envelope; no individual calibration.\" },\n { parameterPath: \"spatialFrequencyCyclesPerDegree\", unit: \"cycles/degree\", minimum: .25, maximum: 16 },\n { parameterPath: \"gaussianEnvelopeSigmaDegrees\", unit: \"degree\", minimum: 1.5, maximum: 1.5 },\n { parameterPath: \"temporalFrequencyHz\", unit: \"Hz\", minimum: 0, maximum: 0 },\n { parameterPath: \"eccentricityDegrees\", unit: \"degree\", minimum: 0, maximum: 0 }\n ], notes: [\"Uncertainty is not quantified; numerical conformance is distinct from stimulus/observer qualification.\"]\n }));\n}\n"]}
@@ -26,6 +26,7 @@ one-call capture, see the [Quick Start](QUICK_START.md). For every export, see t
26
26
  | Compare two exposures in stops | [`calculateRelativeRenderedExposure`](api/exposure-exposure.md#calculaterelativerenderedexposure) | Includes ISO as gain. |
27
27
  | Depth of field and hyperfocal distance | [`calculateDepthOfField`](api/optics-depth-of-field.md#calculatedepthoffield) | Thin-lens model. `farLimitM` is `null` when it is infinite. |
28
28
  | Blur of an out-of-focus subject | [`calculateDefocusCircle`](api/optics-depth-of-field.md#calculatedefocuscircle) | Geometric blur-circle diameter at the sensor. |
29
+ | Check a defocus blur before rendering it | [`calculateDefocusDiscKernelRadius`](api/optics-defocus-disc-kernel.md#calculatedefocusdisckernelradius) | Whether `lens.defocus` renders the disc or refuses it (kernel radius at most `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`, 64 sites), and by how much it is over. |
29
30
  | Field (angle) of view | [`calculateFieldOfView`](api/camera-field-of-view.md#calculatefieldofview) | Pass the sensor width, height or diagonal for that angle. |
30
31
  | Crop factor and 35 mm-equivalent focal length | [`calculateEquivalentFocalLength35Mm`](api/camera-equivalent-focal-length.md#calculateequivalentfocallength35mm) | Diagonal-based, against a 36 × 24 mm frame. |
31
32
  | Pixels left after a centred crop | [`calculateCenteredCrop`](api/output-crop.md#calculatecenteredcrop) | Keeps the aspect ratio to whole pixels. |
@@ -1,6 +1,6 @@
1
1
  # Public API reference
2
2
 
3
- Generated from the exact root exports for **@photivra/engine 2.14.0 / root contract 2.14.0** by `node scripts/generate-api-reference.mjs`. No module deep import is supported.
3
+ Generated from the exact root exports for **@photivra/engine 2.15.0 / root contract 2.15.0** by `node scripts/generate-api-reference.mjs`. No module deep import is supported.
4
4
 
5
5
  Use [the developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md) for executable paths and scientific boundaries. Types are contracts, not runtime validation: parse untrusted data, preserve units/reference frames, read assessment blockers, and retain evidence/limitations. Optional properties do not imply a universal default. Model/schema IDs remain independent of the distribution.
6
6
 
@@ -1570,6 +1570,17 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1570
1570
  | [`LensComplexPupilPsfSampling`](api/optics-complex-pupil-psf.md#lenscomplexpupilpsfsampling) | Type |
1571
1571
  | [`parseLensComplexPupilProfile`](api/optics-complex-pupil-psf.md#parselenscomplexpupilprofile) | Runtime |
1572
1572
 
1573
+ ## optics/defocus-disc-kernel.ts
1574
+
1575
+ [Detailed contracts](api/optics-defocus-disc-kernel.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/optics/defocus-disc-kernel.ts)
1576
+
1577
+ | Export | Kind |
1578
+ | --- | --- |
1579
+ | [`calculateDefocusDiscKernelRadius`](api/optics-defocus-disc-kernel.md#calculatedefocusdisckernelradius) | Runtime |
1580
+ | [`CalculateDefocusDiscKernelRadiusInput`](api/optics-defocus-disc-kernel.md#calculatedefocusdisckernelradiusinput) | Type |
1581
+ | [`DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`](api/optics-defocus-disc-kernel.md#defocus_disc_kernel_maximum_radius_sites) | Runtime |
1582
+ | [`DefocusDiscKernelRadius`](api/optics-defocus-disc-kernel.md#defocusdisckernelradius) | Type |
1583
+
1573
1584
  ## optics/depth-of-field.ts
1574
1585
 
1575
1586
  [Detailed contracts](api/optics-depth-of-field.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/optics/depth-of-field.ts)
@@ -871,6 +871,10 @@ The optics options feed only what the environment site contract accepts:
871
871
  lit circular pupil (scalar Fraunhofer diffraction), aberration-free and in focus, with pupil diameter
872
872
  f/N and the thin-lens image distance (working f-number N·v/f). Each kernel holds the PSF energy of
873
873
  site-sized cells, one kernel per spectral node.
874
+ - Every spectral step works (2.15.0): the 1–100 nm range of `sensor.spectralStepNanometers` gives 4–400 nodes,
875
+ and each node gets its own exact kernel. Before 2.15.0 a step under 6.25 nm (more than 64 nodes, for example
876
+ the 5 nm step recommended for color work) failed with "wavelengthsNm must list 1 to 64 wavelengths." Time and
877
+ memory are linear in the node count; measurements are in [PSF foundation](https://github.com/Photivra/photivra/blob/main/docs/PSF_FOUNDATION.md#spectral-nodes-2150).
874
878
  - Sources: the Airy pattern is the textbook Fraunhofer diffraction pattern of a circular aperture
875
879
  (Born & Wolf, *Principles of Optics*, 7th ed., §8.5.2; Goodman, *Introduction to Fourier Optics*,
876
880
  4th ed., §4.4). The kernels come from the pupil's incoherent optical transfer function (Goodman,
@@ -920,6 +924,13 @@ The optics options feed only what the environment site contract accepts:
920
924
  boundaries. Elsewhere they agree within the site sampling of the edge.
921
925
  - The kernel radius is capped at 64 sites, a 129 × 129 window (`DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`). A larger
922
926
  blur is refused with an error that names the cap and the radius needed; the disc is never clipped.
927
+ - **Check before rendering (#532):** `calculateDefocusDiscKernelRadius({ focalLengthMm, aperture, focusDistanceM,
928
+ subjectDistanceM, pixelPitchMicrometers })` returns the disc's diameter in µm and in sites, the kernel radius it
929
+ needs, `withinLimit` and `excessKernelRadiusSites`, without rendering. It runs the capture's own resolution and limit
930
+ test, so it agrees with the refusal of `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` at the
931
+ boundary (`test/defocus-disc-limit.test.ts`, at two adjacent focus distances). Pass the f-number the capture uses
932
+ (in an automatic mode, `exposure.settings.fNumber`). The limit is on the disc alone: with `lens.diffraction` the
933
+ combined radius has no further limit. The other refusals below are not checked.
923
934
  - **The capture renders it** (#492, step 2a). The scene radiance on the site lattice is convolved with the disc,
924
935
  the same kernel at every spectral node, and each site query is answered with the result. This is the object-space
925
936
  convolution that `lens.diffraction` uses.
@@ -1519,7 +1530,11 @@ resolves them).
1519
1530
 
1520
1531
  **How it is computed** (details and error analysis in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md)):
1521
1532
 
1522
- - The expected value of each display pixel is a stratified Monte Carlo average over K × K real pixels of its rectangle
1533
+ - Where a display pixel's rectangle holds at most 25 real pixels (and at most `view.samplesPerAxis`²), so views at least
1534
+ 1/5 of the frame's width, its expected value is the exact area mean (2.8.0, #393): every real pixel it touches is
1535
+ evaluated once and weighted by its exact overlap, with no sampling error. The `view.estimation` assumption names the
1536
+ method used.
1537
+ - Coarser display pixels take a stratified Monte Carlo average over K × K real pixels of their rectangle
1523
1538
  (`view.samplesPerAxis`, default 16), seeded and unbiased. For each sampled pixel the expected electrons of the real
1524
1539
  sites its demosaic reads are exact, and the clips and tone curve act on the pixel's own noise distribution. A pilot
1525
1540
  grid gives uniform areas K/4 samples per axis; their error is zero anyway.
@@ -1552,9 +1567,10 @@ the exact speed-ups, in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/b
1552
1567
 
1553
1568
  Quality costs more on small pixels. The diffraction kernel spans several real sites, and at 1.4 µm even ISO 100 is
1554
1569
  noisy enough for the AHD correction to run on every sampled pixel; its correction states are kept for later views of
1555
- the same sensor, exposure and development in the same JavaScript realm (a second 384-wide phone view of the same
1556
- request took 73 s instead of 103 s). The results are byte-identical to those of the display view as first merged,
1557
- which took 222 s, 450 s and 837 s on the phone.
1570
+ the same sensor, exposure and development in the same JavaScript realm (2.6.0: a second 384-wide phone view of the same
1571
+ request took 73 s instead of 103 s). In 2.6.0 the results were byte-identical to those of the display view as first
1572
+ merged, which took 222 s, 450 s and 837 s on the phone; later releases changed some views' values (for example the
1573
+ exact area mean, 2.8.0; AHD's choice, 2.13.0).
1558
1574
 
1559
1575
  **Options.** `view.width` and `view.height` (1 to the capture's output size), `view.samplesPerAxis` (1–32),
1560
1576
  `view.format` (`"srgb-8"` default or `"srgb-16"`) and `view.region` (a rectangle in display pixels, byte-identical to
package/docs/USAGE.md CHANGED
@@ -1455,6 +1455,23 @@ console.log(blur.value.diameterMm);
1455
1455
 
1456
1456
  You supply the circle-of-confusion criterion used for depth of field (DOF). These are ideal geometric thin-lens models. They do not include diffraction or real-lens aberrations in the DOF criterion.
1457
1457
 
1458
+ `simulateCapture` and the display view render the defocus blur disc with `lens.defocus: { model: "geometric-disc" }`, up to a kernel radius of `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES` (64 sites, a disc 129 sites across) and refuse a larger one. Use `calculateDefocusDiscKernelRadius()` to check a request before rendering it. It runs the capture's own resolution and limit test, so it agrees with the capture's refusal. Pass the f-number the capture uses (in an automatic exposure mode, the resolved one) and the sensor's pixel pitch:
1459
+
1460
+ ```ts
1461
+ import { calculateDefocusDiscKernelRadius } from "@photivra/engine";
1462
+
1463
+ const check = calculateDefocusDiscKernelRadius({
1464
+ focalLengthMm: 50,
1465
+ aperture: 1.4,
1466
+ focusDistanceM: 1,
1467
+ subjectDistanceM: 2,
1468
+ pixelPitchMicrometers: 6
1469
+ });
1470
+
1471
+ console.log(check.value.withinLimit); // false: lens.defocus refuses this request
1472
+ console.log(check.value.kernelRadiusSites, check.value.excessKernelRadiusSites); // 78, 14
1473
+ ```
1474
+
1458
1475
  See [Physics Foundation](https://github.com/Photivra/photivra/blob/main/docs/PHYSICS_FOUNDATION.md#thin-lens-focus-and-depth-of-field).
1459
1476
 
1460
1477
  ## Real-lens PSF profiles and complex-pupil reference
@@ -11,5 +11,5 @@ version they were made with. Proof-of-concept (POC), schema and model contracts
11
11
  own versions. Release checks confirm it matches package.json and the packed package.
12
12
 
13
13
  ```ts
14
- ENGINE_API_VERSION = "2.14.0" as const
14
+ ENGINE_API_VERSION = "2.15.0" as const
15
15
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
- "packageVersion": "2.14.0",
3
- "engineApiVersion": "2.14.0",
2
+ "packageVersion": "2.15.0",
3
+ "engineApiVersion": "2.15.0",
4
4
  "exports": [
5
5
  {
6
6
  "name": "AbsoluteUncertaintyEstimate",
@@ -572,6 +572,16 @@
572
572
  "source": "src/optics/depth-of-field.ts",
573
573
  "kind": "type"
574
574
  },
575
+ {
576
+ "name": "calculateDefocusDiscKernelRadius",
577
+ "source": "src/optics/defocus-disc-kernel.ts",
578
+ "kind": "runtime"
579
+ },
580
+ {
581
+ "name": "CalculateDefocusDiscKernelRadiusInput",
582
+ "source": "src/optics/defocus-disc-kernel.ts",
583
+ "kind": "type"
584
+ },
575
585
  {
576
586
  "name": "calculateDefocusWavefront",
577
587
  "source": "src/optics/zernike-wavefront.ts",
@@ -2692,11 +2702,21 @@
2692
2702
  "source": "src/sensor/capture-mode.ts",
2693
2703
  "kind": "type"
2694
2704
  },
2705
+ {
2706
+ "name": "DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES",
2707
+ "source": "src/optics/defocus-disc-kernel.ts",
2708
+ "kind": "runtime"
2709
+ },
2695
2710
  {
2696
2711
  "name": "DefocusCircle",
2697
2712
  "source": "src/optics/depth-of-field.ts",
2698
2713
  "kind": "type"
2699
2714
  },
2715
+ {
2716
+ "name": "DefocusDiscKernelRadius",
2717
+ "source": "src/optics/defocus-disc-kernel.ts",
2718
+ "kind": "type"
2719
+ },
2700
2720
  {
2701
2721
  "name": "DefocusWavefront",
2702
2722
  "source": "src/optics/zernike-wavefront.ts",