@photivra/engine 2.0.0 → 2.2.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 (77) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/export-jpeg.js +7 -1
  6. package/dist/capture/export-jpeg.js.map +1 -1
  7. package/dist/capture/native-environment-lean.js +5 -2
  8. package/dist/capture/native-environment-lean.js.map +1 -1
  9. package/dist/capture/native-environment-raw.js +2 -2
  10. package/dist/capture/native-environment-raw.js.map +1 -1
  11. package/dist/capture/native-raw-development.js +1 -1
  12. package/dist/capture/native-raw-development.js.map +1 -1
  13. package/dist/capture/native-raw.d.ts +16 -0
  14. package/dist/capture/native-raw.js +16 -3
  15. package/dist/capture/native-raw.js.map +1 -1
  16. package/dist/capture/packed-sensor-raw.d.ts +7 -0
  17. package/dist/capture/packed-sensor-raw.js +6 -2
  18. package/dist/capture/packed-sensor-raw.js.map +1 -1
  19. package/dist/core/pixel-limits.d.ts +14 -0
  20. package/dist/core/pixel-limits.js +40 -0
  21. package/dist/core/pixel-limits.js.map +1 -0
  22. package/dist/core/version.d.ts +1 -1
  23. package/dist/core/version.js +1 -1
  24. package/dist/core/version.js.map +1 -1
  25. package/dist/facade/chart-radiance-batch.d.ts +7 -0
  26. package/dist/facade/chart-radiance-batch.js +7 -3
  27. package/dist/facade/chart-radiance-batch.js.map +1 -1
  28. package/dist/facade/diffraction-provider.d.ts +30 -0
  29. package/dist/facade/diffraction-provider.js +127 -0
  30. package/dist/facade/diffraction-provider.js.map +1 -0
  31. package/dist/facade/host-yield.d.ts +1 -1
  32. package/dist/facade/host-yield.js +0 -4
  33. package/dist/facade/host-yield.js.map +1 -1
  34. package/dist/facade/simulate-capture.d.ts +88 -2
  35. package/dist/facade/simulate-capture.js +343 -41
  36. package/dist/facade/simulate-capture.js.map +1 -1
  37. package/dist/index.d.ts +5 -2
  38. package/dist/index.js +4 -2
  39. package/dist/index.js.map +1 -1
  40. package/dist/motion/temporal-exposure-sum.d.ts +7 -0
  41. package/dist/motion/temporal-exposure-sum.js +6 -2
  42. package/dist/motion/temporal-exposure-sum.js.map +1 -1
  43. package/dist/optics/diffraction-psf-profile.d.ts +118 -0
  44. package/dist/optics/diffraction-psf-profile.js +252 -0
  45. package/dist/optics/diffraction-psf-profile.js.map +1 -0
  46. package/dist/optics/irradiance-convolution.d.ts +7 -0
  47. package/dist/optics/irradiance-convolution.js +6 -2
  48. package/dist/optics/irradiance-convolution.js.map +1 -1
  49. package/dist/output/native-capture-sdr.d.ts +7 -0
  50. package/dist/output/native-capture-sdr.js +6 -3
  51. package/dist/output/native-capture-sdr.js.map +1 -1
  52. package/dist/output/png-encoder.d.ts +10 -1
  53. package/dist/output/png-encoder.js +10 -4
  54. package/dist/output/png-encoder.js.map +1 -1
  55. package/dist/output/print-jpeg.d.ts +18 -0
  56. package/dist/output/print-jpeg.js +15 -4
  57. package/dist/output/print-jpeg.js.map +1 -1
  58. package/dist/sensor/sampling-aperture.d.ts +15 -0
  59. package/dist/sensor/sampling-aperture.js +31 -10
  60. package/dist/sensor/sampling-aperture.js.map +1 -1
  61. package/docs/API_REFERENCE.md +27 -1
  62. package/docs/QUICK_START.md +285 -13
  63. package/docs/USAGE.md +3 -1
  64. package/docs/api/capture-native-raw.md +11 -2
  65. package/docs/api/capture-packed-sensor-raw.md +7 -1
  66. package/docs/api/core-pixel-limits.md +19 -0
  67. package/docs/api/core-version.md +1 -1
  68. package/docs/api/exports.json +62 -2
  69. package/docs/api/facade-chart-radiance-batch.md +8 -2
  70. package/docs/api/facade-simulate-capture.md +83 -3
  71. package/docs/api/motion-temporal-exposure-sum.md +7 -1
  72. package/docs/api/optics-diffraction-psf-profile.md +96 -0
  73. package/docs/api/optics-irradiance-convolution.md +7 -1
  74. package/docs/api/output-native-capture-sdr.md +7 -2
  75. package/docs/api/output-png-encoder.md +8 -1
  76. package/docs/api/output-print-jpeg.md +17 -0
  77. package/package.json +3 -2
@@ -1 +1 @@
1
- {"version":3,"file":"temporal-exposure-sum.js","sourceRoot":"","sources":["../../src/motion/temporal-exposure-sum.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Exposure and motion as a weighted sum of shifted frames (#277, image-formation step 3). The CPU\n * float64 reference for the `webgpu-f32` temporal stage; see docs/WEBGPU.md.\n *\n * A site's exposure is the time integral of its image-plane rate quantity over its own exposure window,\n * H = ∫ E(x, t) dt (EMVA Standard 1288 Release 4.0 Linear, §2, free; CIPA DC-004-2020 §5.5, free). For an\n * image that translates rigidly by d(t) on the sensor, E(x, t) = E₀(x − d(t)), so a temporal quadrature\n * with nodes t_k and weights w_k gives\n *\n * out(x, y) = Σ_k w_k(y) · E₀(x − dx_k, y − dy_k).\n *\n * Motion blur is this superposition of displaced images (Szeliski, *Computer Vision: Algorithms and\n * Applications*, 2nd ed., 2022, free at https://szeliski.org/Book/). The weights depend on the row so a\n * rolling shutter, whose rows start and end their exposure at different times, is the same sum with\n * row-dependent windows (Meingast, Geyer & Sastry, \"Geometric Models of Rolling-Shutter Cameras\",\n * arXiv:cs/0503076, free); a global shutter uses the same weights on every row.\n *\n * Declared approximations, owned by the caller: the quadrature nodes and weights, and rigid translation\n * (no rotation, warp or occlusion). Sub-site displacements use bilinear interpolation between the four\n * nearest site centres, which is linear interpolation and slightly low-pass; it is labelled as an\n * approximation, not exact resampling. Sources outside the grid are zero; no padding or renormalization.\n */\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { requireAllowlistedRecord } from \"../core/record-validation.js\";\n\nexport const TEMPORAL_EXPOSURE_SUM_SCHEMA_VERSION = \"0.1.0\" as const;\nexport const TEMPORAL_EXPOSURE_SUM_LIMITS = Object.freeze({\n maximumPixels: 24_000_000,\n maximumDimension: 16_384,\n maximumTemporalSamples: 256,\n maximumDisplacementSites: 4_096,\n maximumRowWeights: 4_194_304,\n /** Keeps every float32 partial sum finite: 256 · 2^16 · 2^100 < 2^128. */\n maximumSampleMagnitude: 2 ** 100,\n maximumWeight: 2 ** 16\n});\n\nexport interface TemporalExposureSumInput {\n schemaVersion: typeof TEMPORAL_EXPOSURE_SUM_SCHEMA_VERSION;\n width: number;\n height: number;\n /** Row-major rate samples E₀ at site centres. Finite, non-negative, linear and additive (irradiance, photon or electron rate). */\n samples: Float32Array | Float64Array;\n /** Image displacement at each temporal node in grid sites, interleaved (dx₀, dy₀, dx₁, dy₁, …); +x right, +y down. */\n displacements: Float64Array;\n /** Quadrature weights `[k][row]` (nodes × height), finite and non-negative, for example seconds of each row's window near t_k. */\n rowWeights: Float32Array | Float64Array;\n boundary: \"zero-outside-grid\";\n}\n\n/** One temporal node: integer source offset of the top-left tap and the four bilinear weights (float64). */\nexport interface TemporalExposureSumNode {\n offsetX: number;\n offsetY: number;\n weights: readonly [number, number, number, number];\n}\n\nexport interface TemporalExposureSumPlan {\n width: number;\n height: number;\n nodeCount: number;\n nodes: readonly TemporalExposureSumNode[];\n maximumSampleMagnitude: number;\n /** Largest Σ_k w_k(y) over rows; the operator's weight sum for error composition. */\n maximumRowWeightSum: number;\n maximumWeight: number;\n /** True when every displacement is a whole number of sites (no interpolation). */\n integerDisplacements: boolean;\n}\n\nfunction positiveInteger(value: unknown, maximum: number): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value <= 0 || value > maximum) {\n throw new InvalidConfigurationError(\"Invalid temporal exposure sum geometry.\");\n }\n return value;\n}\n\n/** Validates the request (every value once) and returns its plan. */\nexport function calculateTemporalExposureSumPlan(input: TemporalExposureSumInput): TemporalExposureSumPlan {\n const r = requireAllowlistedRecord(input, [\"schemaVersion\", \"width\", \"height\", \"samples\", \"displacements\", \"rowWeights\", \"boundary\"],\n \"Invalid temporal exposure sum input.\");\n const L = TEMPORAL_EXPOSURE_SUM_LIMITS;\n if (r.schemaVersion !== TEMPORAL_EXPOSURE_SUM_SCHEMA_VERSION || r.boundary !== \"zero-outside-grid\") {\n throw new InvalidConfigurationError(\"Unsupported temporal exposure sum schema or boundary.\");\n }\n const width = positiveInteger(r.width, L.maximumDimension), height = positiveInteger(r.height, L.maximumDimension);\n if (width * height > L.maximumPixels) throw new InvalidConfigurationError(\"Temporal exposure sum grid exceeds the pixel limit.\");\n const samples = r.samples, displacements = r.displacements, weights = r.rowWeights;\n if (!(displacements instanceof Float64Array) || displacements.length < 2 || displacements.length % 2 !== 0 ||\n displacements.length / 2 > L.maximumTemporalSamples) {\n throw new InvalidConfigurationError(\"Temporal exposure sum needs 1 to 256 displacement pairs.\");\n }\n const nodeCount = displacements.length / 2;\n if (!(samples instanceof Float32Array || samples instanceof Float64Array) || samples.length !== width * height ||\n !(weights instanceof Float32Array || weights instanceof Float64Array) || weights.length !== nodeCount * height ||\n weights.length > L.maximumRowWeights) {\n throw new InvalidConfigurationError(\"Temporal exposure sum arrays do not match the declared geometry.\");\n }\n let maximumSampleMagnitude = 0;\n for (let i = 0; i < samples.length; i++) {\n const v = samples[i]!;\n if (!(v >= 0 && v <= L.maximumSampleMagnitude)) throw new InvalidConfigurationError(\"Temporal samples must be finite, non-negative and bounded.\");\n if (v > maximumSampleMagnitude) maximumSampleMagnitude = v;\n }\n let maximumWeight = 0, maximumRowWeightSum = 0;\n const rowSums = new Float64Array(height);\n for (let k = 0; k < nodeCount; k++) {\n for (let y = 0; y < height; y++) {\n const w = weights[k * height + y]!;\n if (!(w >= 0 && w <= L.maximumWeight)) throw new InvalidConfigurationError(\"Temporal row weights must be finite, non-negative and bounded.\");\n if (w > maximumWeight) maximumWeight = w;\n rowSums[y]! += w;\n }\n }\n for (const s of rowSums) if (s > maximumRowWeightSum) maximumRowWeightSum = s;\n let integerDisplacements = true;\n const nodes: TemporalExposureSumNode[] = [];\n for (let k = 0; k < nodeCount; k++) {\n const dx = displacements[2 * k]!, dy = displacements[2 * k + 1]!;\n if (!(Math.abs(dx) <= L.maximumDisplacementSites && Math.abs(dy) <= L.maximumDisplacementSites)) {\n throw new InvalidConfigurationError(\"Temporal displacements must be finite and within 4096 sites.\");\n }\n // Source position x − dx = x + offsetX + ax with ax in [0, 1).\n const offsetX = Math.floor(-dx), offsetY = Math.floor(-dy), ax = -dx - offsetX, ay = -dy - offsetY;\n if (ax !== 0 || ay !== 0) integerDisplacements = false;\n nodes.push(Object.freeze({ offsetX, offsetY,\n weights: Object.freeze([(1 - ax) * (1 - ay), ax * (1 - ay), (1 - ax) * ay, ax * ay] as const) }));\n }\n return Object.freeze({ width, height, nodeCount, nodes: Object.freeze(nodes), maximumSampleMagnitude, maximumRowWeightSum, maximumWeight,\n integerDisplacements });\n}\n\n/**\n * Reference rows `[y0, y1)` into `output` at `(y − y0)·width + x`. The order (node ascending; taps 00,\n * 10, 01, 11 summed left to right; then times the row weight) is part of the contract shared with the\n * WGSL kernel.\n * @internal\n */\nexport function sumTemporalExposureRows(plan: TemporalExposureSumPlan, samples: Float32Array | Float64Array, rowWeights: Float32Array | Float64Array,\n y0: number, y1: number, output: Float64Array): void {\n const { width, height, nodes } = plan;\n for (let y = y0; y < y1; y++) {\n for (let x = 0; x < width; x++) {\n let sum = 0;\n for (let k = 0; k < nodes.length; k++) {\n const node = nodes[k]!, sx = x + node.offsetX, sy = y + node.offsetY, b = node.weights;\n const left = sx >= 0 && sx < width, right = sx + 1 >= 0 && sx + 1 < width;\n const top = sy >= 0 && sy < height, bottom = sy + 1 >= 0 && sy + 1 < height;\n let inner = 0;\n if (top && left) inner += b[0] * samples[sy * width + sx]!;\n if (top && right) inner += b[1] * samples[sy * width + sx + 1]!;\n if (bottom && left) inner += b[2] * samples[(sy + 1) * width + sx]!;\n if (bottom && right) inner += b[3] * samples[(sy + 1) * width + sx + 1]!;\n sum += rowWeights[k * height + y]! * inner;\n }\n output[(y - y0) * width + x] = sum;\n }\n }\n}\n\n/** Synchronous float64 reference for bounded grids. Use the image-kernel accelerator for large grids. */\nexport function calculateTemporalExposureSum(input: TemporalExposureSumInput): { plan: TemporalExposureSumPlan; values: Float64Array } {\n const plan = calculateTemporalExposureSumPlan(input);\n const values = new Float64Array(plan.width * plan.height);\n sumTemporalExposureRows(plan, input.samples, input.rowWeights, 0, plan.height, values);\n return { plan, values };\n}\n"]}
1
+ {"version":3,"file":"temporal-exposure-sum.js","sourceRoot":"","sources":["../../src/motion/temporal-exposure-sum.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Exposure and motion as a weighted sum of shifted frames (#277, image-formation step 3). The CPU\n * float64 reference for the `webgpu-f32` temporal stage; see docs/WEBGPU.md.\n *\n * A site's exposure is the time integral of its image-plane rate quantity over its own exposure window,\n * H = ∫ E(x, t) dt (EMVA Standard 1288 Release 4.0 Linear, §2, free; CIPA DC-004-2020 §5.5, free). For an\n * image that translates rigidly by d(t) on the sensor, E(x, t) = E₀(x − d(t)), so a temporal quadrature\n * with nodes t_k and weights w_k gives\n *\n * out(x, y) = Σ_k w_k(y) · E₀(x − dx_k, y − dy_k).\n *\n * Motion blur is this superposition of displaced images (Szeliski, *Computer Vision: Algorithms and\n * Applications*, 2nd ed., 2022, free at https://szeliski.org/Book/). The weights depend on the row so a\n * rolling shutter, whose rows start and end their exposure at different times, is the same sum with\n * row-dependent windows (Meingast, Geyer & Sastry, \"Geometric Models of Rolling-Shutter Cameras\",\n * arXiv:cs/0503076, free); a global shutter uses the same weights on every row.\n *\n * Declared approximations, owned by the caller: the quadrature nodes and weights, and rigid translation\n * (no rotation, warp or occlusion). Sub-site displacements use bilinear interpolation between the four\n * nearest site centres, which is linear interpolation and slightly low-pass; it is labelled as an\n * approximation, not exact resampling. Sources outside the grid are zero; no padding or renormalization.\n */\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { requireAllowlistedRecord } from \"../core/record-validation.js\";\nimport { FULL_RESOLUTION_CEILING_PIXELS, resolveRaisedLimit } from \"../core/pixel-limits.js\";\n\nexport const TEMPORAL_EXPOSURE_SUM_SCHEMA_VERSION = \"0.1.0\" as const;\n/** `maximumPixels` is the default grid limit; a request may raise it with its own `maximumPixels`, up to `ceilingPixels`. */\nexport const TEMPORAL_EXPOSURE_SUM_LIMITS = Object.freeze({\n maximumPixels: 24_000_000,\n ceilingPixels: FULL_RESOLUTION_CEILING_PIXELS,\n maximumDimension: 16_384,\n maximumTemporalSamples: 256,\n maximumDisplacementSites: 4_096,\n maximumRowWeights: 4_194_304,\n /** Keeps every float32 partial sum finite: 256 · 2^16 · 2^100 < 2^128. */\n maximumSampleMagnitude: 2 ** 100,\n maximumWeight: 2 ** 16\n});\n\nexport interface TemporalExposureSumInput {\n schemaVersion: typeof TEMPORAL_EXPOSURE_SUM_SCHEMA_VERSION;\n width: number;\n height: number;\n /** Row-major rate samples E₀ at site centres. Finite, non-negative, linear and additive (irradiance, photon or electron rate). */\n samples: Float32Array | Float64Array;\n /** Image displacement at each temporal node in grid sites, interleaved (dx₀, dy₀, dx₁, dy₁, …); +x right, +y down. */\n displacements: Float64Array;\n /** Quadrature weights `[k][row]` (nodes × height), finite and non-negative, for example seconds of each row's window near t_k. */\n rowWeights: Float32Array | Float64Array;\n boundary: \"zero-outside-grid\";\n /**\n * Optional opt-in grid limit for large images: an integer from 1 to `TEMPORAL_EXPOSURE_SUM_LIMITS.ceilingPixels`.\n * Default `TEMPORAL_EXPOSURE_SUM_LIMITS.maximumPixels` (24 000 000). It does not change the result.\n */\n maximumPixels?: number;\n}\n\n/** One temporal node: integer source offset of the top-left tap and the four bilinear weights (float64). */\nexport interface TemporalExposureSumNode {\n offsetX: number;\n offsetY: number;\n weights: readonly [number, number, number, number];\n}\n\nexport interface TemporalExposureSumPlan {\n width: number;\n height: number;\n nodeCount: number;\n nodes: readonly TemporalExposureSumNode[];\n maximumSampleMagnitude: number;\n /** Largest Σ_k w_k(y) over rows; the operator's weight sum for error composition. */\n maximumRowWeightSum: number;\n maximumWeight: number;\n /** True when every displacement is a whole number of sites (no interpolation). */\n integerDisplacements: boolean;\n}\n\nfunction positiveInteger(value: unknown, maximum: number): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value <= 0 || value > maximum) {\n throw new InvalidConfigurationError(\"Invalid temporal exposure sum geometry.\");\n }\n return value;\n}\n\n/** Validates the request (every value once) and returns its plan. */\nexport function calculateTemporalExposureSumPlan(input: TemporalExposureSumInput): TemporalExposureSumPlan {\n const r = requireAllowlistedRecord(input, [\"schemaVersion\", \"width\", \"height\", \"samples\", \"displacements\", \"rowWeights\", \"boundary\", \"maximumPixels\"],\n \"Invalid temporal exposure sum input.\");\n const L = TEMPORAL_EXPOSURE_SUM_LIMITS;\n if (r.schemaVersion !== TEMPORAL_EXPOSURE_SUM_SCHEMA_VERSION || r.boundary !== \"zero-outside-grid\") {\n throw new InvalidConfigurationError(\"Unsupported temporal exposure sum schema or boundary.\");\n }\n const width = positiveInteger(r.width, L.maximumDimension), height = positiveInteger(r.height, L.maximumDimension);\n const maximumPixels = resolveRaisedLimit(r.maximumPixels, L.maximumPixels, L.ceilingPixels,\n \"Temporal exposure sum maximumPixels must be an integer from 1 to \" + L.ceilingPixels + \".\");\n if (width * height > maximumPixels) throw new InvalidConfigurationError(\"Temporal exposure sum grid exceeds the pixel limit.\");\n const samples = r.samples, displacements = r.displacements, weights = r.rowWeights;\n if (!(displacements instanceof Float64Array) || displacements.length < 2 || displacements.length % 2 !== 0 ||\n displacements.length / 2 > L.maximumTemporalSamples) {\n throw new InvalidConfigurationError(\"Temporal exposure sum needs 1 to 256 displacement pairs.\");\n }\n const nodeCount = displacements.length / 2;\n if (!(samples instanceof Float32Array || samples instanceof Float64Array) || samples.length !== width * height ||\n !(weights instanceof Float32Array || weights instanceof Float64Array) || weights.length !== nodeCount * height ||\n weights.length > L.maximumRowWeights) {\n throw new InvalidConfigurationError(\"Temporal exposure sum arrays do not match the declared geometry.\");\n }\n let maximumSampleMagnitude = 0;\n for (let i = 0; i < samples.length; i++) {\n const v = samples[i]!;\n if (!(v >= 0 && v <= L.maximumSampleMagnitude)) throw new InvalidConfigurationError(\"Temporal samples must be finite, non-negative and bounded.\");\n if (v > maximumSampleMagnitude) maximumSampleMagnitude = v;\n }\n let maximumWeight = 0, maximumRowWeightSum = 0;\n const rowSums = new Float64Array(height);\n for (let k = 0; k < nodeCount; k++) {\n for (let y = 0; y < height; y++) {\n const w = weights[k * height + y]!;\n if (!(w >= 0 && w <= L.maximumWeight)) throw new InvalidConfigurationError(\"Temporal row weights must be finite, non-negative and bounded.\");\n if (w > maximumWeight) maximumWeight = w;\n rowSums[y]! += w;\n }\n }\n for (const s of rowSums) if (s > maximumRowWeightSum) maximumRowWeightSum = s;\n let integerDisplacements = true;\n const nodes: TemporalExposureSumNode[] = [];\n for (let k = 0; k < nodeCount; k++) {\n const dx = displacements[2 * k]!, dy = displacements[2 * k + 1]!;\n if (!(Math.abs(dx) <= L.maximumDisplacementSites && Math.abs(dy) <= L.maximumDisplacementSites)) {\n throw new InvalidConfigurationError(\"Temporal displacements must be finite and within 4096 sites.\");\n }\n // Source position x − dx = x + offsetX + ax with ax in [0, 1).\n const offsetX = Math.floor(-dx), offsetY = Math.floor(-dy), ax = -dx - offsetX, ay = -dy - offsetY;\n if (ax !== 0 || ay !== 0) integerDisplacements = false;\n nodes.push(Object.freeze({ offsetX, offsetY,\n weights: Object.freeze([(1 - ax) * (1 - ay), ax * (1 - ay), (1 - ax) * ay, ax * ay] as const) }));\n }\n return Object.freeze({ width, height, nodeCount, nodes: Object.freeze(nodes), maximumSampleMagnitude, maximumRowWeightSum, maximumWeight,\n integerDisplacements });\n}\n\n/**\n * Reference rows `[y0, y1)` into `output` at `(y − y0)·width + x`. The order (node ascending; taps 00,\n * 10, 01, 11 summed left to right; then times the row weight) is part of the contract shared with the\n * WGSL kernel.\n * @internal\n */\nexport function sumTemporalExposureRows(plan: TemporalExposureSumPlan, samples: Float32Array | Float64Array, rowWeights: Float32Array | Float64Array,\n y0: number, y1: number, output: Float64Array): void {\n const { width, height, nodes } = plan;\n for (let y = y0; y < y1; y++) {\n for (let x = 0; x < width; x++) {\n let sum = 0;\n for (let k = 0; k < nodes.length; k++) {\n const node = nodes[k]!, sx = x + node.offsetX, sy = y + node.offsetY, b = node.weights;\n const left = sx >= 0 && sx < width, right = sx + 1 >= 0 && sx + 1 < width;\n const top = sy >= 0 && sy < height, bottom = sy + 1 >= 0 && sy + 1 < height;\n let inner = 0;\n if (top && left) inner += b[0] * samples[sy * width + sx]!;\n if (top && right) inner += b[1] * samples[sy * width + sx + 1]!;\n if (bottom && left) inner += b[2] * samples[(sy + 1) * width + sx]!;\n if (bottom && right) inner += b[3] * samples[(sy + 1) * width + sx + 1]!;\n sum += rowWeights[k * height + y]! * inner;\n }\n output[(y - y0) * width + x] = sum;\n }\n }\n}\n\n/** Synchronous float64 reference for bounded grids. Use the image-kernel accelerator for large grids. */\nexport function calculateTemporalExposureSum(input: TemporalExposureSumInput): { plan: TemporalExposureSumPlan; values: Float64Array } {\n const plan = calculateTemporalExposureSumPlan(input);\n const values = new Float64Array(plan.width * plan.height);\n sumTemporalExposureRows(plan, input.samples, input.rowWeights, 0, plan.height, values);\n return { plan, values };\n}\n"]}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Builds a `LensSampledPsfProfile` for an ideal, aberration-free, in-focus lens with a uniformly lit
3
+ * circular pupil: the Airy pattern of scalar Fraunhofer diffraction, one unit-sum kernel per declared
4
+ * wavelength on ONE shared sample grid (2.1.0).
5
+ *
6
+ * **Model.** The pupil diameter is D = f/N (entrance pupil = exit pupil, unity pupil magnification) and
7
+ * the pupil-to-image distance z is the thin-lens image distance for the focus (f at infinity), so the
8
+ * image-side (working) f-number is N_w = z/D. The incoherent optical transfer function (OTF) of a
9
+ * uniform circular pupil is the normalized autocorrelation of the pupil,
10
+ * H(ρ) = (2/π)·(acos ρ − ρ·√(1 − ρ²)), ρ = |f|/f_c ≤ 1, f_c = 1/(λ·N_w),
11
+ * and the intensity PSF is its inverse Fourier transform (the Airy pattern, first zero at 1.21967·λ·N_w)
12
+ * (MIT OpenCourseWare 2.71 Optics, Spring 2009, lectures 21–22, free: the intensity PSF as the transform of
13
+ * the pupil autocorrelation and the circular-pupil MTF; also see: Goodman, Introduction to Fourier Optics,
14
+ * 4th ed., 2017, §6.3).
15
+ *
16
+ * **Cell integration (exact, no Bessel functions).** Kernel sample (i, j) is the PSF energy inside the
17
+ * square cell of side p centred on (i·p, j·p). The cell integral is the PSF convolved with the cell's
18
+ * indicator, evaluated at the cell centre, so in the frequency domain
19
+ * K(i, j) = p² ∫∫ H(|f|/f_c)·sinc(p·f_x)·sinc(p·f_y)·cos(2π·p·i·f_x)·cos(2π·p·j·f_y) d²f,
20
+ * with sinc(x) = sin(πx)/(πx) (the sine terms vanish because H is even). It is evaluated in polar
21
+ * coordinates: a trapezoid rule in angle (exponentially convergent for this smooth periodic integrand) and
22
+ * Gauss–Legendre in t with ρ = 1 − t², which removes the (1 − ρ)^{3/2} edge behaviour of H. The node counts
23
+ * follow from the integrand's bandwidth with a fixed margin; the tests check convergence by doubling them and
24
+ * check the values against an independent real-space integration of `calculateIdealPolygonDiffractionPsf` in
25
+ * its many-blade (circular) limit. The sum of all cells is exactly 1 (Parseval), so the energy captured by
26
+ * the finite kernel is Σ K over its window and the truncated energy is 1 − Σ K.
27
+ *
28
+ * **Truncation and normalization.** The kernel is the (2R+1)² window of cells. The Airy tails fall only as
29
+ * r⁻³, so a finite window always misses some energy: it is reported per wavelength, and the window is then
30
+ * renormalized to unit sum because the sampled-profile contract requires unit-energy kernels. The missing
31
+ * tail energy is thereby put back inside the window (an approximation that slightly raises low-frequency
32
+ * contrast compared with the untruncated pattern).
33
+ *
34
+ * **Approximations (labelled):** scalar (no polarization), aberration-free, uniform pupil amplitude, exactly in
35
+ * focus (no defocus), thin lens with unity pupil magnification, the on-axis PSF used across the declared field
36
+ * (isoplanatic), PSF energy binned into the sample cells, finite window renormalized. Not a measured lens.
37
+ * @see docs/PSF_FOUNDATION.md, "Diffraction-limited sampled profile (2.1)".
38
+ */
39
+ import { type CalculationResult } from "../core/calculation-result.js";
40
+ import type { FocusPlane } from "./focus-state.js";
41
+ import { type LensSampledPsfProfile } from "./lens-psf-profile.js";
42
+ export declare const DIFFRACTION_LIMITED_PSF_PROFILE_MODEL: Readonly<{
43
+ modelId: "photivra-diffraction-limited-circular-pupil-psf";
44
+ modelVersion: "1.0.0";
45
+ }>;
46
+ /** Input limits: at most 64 wavelengths, kernel radius 0–32 samples (the irradiance-convolution limit). */
47
+ export declare const DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS: Readonly<{
48
+ maximumWavelengths: 64;
49
+ maximumKernelRadiusSamples: 32;
50
+ }>;
51
+ export interface CreateDiffractionLimitedPsfProfileInput {
52
+ /** Only `circular` in 2.1.0 (polygon pupils: `calculateIdealPolygonDiffractionPsf` gives point densities). */
53
+ aperture: "circular";
54
+ focalLengthMm: number;
55
+ apertureFNumber: number;
56
+ focus: FocusPlane;
57
+ /** Strictly increasing wavelengths, nm, in the medium of propagation (no air/vacuum conversion is applied). 1–64. */
58
+ wavelengthsNm: readonly number[];
59
+ /** Square sample pitch of every kernel, µm. */
60
+ samplePitchMicrometers: number;
61
+ /** Kernel half-width in samples; each kernel is (2R + 1)². 0–32. */
62
+ kernelRadiusSamples: number;
63
+ /** Half-extent of the image field over which the on-axis PSF is declared (profile field axes ±x, ±y), mm. */
64
+ fieldHalfExtentMm: {
65
+ x: number;
66
+ y: number;
67
+ };
68
+ profileId: string;
69
+ }
70
+ export interface DiffractionLimitedPsfWavelength {
71
+ wavelengthNm: number;
72
+ /** f_c = 1/(λ·N_w), cycles per µm. */
73
+ cutoffFrequencyCyclesPerMicrometer: number;
74
+ /** 1.21967·λ·N_w, µm. */
75
+ firstZeroRadiusMicrometers: number;
76
+ /** Σ of the cell energies inside the window before renormalization (of a total of 1). */
77
+ capturedEnergyFraction: number;
78
+ /** 1 − capturedEnergyFraction: the Airy tail energy outside the window, put back by renormalization. */
79
+ truncatedEnergyFraction: number;
80
+ /** Fraction of the renormalized kernel in the centre sample. */
81
+ centreSampleFraction: number;
82
+ }
83
+ export interface DiffractionLimitedPsfProfile {
84
+ profile: LensSampledPsfProfile;
85
+ model: typeof DIFFRACTION_LIMITED_PSF_PROFILE_MODEL;
86
+ pupilDiameterMm: number;
87
+ pupilToImageDistanceMm: number;
88
+ workingFNumber: number;
89
+ kernelRadiusSamples: number;
90
+ samplePitchMicrometers: number;
91
+ wavelengths: readonly DiffractionLimitedPsfWavelength[];
92
+ /** Largest truncated energy over the wavelengths. */
93
+ maximumTruncatedEnergyFraction: number;
94
+ quadrature: {
95
+ method: "polar-frequency-domain-trapezoid-gauss-legendre";
96
+ maximumRadialNodes: number;
97
+ maximumAngularNodes: number;
98
+ };
99
+ }
100
+ /**
101
+ * Cell energies K(i, j), 0 ≤ i, j ≤ R, of the Airy pattern for β = p·f_c (cell side in units of the cut-off
102
+ * period). `scale` multiplies the node counts (1 in production; the tests use 2 to check convergence).
103
+ */
104
+ export declare function calculateAiryCellEnergies(beta: number, radius: number, scale?: number): {
105
+ cells: Float64Array;
106
+ radialNodes: number;
107
+ angularNodes: number;
108
+ };
109
+ /**
110
+ * Creates a sampled lens PSF profile for an ideal diffraction-limited lens with a circular pupil: one
111
+ * unit-sum Airy kernel per wavelength on one shared (2R+1)² grid, with the truncated energy reported per
112
+ * wavelength. The profile declares the given focal length, f-number and focus exactly, and the field axes
113
+ * ±`fieldHalfExtentMm` with the same kernel at every field node.
114
+ * @param input - See `CreateDiffractionLimitedPsfProfileInput`.
115
+ * @returns The profile and its diffraction record. An approximation; not a measured lens.
116
+ * @see docs/PSF_FOUNDATION.md, "Diffraction-limited sampled profile (2.1)".
117
+ */
118
+ export declare function createDiffractionLimitedPsfProfile(input: CreateDiffractionLimitedPsfProfileInput): CalculationResult<DiffractionLimitedPsfProfile>;
@@ -0,0 +1,252 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Builds a `LensSampledPsfProfile` for an ideal, aberration-free, in-focus lens with a uniformly lit
4
+ * circular pupil: the Airy pattern of scalar Fraunhofer diffraction, one unit-sum kernel per declared
5
+ * wavelength on ONE shared sample grid (2.1.0).
6
+ *
7
+ * **Model.** The pupil diameter is D = f/N (entrance pupil = exit pupil, unity pupil magnification) and
8
+ * the pupil-to-image distance z is the thin-lens image distance for the focus (f at infinity), so the
9
+ * image-side (working) f-number is N_w = z/D. The incoherent optical transfer function (OTF) of a
10
+ * uniform circular pupil is the normalized autocorrelation of the pupil,
11
+ * H(ρ) = (2/π)·(acos ρ − ρ·√(1 − ρ²)), ρ = |f|/f_c ≤ 1, f_c = 1/(λ·N_w),
12
+ * and the intensity PSF is its inverse Fourier transform (the Airy pattern, first zero at 1.21967·λ·N_w)
13
+ * (MIT OpenCourseWare 2.71 Optics, Spring 2009, lectures 21–22, free: the intensity PSF as the transform of
14
+ * the pupil autocorrelation and the circular-pupil MTF; also see: Goodman, Introduction to Fourier Optics,
15
+ * 4th ed., 2017, §6.3).
16
+ *
17
+ * **Cell integration (exact, no Bessel functions).** Kernel sample (i, j) is the PSF energy inside the
18
+ * square cell of side p centred on (i·p, j·p). The cell integral is the PSF convolved with the cell's
19
+ * indicator, evaluated at the cell centre, so in the frequency domain
20
+ * K(i, j) = p² ∫∫ H(|f|/f_c)·sinc(p·f_x)·sinc(p·f_y)·cos(2π·p·i·f_x)·cos(2π·p·j·f_y) d²f,
21
+ * with sinc(x) = sin(πx)/(πx) (the sine terms vanish because H is even). It is evaluated in polar
22
+ * coordinates: a trapezoid rule in angle (exponentially convergent for this smooth periodic integrand) and
23
+ * Gauss–Legendre in t with ρ = 1 − t², which removes the (1 − ρ)^{3/2} edge behaviour of H. The node counts
24
+ * follow from the integrand's bandwidth with a fixed margin; the tests check convergence by doubling them and
25
+ * check the values against an independent real-space integration of `calculateIdealPolygonDiffractionPsf` in
26
+ * its many-blade (circular) limit. The sum of all cells is exactly 1 (Parseval), so the energy captured by
27
+ * the finite kernel is Σ K over its window and the truncated energy is 1 − Σ K.
28
+ *
29
+ * **Truncation and normalization.** The kernel is the (2R+1)² window of cells. The Airy tails fall only as
30
+ * r⁻³, so a finite window always misses some energy: it is reported per wavelength, and the window is then
31
+ * renormalized to unit sum because the sampled-profile contract requires unit-energy kernels. The missing
32
+ * tail energy is thereby put back inside the window (an approximation that slightly raises low-frequency
33
+ * contrast compared with the untruncated pattern).
34
+ *
35
+ * **Approximations (labelled):** scalar (no polarization), aberration-free, uniform pupil amplitude, exactly in
36
+ * focus (no defocus), thin lens with unity pupil magnification, the on-axis PSF used across the declared field
37
+ * (isoplanatic), PSF energy binned into the sample cells, finite window renormalized. Not a measured lens.
38
+ * @see docs/PSF_FOUNDATION.md, "Diffraction-limited sampled profile (2.1)".
39
+ */
40
+ import { approximationResult } from "../core/calculation-result.js";
41
+ import { InvalidConfigurationError } from "../core/configuration-error.js";
42
+ import { requireAllowlistedRecord } from "../core/record-validation.js";
43
+ import { calculateThinLensImageDistance } from "./thin-lens.js";
44
+ import { parseLensSampledPsfProfile } from "./lens-psf-profile.js";
45
+ export const DIFFRACTION_LIMITED_PSF_PROFILE_MODEL = Object.freeze({
46
+ modelId: "photivra-diffraction-limited-circular-pupil-psf", modelVersion: "1.0.0"
47
+ });
48
+ /** Input limits: at most 64 wavelengths, kernel radius 0–32 samples (the irradiance-convolution limit). */
49
+ export const DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS = Object.freeze({ maximumWavelengths: 64, maximumKernelRadiusSamples: 32 });
50
+ /** Radius of the first dark ring of the Airy pattern in units of λ·N_w: j₁,₁/π, j₁,₁ = 3.8317059702 (first zero of J₁). */
51
+ const AIRY_FIRST_ZERO_PER_LAMBDA_N = 3.8317059702075125 / Math.PI;
52
+ const EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
53
+ sourceReference: "photivra:diffraction-limited-psf-profile", reuseStatus: "photivra-owned" })]);
54
+ const LIMITATIONS = Object.freeze([
55
+ "Scalar Fraunhofer diffraction of an ideal, uniformly lit circular pupil (Airy pattern); no aberration, apodization, polarization or stray light.",
56
+ "Exactly in focus: no defocus or depth of field. Thin lens with unity pupil magnification; the pupil-to-image distance is the thin-lens image distance.",
57
+ "The on-axis PSF is declared unchanged across the field (isoplanatic); no field-dependent aberration or pupil clipping.",
58
+ "PSF energy is binned into square sample cells; the finite window misses the Airy tail energy listed per wavelength and is renormalized to unit sum.",
59
+ "Wavelengths are used as given, in the declared basis, without air/vacuum conversion."
60
+ ]);
61
+ function positive(value, name) {
62
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
63
+ throw new InvalidConfigurationError(name + " must be a positive finite number.");
64
+ return value;
65
+ }
66
+ /** Gauss–Legendre nodes and weights on [0, 1] (Newton iteration on Pₙ; DLMF §3.5(v)). Cached per n. */
67
+ const GAUSS_LEGENDRE = new Map();
68
+ function gaussLegendre(n) {
69
+ const cached = GAUSS_LEGENDRE.get(n);
70
+ if (cached)
71
+ return cached;
72
+ const nodes = new Float64Array(n), weights = new Float64Array(n);
73
+ for (let k = 0; k < Math.ceil(n / 2); k++) {
74
+ let x = Math.cos(Math.PI * (k + 0.75) / (n + 0.5));
75
+ for (let iteration = 0; iteration < 100; iteration++) {
76
+ let p0 = 1, p1 = x;
77
+ for (let m = 2; m <= n; m++) {
78
+ const p2 = ((2 * m - 1) * x * p1 - (m - 1) * p0) / m;
79
+ p0 = p1;
80
+ p1 = p2;
81
+ }
82
+ const step = p1 / (n * (x * p1 - p0) / (x * x - 1));
83
+ x -= step;
84
+ if (Math.abs(step) < 1e-16)
85
+ break;
86
+ }
87
+ let p0 = 1, p1 = x;
88
+ for (let m = 2; m <= n; m++) {
89
+ const p2 = ((2 * m - 1) * x * p1 - (m - 1) * p0) / m;
90
+ p0 = p1;
91
+ p1 = p2;
92
+ }
93
+ const derivative = n * (x * p1 - p0) / (x * x - 1);
94
+ const w = 1 / ((1 - x * x) * derivative * derivative);
95
+ // Map [-1, 1] to [0, 1]: t = (1 ± x)/2, weight w (2/(1-x²)P'² halved).
96
+ nodes[k] = (1 - x) / 2;
97
+ weights[k] = w;
98
+ nodes[n - 1 - k] = (1 + x) / 2;
99
+ weights[n - 1 - k] = w;
100
+ }
101
+ const result = { nodes, weights };
102
+ if (GAUSS_LEGENDRE.size < 64)
103
+ GAUSS_LEGENDRE.set(n, result);
104
+ return result;
105
+ }
106
+ function sinc(x) {
107
+ if (x === 0)
108
+ return 1;
109
+ const a = Math.PI * x;
110
+ return Math.sin(a) / a;
111
+ }
112
+ /**
113
+ * Cell energies K(i, j), 0 ≤ i, j ≤ R, of the Airy pattern for β = p·f_c (cell side in units of the cut-off
114
+ * period). `scale` multiplies the node counts (1 in production; the tests use 2 to check convergence).
115
+ */
116
+ export function calculateAiryCellEnergies(beta, radius, scale = 1) {
117
+ // Bandwidth of the angular and radial integrands: c = π·β·(2R + 1)·√2 (the sinc factors and the highest cosine).
118
+ const c = Math.PI * beta * (2 * radius + 1) * Math.SQRT2, margin = 10 * Math.cbrt(c) + 24;
119
+ const quarter = Math.ceil(scale * (c + margin) / 4), radialNodes = Math.ceil(scale * (c + margin));
120
+ const { nodes, weights } = gaussLegendre(radialNodes), size = radius + 1, cells = new Float64Array(size * size);
121
+ const ca = new Float64Array(size), cb = new Float64Array(size), angularStep = (Math.PI / 2) / quarter;
122
+ for (let r = 0; r < radialNodes; r++) {
123
+ const t = nodes[r], rho = 1 - t * t;
124
+ // H(1 − t²) without cancellation: acos(1 − t²) = 2·asin(t/√2), ρ·√(1 − ρ²) = ρ·t·√(2 − t²).
125
+ const otf = (2 / Math.PI) * (2 * Math.asin(t / Math.SQRT2) - rho * t * Math.sqrt(2 - t * t));
126
+ // ∫₀¹ g(ρ) dρ = ∫₀¹ g(1 − t²)·2t dt; the 4 is the four symmetric quadrants of the angular trapezoid.
127
+ const radialWeight = weights[r] * 2 * t * rho * otf * 4 * angularStep;
128
+ for (let k = 0; k <= quarter; k++) {
129
+ const theta = k * angularStep, x = beta * rho * Math.cos(theta), y = beta * rho * Math.sin(theta);
130
+ const w = radialWeight * (k === 0 || k === quarter ? 0.5 : 1) * sinc(x) * sinc(y);
131
+ // cos(2π·i·x) and cos(2π·j·y) by the Chebyshev recurrence.
132
+ const cx = Math.cos(2 * Math.PI * x), cy = Math.cos(2 * Math.PI * y);
133
+ ca[0] = 1;
134
+ cb[0] = 1;
135
+ if (size > 1) {
136
+ ca[1] = cx;
137
+ cb[1] = cy;
138
+ }
139
+ for (let i = 2; i < size; i++) {
140
+ ca[i] = 2 * cx * ca[i - 1] - ca[i - 2];
141
+ cb[i] = 2 * cy * cb[i - 1] - cb[i - 2];
142
+ }
143
+ for (let j = 0; j < size; j++) {
144
+ const wj = w * cb[j];
145
+ for (let i = 0; i < size; i++) {
146
+ const o = j * size + i;
147
+ cells[o] = cells[o] + wj * ca[i];
148
+ }
149
+ }
150
+ }
151
+ }
152
+ const area = beta * beta;
153
+ for (let i = 0; i < cells.length; i++)
154
+ cells[i] = cells[i] * area;
155
+ return { cells, radialNodes, angularNodes: 4 * quarter };
156
+ }
157
+ /**
158
+ * Creates a sampled lens PSF profile for an ideal diffraction-limited lens with a circular pupil: one
159
+ * unit-sum Airy kernel per wavelength on one shared (2R+1)² grid, with the truncated energy reported per
160
+ * wavelength. The profile declares the given focal length, f-number and focus exactly, and the field axes
161
+ * ±`fieldHalfExtentMm` with the same kernel at every field node.
162
+ * @param input - See `CreateDiffractionLimitedPsfProfileInput`.
163
+ * @returns The profile and its diffraction record. An approximation; not a measured lens.
164
+ * @see docs/PSF_FOUNDATION.md, "Diffraction-limited sampled profile (2.1)".
165
+ */
166
+ export function createDiffractionLimitedPsfProfile(input) {
167
+ const r = requireAllowlistedRecord(input, ["aperture", "focalLengthMm", "apertureFNumber", "focus", "wavelengthsNm", "samplePitchMicrometers",
168
+ "kernelRadiusSamples", "fieldHalfExtentMm", "profileId"], "Invalid diffraction-limited PSF profile input.");
169
+ if (r.aperture !== "circular")
170
+ throw new InvalidConfigurationError("aperture must be \"circular\".");
171
+ const focalLengthMm = positive(r.focalLengthMm, "focalLengthMm"), fNumber = positive(r.apertureFNumber, "apertureFNumber");
172
+ const pitch = positive(r.samplePitchMicrometers, "samplePitchMicrometers");
173
+ const L = DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS;
174
+ const radius = r.kernelRadiusSamples;
175
+ if (typeof radius !== "number" || !Number.isSafeInteger(radius) || radius < 0 || radius > L.maximumKernelRadiusSamples) {
176
+ throw new InvalidConfigurationError("kernelRadiusSamples must be an integer from 0 to " + L.maximumKernelRadiusSamples + ".");
177
+ }
178
+ const focus = requireAllowlistedRecord(r.focus, ["kind", "distanceM"], "Invalid focus.");
179
+ let focusDiopters, distanceMm;
180
+ if (focus.kind === "infinity") {
181
+ if (focus.distanceM !== undefined)
182
+ throw new InvalidConfigurationError("An infinity focus has no distanceM.");
183
+ focusDiopters = 0;
184
+ distanceMm = focalLengthMm;
185
+ }
186
+ else if (focus.kind === "finite") {
187
+ const distanceM = positive(focus.distanceM, "focus.distanceM");
188
+ focusDiopters = 1 / distanceM;
189
+ distanceMm = calculateThinLensImageDistance({ focalLengthMm, objectDistanceM: distanceM }).value.imageDistanceMm;
190
+ }
191
+ else
192
+ throw new InvalidConfigurationError("focus.kind must be finite or infinity.");
193
+ const field = requireAllowlistedRecord(r.fieldHalfExtentMm, ["x", "y"], "Invalid fieldHalfExtentMm.");
194
+ const fieldX = positive(field.x, "fieldHalfExtentMm.x"), fieldY = positive(field.y, "fieldHalfExtentMm.y");
195
+ const list = r.wavelengthsNm;
196
+ if (!Array.isArray(list) || list.length < 1 || list.length > L.maximumWavelengths)
197
+ throw new InvalidConfigurationError("wavelengthsNm must list 1 to 64 wavelengths.");
198
+ const wavelengths = list.map((value, index) => positive(value, "wavelengthsNm[" + index + "]"));
199
+ for (let i = 1; i < wavelengths.length; i++)
200
+ if (!(wavelengths[i] > wavelengths[i - 1]))
201
+ throw new InvalidConfigurationError("wavelengthsNm must be strictly increasing.");
202
+ if (typeof r.profileId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(r.profileId))
203
+ throw new InvalidConfigurationError("profileId must be a public opaque ID.");
204
+ const pupilDiameterMm = focalLengthMm / fNumber, workingFNumber = distanceMm / pupilDiameterMm, size = 2 * radius + 1;
205
+ let maximumRadialNodes = 0, maximumAngularNodes = 0, maximumTruncated = 0;
206
+ const records = [], kernels = [];
207
+ for (const nm of wavelengths) {
208
+ const lambdaN = nm / 1000 * workingFNumber, cutoff = 1 / lambdaN;
209
+ const airy = calculateAiryCellEnergies(pitch * cutoff, radius);
210
+ maximumRadialNodes = Math.max(maximumRadialNodes, airy.radialNodes);
211
+ maximumAngularNodes = Math.max(maximumAngularNodes, airy.angularNodes);
212
+ const values = new Array(size * size);
213
+ let captured = 0;
214
+ for (let ky = 0; ky < size; ky++)
215
+ for (let kx = 0; kx < size; kx++) {
216
+ const value = airy.cells[Math.abs(ky - radius) * (radius + 1) + Math.abs(kx - radius)];
217
+ // Every cell energy of a non-negative PSF is positive; a negative value would be a quadrature failure.
218
+ if (!(value > -1e-13))
219
+ throw new InvalidConfigurationError("Diffraction cell integration failed to converge.");
220
+ values[ky * size + kx] = Math.max(0, value);
221
+ captured += values[ky * size + kx];
222
+ }
223
+ if (!(captured > 0 && captured <= 1 + 1e-9))
224
+ throw new InvalidConfigurationError("Diffraction cell integration failed to converge.");
225
+ const truncated = Math.max(0, 1 - captured);
226
+ maximumTruncated = Math.max(maximumTruncated, truncated);
227
+ const kernel = values.map((v) => v / captured);
228
+ kernels.push(kernel);
229
+ records.push({ wavelengthNm: nm, cutoffFrequencyCyclesPerMicrometer: cutoff, firstZeroRadiusMicrometers: AIRY_FIRST_ZERO_PER_LAMBDA_N * lambdaN,
230
+ capturedEnergyFraction: captured, truncatedEnergyFraction: truncated, centreSampleFraction: kernel[radius * size + radius] });
231
+ }
232
+ const uncertainty = { kind: "not-quantified", limitation: "Ideal diffraction-limited model; no measured lens or quadrature error bound." };
233
+ const nodes = [-fieldX, fieldX].flatMap((fx) => [-fieldY, fieldY].flatMap((fy) => wavelengths.map((nm, index) => ({
234
+ nodeId: "airy-" + (fx < 0 ? "xm" : "xp") + "-" + (fy < 0 ? "ym" : "yp") + "-" + index,
235
+ coordinate: { focalLengthMm, focusDiopters, apertureFNumber: fNumber, fieldXmm: fx, fieldYmm: fy, wavelengthNm: nm, signedDefocusImagePlaneMicrometers: 0 },
236
+ kernel: { widthSamples: size, heightSamples: size, samplePitchMicrometersX: pitch, samplePitchMicrometersY: pitch, centerSampleX: radius, centerSampleY: radius,
237
+ normalizedIntensity: kernels[index] },
238
+ bestFocusImagePlaneOffsetMicrometers: 0, relativePupilThroughputFactor: 1, evidence: EVIDENCE, uncertainty
239
+ }))));
240
+ const profile = parseLensSampledPsfProfile({ schemaVersion: "0.1.0", profileId: r.profileId, profileVersion: DIFFRACTION_LIMITED_PSF_PROFILE_MODEL.modelVersion,
241
+ scientificStatus: "approximation", opticalDomain: "lens-primary-optical-path-only", coordinateSystem: "image-plane-metric", fieldAxes: "+X right, +Y up",
242
+ kernelEnergyNormalization: "unit-energy-shape", interpolation: "bounded-regular-grid-multilinear", throughputOwnership: "separate-relative-pupil-throughput-factor",
243
+ responseIncludes: { diffraction: true, aberration: false, defocus: false, pupilClippingShape: false },
244
+ sensorOpticalStackIncluded: false, sensorSamplingIncluded: false, reconstructionIncluded: false, strayLightIncluded: false,
245
+ axes: { focalLengthMm: [focalLengthMm], focusDiopters: [focusDiopters], apertureFNumber: [fNumber], fieldXmm: [-fieldX, fieldX], fieldYmm: [-fieldY, fieldY],
246
+ wavelengthNm: wavelengths, signedDefocusImagePlaneMicrometers: [0] },
247
+ nodes, evidence: EVIDENCE, limitations: [...LIMITATIONS] });
248
+ return approximationResult({ profile, model: DIFFRACTION_LIMITED_PSF_PROFILE_MODEL, pupilDiameterMm, pupilToImageDistanceMm: distanceMm, workingFNumber,
249
+ kernelRadiusSamples: radius, samplePitchMicrometers: pitch, wavelengths: records, maximumTruncatedEnergyFraction: maximumTruncated,
250
+ quadrature: { method: "polar-frequency-domain-trapezoid-gauss-legendre", maximumRadialNodes, maximumAngularNodes } }, DIFFRACTION_LIMITED_PSF_PROFILE_MODEL.modelId, DIFFRACTION_LIMITED_PSF_PROFILE_MODEL.modelVersion, [...LIMITATIONS]);
251
+ }
252
+ //# sourceMappingURL=diffraction-psf-profile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diffraction-psf-profile.js","sourceRoot":"","sources":["../../src/optics/diffraction-psf-profile.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Builds a `LensSampledPsfProfile` for an ideal, aberration-free, in-focus lens with a uniformly lit\n * circular pupil: the Airy pattern of scalar Fraunhofer diffraction, one unit-sum kernel per declared\n * wavelength on ONE shared sample grid (2.1.0).\n *\n * **Model.** The pupil diameter is D = f/N (entrance pupil = exit pupil, unity pupil magnification) and\n * the pupil-to-image distance z is the thin-lens image distance for the focus (f at infinity), so the\n * image-side (working) f-number is N_w = z/D. The incoherent optical transfer function (OTF) of a\n * uniform circular pupil is the normalized autocorrelation of the pupil,\n * H(ρ) = (2/π)·(acos ρ − ρ·√(1 − ρ²)), ρ = |f|/f_c ≤ 1, f_c = 1/(λ·N_w),\n * and the intensity PSF is its inverse Fourier transform (the Airy pattern, first zero at 1.21967·λ·N_w)\n * (MIT OpenCourseWare 2.71 Optics, Spring 2009, lectures 21–22, free: the intensity PSF as the transform of\n * the pupil autocorrelation and the circular-pupil MTF; also see: Goodman, Introduction to Fourier Optics,\n * 4th ed., 2017, §6.3).\n *\n * **Cell integration (exact, no Bessel functions).** Kernel sample (i, j) is the PSF energy inside the\n * square cell of side p centred on (i·p, j·p). The cell integral is the PSF convolved with the cell's\n * indicator, evaluated at the cell centre, so in the frequency domain\n * K(i, j) = p² ∫∫ H(|f|/f_c)·sinc(p·f_x)·sinc(p·f_y)·cos(2π·p·i·f_x)·cos(2π·p·j·f_y) d²f,\n * with sinc(x) = sin(πx)/(πx) (the sine terms vanish because H is even). It is evaluated in polar\n * coordinates: a trapezoid rule in angle (exponentially convergent for this smooth periodic integrand) and\n * Gauss–Legendre in t with ρ = 1 − t², which removes the (1 − ρ)^{3/2} edge behaviour of H. The node counts\n * follow from the integrand's bandwidth with a fixed margin; the tests check convergence by doubling them and\n * check the values against an independent real-space integration of `calculateIdealPolygonDiffractionPsf` in\n * its many-blade (circular) limit. The sum of all cells is exactly 1 (Parseval), so the energy captured by\n * the finite kernel is Σ K over its window and the truncated energy is 1 − Σ K.\n *\n * **Truncation and normalization.** The kernel is the (2R+1)² window of cells. The Airy tails fall only as\n * r⁻³, so a finite window always misses some energy: it is reported per wavelength, and the window is then\n * renormalized to unit sum because the sampled-profile contract requires unit-energy kernels. The missing\n * tail energy is thereby put back inside the window (an approximation that slightly raises low-frequency\n * contrast compared with the untruncated pattern).\n *\n * **Approximations (labelled):** scalar (no polarization), aberration-free, uniform pupil amplitude, exactly in\n * focus (no defocus), thin lens with unity pupil magnification, the on-axis PSF used across the declared field\n * (isoplanatic), PSF energy binned into the sample cells, finite window renormalized. Not a measured lens.\n * @see docs/PSF_FOUNDATION.md, \"Diffraction-limited sampled profile (2.1)\".\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 type { EvidenceProvenance } from \"../core/evidence-provenance.js\";\nimport type { FocusPlane } from \"./focus-state.js\";\nimport { calculateThinLensImageDistance } from \"./thin-lens.js\";\nimport { parseLensSampledPsfProfile, type LensSampledPsfProfile } from \"./lens-psf-profile.js\";\n\nexport const DIFFRACTION_LIMITED_PSF_PROFILE_MODEL = Object.freeze({\n modelId: \"photivra-diffraction-limited-circular-pupil-psf\" as const, modelVersion: \"1.0.0\" as const });\n/** Input limits: at most 64 wavelengths, kernel radius 0–32 samples (the irradiance-convolution limit). */\nexport const DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS = Object.freeze({ maximumWavelengths: 64, maximumKernelRadiusSamples: 32 });\n/** Radius of the first dark ring of the Airy pattern in units of λ·N_w: j₁,₁/π, j₁,₁ = 3.8317059702 (first zero of J₁). */\nconst AIRY_FIRST_ZERO_PER_LAMBDA_N = 3.8317059702075125 / Math.PI;\n\nexport interface CreateDiffractionLimitedPsfProfileInput {\n /** Only `circular` in 2.1.0 (polygon pupils: `calculateIdealPolygonDiffractionPsf` gives point densities). */\n aperture: \"circular\";\n focalLengthMm: number;\n apertureFNumber: number;\n focus: FocusPlane;\n /** Strictly increasing wavelengths, nm, in the medium of propagation (no air/vacuum conversion is applied). 1–64. */\n wavelengthsNm: readonly number[];\n /** Square sample pitch of every kernel, µm. */\n samplePitchMicrometers: number;\n /** Kernel half-width in samples; each kernel is (2R + 1)². 0–32. */\n kernelRadiusSamples: number;\n /** Half-extent of the image field over which the on-axis PSF is declared (profile field axes ±x, ±y), mm. */\n fieldHalfExtentMm: { x: number; y: number };\n profileId: string;\n}\n\nexport interface DiffractionLimitedPsfWavelength {\n wavelengthNm: number;\n /** f_c = 1/(λ·N_w), cycles per µm. */\n cutoffFrequencyCyclesPerMicrometer: number;\n /** 1.21967·λ·N_w, µm. */\n firstZeroRadiusMicrometers: number;\n /** Σ of the cell energies inside the window before renormalization (of a total of 1). */\n capturedEnergyFraction: number;\n /** 1 − capturedEnergyFraction: the Airy tail energy outside the window, put back by renormalization. */\n truncatedEnergyFraction: number;\n /** Fraction of the renormalized kernel in the centre sample. */\n centreSampleFraction: number;\n}\n\nexport interface DiffractionLimitedPsfProfile {\n profile: LensSampledPsfProfile;\n model: typeof DIFFRACTION_LIMITED_PSF_PROFILE_MODEL;\n pupilDiameterMm: number;\n pupilToImageDistanceMm: number;\n workingFNumber: number;\n kernelRadiusSamples: number;\n samplePitchMicrometers: number;\n wavelengths: readonly DiffractionLimitedPsfWavelength[];\n /** Largest truncated energy over the wavelengths. */\n maximumTruncatedEnergyFraction: number;\n quadrature: { method: \"polar-frequency-domain-trapezoid-gauss-legendre\"; maximumRadialNodes: number; maximumAngularNodes: number };\n}\n\nconst EVIDENCE: readonly EvidenceProvenance[] = Object.freeze([Object.freeze({ sourceOrigin: \"photivra\" as const,\n sourceReference: \"photivra:diffraction-limited-psf-profile\", reuseStatus: \"photivra-owned\" as const })]);\nconst LIMITATIONS = Object.freeze([\n \"Scalar Fraunhofer diffraction of an ideal, uniformly lit circular pupil (Airy pattern); no aberration, apodization, polarization or stray light.\",\n \"Exactly in focus: no defocus or depth of field. Thin lens with unity pupil magnification; the pupil-to-image distance is the thin-lens image distance.\",\n \"The on-axis PSF is declared unchanged across the field (isoplanatic); no field-dependent aberration or pupil clipping.\",\n \"PSF energy is binned into square sample cells; the finite window misses the Airy tail energy listed per wavelength and is renormalized to unit sum.\",\n \"Wavelengths are used as given, in the declared basis, without air/vacuum conversion.\"\n]);\n\nfunction positive(value: unknown, name: string): number {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) throw new InvalidConfigurationError(name + \" must be a positive finite number.\");\n return value;\n}\n\n/** Gauss–Legendre nodes and weights on [0, 1] (Newton iteration on Pₙ; DLMF §3.5(v)). Cached per n. */\nconst GAUSS_LEGENDRE = new Map<number, { nodes: Float64Array; weights: Float64Array }>();\nfunction gaussLegendre(n: number): { nodes: Float64Array; weights: Float64Array } {\n const cached = GAUSS_LEGENDRE.get(n);\n if (cached) return cached;\n const nodes = new Float64Array(n), weights = new Float64Array(n);\n for (let k = 0; k < Math.ceil(n / 2); k++) {\n let x = Math.cos(Math.PI * (k + 0.75) / (n + 0.5));\n for (let iteration = 0; iteration < 100; iteration++) {\n let p0 = 1, p1 = x;\n for (let m = 2; m <= n; m++) { const p2 = ((2 * m - 1) * x * p1 - (m - 1) * p0) / m; p0 = p1; p1 = p2; }\n const step = p1 / (n * (x * p1 - p0) / (x * x - 1));\n x -= step;\n if (Math.abs(step) < 1e-16) break;\n }\n let p0 = 1, p1 = x;\n for (let m = 2; m <= n; m++) { const p2 = ((2 * m - 1) * x * p1 - (m - 1) * p0) / m; p0 = p1; p1 = p2; }\n const derivative = n * (x * p1 - p0) / (x * x - 1);\n const w = 1 / ((1 - x * x) * derivative * derivative);\n // Map [-1, 1] to [0, 1]: t = (1 ± x)/2, weight w (2/(1-x²)P'² halved).\n nodes[k] = (1 - x) / 2; weights[k] = w;\n nodes[n - 1 - k] = (1 + x) / 2; weights[n - 1 - k] = w;\n }\n const result = { nodes, weights };\n if (GAUSS_LEGENDRE.size < 64) GAUSS_LEGENDRE.set(n, result);\n return result;\n}\nfunction sinc(x: number): number {\n if (x === 0) return 1;\n const a = Math.PI * x;\n return Math.sin(a) / a;\n}\n\n/**\n * Cell energies K(i, j), 0 ≤ i, j ≤ R, of the Airy pattern for β = p·f_c (cell side in units of the cut-off\n * period). `scale` multiplies the node counts (1 in production; the tests use 2 to check convergence).\n */\nexport function calculateAiryCellEnergies(beta: number, radius: number, scale = 1): { cells: Float64Array; radialNodes: number; angularNodes: number } {\n // Bandwidth of the angular and radial integrands: c = π·β·(2R + 1)·√2 (the sinc factors and the highest cosine).\n const c = Math.PI * beta * (2 * radius + 1) * Math.SQRT2, margin = 10 * Math.cbrt(c) + 24;\n const quarter = Math.ceil(scale * (c + margin) / 4), radialNodes = Math.ceil(scale * (c + margin));\n const { nodes, weights } = gaussLegendre(radialNodes), size = radius + 1, cells = new Float64Array(size * size);\n const ca = new Float64Array(size), cb = new Float64Array(size), angularStep = (Math.PI / 2) / quarter;\n for (let r = 0; r < radialNodes; r++) {\n const t = nodes[r]!, rho = 1 - t * t;\n // H(1 − t²) without cancellation: acos(1 − t²) = 2·asin(t/√2), ρ·√(1 − ρ²) = ρ·t·√(2 − t²).\n const otf = (2 / Math.PI) * (2 * Math.asin(t / Math.SQRT2) - rho * t * Math.sqrt(2 - t * t));\n // ∫₀¹ g(ρ) dρ = ∫₀¹ g(1 − t²)·2t dt; the 4 is the four symmetric quadrants of the angular trapezoid.\n const radialWeight = weights[r]! * 2 * t * rho * otf * 4 * angularStep;\n for (let k = 0; k <= quarter; k++) {\n const theta = k * angularStep, x = beta * rho * Math.cos(theta), y = beta * rho * Math.sin(theta);\n const w = radialWeight * (k === 0 || k === quarter ? 0.5 : 1) * sinc(x) * sinc(y);\n // cos(2π·i·x) and cos(2π·j·y) by the Chebyshev recurrence.\n const cx = Math.cos(2 * Math.PI * x), cy = Math.cos(2 * Math.PI * y);\n ca[0] = 1; cb[0] = 1;\n if (size > 1) { ca[1] = cx; cb[1] = cy; }\n for (let i = 2; i < size; i++) { ca[i] = 2 * cx * ca[i - 1]! - ca[i - 2]!; cb[i] = 2 * cy * cb[i - 1]! - cb[i - 2]!; }\n for (let j = 0; j < size; j++) {\n const wj = w * cb[j]!;\n for (let i = 0; i < size; i++) { const o = j * size + i; cells[o] = cells[o]! + wj * ca[i]!; }\n }\n }\n }\n const area = beta * beta;\n for (let i = 0; i < cells.length; i++) cells[i] = cells[i]! * area;\n return { cells, radialNodes, angularNodes: 4 * quarter };\n}\n\n/**\n * Creates a sampled lens PSF profile for an ideal diffraction-limited lens with a circular pupil: one\n * unit-sum Airy kernel per wavelength on one shared (2R+1)² grid, with the truncated energy reported per\n * wavelength. The profile declares the given focal length, f-number and focus exactly, and the field axes\n * ±`fieldHalfExtentMm` with the same kernel at every field node.\n * @param input - See `CreateDiffractionLimitedPsfProfileInput`.\n * @returns The profile and its diffraction record. An approximation; not a measured lens.\n * @see docs/PSF_FOUNDATION.md, \"Diffraction-limited sampled profile (2.1)\".\n */\nexport function createDiffractionLimitedPsfProfile(input: CreateDiffractionLimitedPsfProfileInput): CalculationResult<DiffractionLimitedPsfProfile> {\n const r = requireAllowlistedRecord(input, [\"aperture\", \"focalLengthMm\", \"apertureFNumber\", \"focus\", \"wavelengthsNm\", \"samplePitchMicrometers\",\n \"kernelRadiusSamples\", \"fieldHalfExtentMm\", \"profileId\"], \"Invalid diffraction-limited PSF profile input.\");\n if (r.aperture !== \"circular\") throw new InvalidConfigurationError(\"aperture must be \\\"circular\\\".\");\n const focalLengthMm = positive(r.focalLengthMm, \"focalLengthMm\"), fNumber = positive(r.apertureFNumber, \"apertureFNumber\");\n const pitch = positive(r.samplePitchMicrometers, \"samplePitchMicrometers\");\n const L = DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS;\n const radius = r.kernelRadiusSamples;\n if (typeof radius !== \"number\" || !Number.isSafeInteger(radius) || radius < 0 || radius > L.maximumKernelRadiusSamples) {\n throw new InvalidConfigurationError(\"kernelRadiusSamples must be an integer from 0 to \" + L.maximumKernelRadiusSamples + \".\");\n }\n const focus = requireAllowlistedRecord(r.focus, [\"kind\", \"distanceM\"], \"Invalid focus.\");\n let focusDiopters: number, distanceMm: number;\n if (focus.kind === \"infinity\") {\n if (focus.distanceM !== undefined) throw new InvalidConfigurationError(\"An infinity focus has no distanceM.\");\n focusDiopters = 0; distanceMm = focalLengthMm;\n } else if (focus.kind === \"finite\") {\n const distanceM = positive(focus.distanceM, \"focus.distanceM\");\n focusDiopters = 1 / distanceM;\n distanceMm = calculateThinLensImageDistance({ focalLengthMm, objectDistanceM: distanceM }).value.imageDistanceMm;\n } else throw new InvalidConfigurationError(\"focus.kind must be finite or infinity.\");\n const field = requireAllowlistedRecord(r.fieldHalfExtentMm, [\"x\", \"y\"], \"Invalid fieldHalfExtentMm.\");\n const fieldX = positive(field.x, \"fieldHalfExtentMm.x\"), fieldY = positive(field.y, \"fieldHalfExtentMm.y\");\n const list = r.wavelengthsNm;\n if (!Array.isArray(list) || list.length < 1 || list.length > L.maximumWavelengths) throw new InvalidConfigurationError(\"wavelengthsNm must list 1 to 64 wavelengths.\");\n const wavelengths = (list as unknown[]).map((value, index) => positive(value, \"wavelengthsNm[\" + index + \"]\"));\n for (let i = 1; i < wavelengths.length; i++) if (!(wavelengths[i]! > wavelengths[i - 1]!)) throw new InvalidConfigurationError(\"wavelengthsNm must be strictly increasing.\");\n if (typeof r.profileId !== \"string\" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(r.profileId)) throw new InvalidConfigurationError(\"profileId must be a public opaque ID.\");\n\n const pupilDiameterMm = focalLengthMm / fNumber, workingFNumber = distanceMm / pupilDiameterMm, size = 2 * radius + 1;\n let maximumRadialNodes = 0, maximumAngularNodes = 0, maximumTruncated = 0;\n const records: DiffractionLimitedPsfWavelength[] = [], kernels: number[][] = [];\n for (const nm of wavelengths) {\n const lambdaN = nm / 1000 * workingFNumber, cutoff = 1 / lambdaN;\n const airy = calculateAiryCellEnergies(pitch * cutoff, radius);\n maximumRadialNodes = Math.max(maximumRadialNodes, airy.radialNodes); maximumAngularNodes = Math.max(maximumAngularNodes, airy.angularNodes);\n const values: number[] = new Array(size * size);\n let captured = 0;\n for (let ky = 0; ky < size; ky++) for (let kx = 0; kx < size; kx++) {\n const value = airy.cells[Math.abs(ky - radius) * (radius + 1) + Math.abs(kx - radius)]!;\n // Every cell energy of a non-negative PSF is positive; a negative value would be a quadrature failure.\n if (!(value > -1e-13)) throw new InvalidConfigurationError(\"Diffraction cell integration failed to converge.\");\n values[ky * size + kx] = Math.max(0, value);\n captured += values[ky * size + kx]!;\n }\n if (!(captured > 0 && captured <= 1 + 1e-9)) throw new InvalidConfigurationError(\"Diffraction cell integration failed to converge.\");\n const truncated = Math.max(0, 1 - captured);\n maximumTruncated = Math.max(maximumTruncated, truncated);\n const kernel = values.map((v) => v / captured);\n kernels.push(kernel);\n records.push({ wavelengthNm: nm, cutoffFrequencyCyclesPerMicrometer: cutoff, firstZeroRadiusMicrometers: AIRY_FIRST_ZERO_PER_LAMBDA_N * lambdaN,\n capturedEnergyFraction: captured, truncatedEnergyFraction: truncated, centreSampleFraction: kernel[radius * size + radius]! });\n }\n const uncertainty = { kind: \"not-quantified\" as const, limitation: \"Ideal diffraction-limited model; no measured lens or quadrature error bound.\" };\n const nodes = [-fieldX, fieldX].flatMap((fx) => [-fieldY, fieldY].flatMap((fy) => wavelengths.map((nm, index) => ({\n nodeId: \"airy-\" + (fx < 0 ? \"xm\" : \"xp\") + \"-\" + (fy < 0 ? \"ym\" : \"yp\") + \"-\" + index,\n coordinate: { focalLengthMm, focusDiopters, apertureFNumber: fNumber, fieldXmm: fx, fieldYmm: fy, wavelengthNm: nm, signedDefocusImagePlaneMicrometers: 0 },\n kernel: { widthSamples: size, heightSamples: size, samplePitchMicrometersX: pitch, samplePitchMicrometersY: pitch, centerSampleX: radius, centerSampleY: radius,\n normalizedIntensity: kernels[index]! },\n bestFocusImagePlaneOffsetMicrometers: 0, relativePupilThroughputFactor: 1, evidence: EVIDENCE, uncertainty }))));\n const profile = parseLensSampledPsfProfile({ schemaVersion: \"0.1.0\", profileId: r.profileId, profileVersion: DIFFRACTION_LIMITED_PSF_PROFILE_MODEL.modelVersion,\n scientificStatus: \"approximation\", opticalDomain: \"lens-primary-optical-path-only\", coordinateSystem: \"image-plane-metric\", fieldAxes: \"+X right, +Y up\",\n kernelEnergyNormalization: \"unit-energy-shape\", interpolation: \"bounded-regular-grid-multilinear\", throughputOwnership: \"separate-relative-pupil-throughput-factor\",\n responseIncludes: { diffraction: true, aberration: false, defocus: false, pupilClippingShape: false },\n sensorOpticalStackIncluded: false, sensorSamplingIncluded: false, reconstructionIncluded: false, strayLightIncluded: false,\n axes: { focalLengthMm: [focalLengthMm], focusDiopters: [focusDiopters], apertureFNumber: [fNumber], fieldXmm: [-fieldX, fieldX], fieldYmm: [-fieldY, fieldY],\n wavelengthNm: wavelengths, signedDefocusImagePlaneMicrometers: [0] },\n nodes, evidence: EVIDENCE, limitations: [...LIMITATIONS] });\n return approximationResult({ profile, model: DIFFRACTION_LIMITED_PSF_PROFILE_MODEL, pupilDiameterMm, pupilToImageDistanceMm: distanceMm, workingFNumber,\n kernelRadiusSamples: radius, samplePitchMicrometers: pitch, wavelengths: records, maximumTruncatedEnergyFraction: maximumTruncated,\n quadrature: { method: \"polar-frequency-domain-trapezoid-gauss-legendre\" as const, maximumRadialNodes, maximumAngularNodes } },\n DIFFRACTION_LIMITED_PSF_PROFILE_MODEL.modelId, DIFFRACTION_LIMITED_PSF_PROFILE_MODEL.modelVersion, [...LIMITATIONS]);\n}\n"]}
@@ -1,6 +1,8 @@
1
1
  export declare const IRRADIANCE_CONVOLUTION_SCHEMA_VERSION: "0.1.0";
2
+ /** `maximumPixels` is the default grid limit; a request may raise it with its own `maximumPixels`, up to `ceilingPixels`. */
2
3
  export declare const IRRADIANCE_CONVOLUTION_LIMITS: Readonly<{
3
4
  maximumPixels: 24000000;
5
+ ceilingPixels: 134217728;
4
6
  maximumDimension: 16384;
5
7
  maximumKernelRadius: 32;
6
8
  maximumPatchCount: 4096;
@@ -27,6 +29,11 @@ export interface IrradianceConvolutionInput {
27
29
  */
28
30
  kernels: Float32Array | Float64Array;
29
31
  boundary: "zero-outside-grid";
32
+ /**
33
+ * Optional opt-in grid limit for large images: an integer from 1 to `IRRADIANCE_CONVOLUTION_LIMITS.ceilingPixels`.
34
+ * Default `IRRADIANCE_CONVOLUTION_LIMITS.maximumPixels` (24 000 000). It does not change the result.
35
+ */
36
+ maximumPixels?: number;
30
37
  }
31
38
  /** Validated, immutable geometry of a convolution request. Sample arrays are borrowed, not copied. */
32
39
  export interface IrradianceConvolutionPlan {
@@ -28,9 +28,12 @@
28
28
  */
29
29
  import { InvalidConfigurationError } from "../core/configuration-error.js";
30
30
  import { requireAllowlistedRecord } from "../core/record-validation.js";
31
+ import { FULL_RESOLUTION_CEILING_PIXELS, resolveRaisedLimit } from "../core/pixel-limits.js";
31
32
  export const IRRADIANCE_CONVOLUTION_SCHEMA_VERSION = "0.1.0";
33
+ /** `maximumPixels` is the default grid limit; a request may raise it with its own `maximumPixels`, up to `ceilingPixels`. */
32
34
  export const IRRADIANCE_CONVOLUTION_LIMITS = Object.freeze({
33
35
  maximumPixels: 24_000_000,
36
+ ceilingPixels: FULL_RESOLUTION_CEILING_PIXELS,
34
37
  maximumDimension: 16_384,
35
38
  maximumKernelRadius: 32,
36
39
  maximumPatchCount: 4_096,
@@ -49,13 +52,14 @@ function positiveInteger(value, maximum) {
49
52
  /** Validates the request and returns its plan. Every sample and weight is checked once. */
50
53
  export function calculateIrradianceConvolutionPlan(input) {
51
54
  const r = requireAllowlistedRecord(input, ["schemaVersion", "width", "height", "samples", "kernelRadius", "patchWidth",
52
- "patchHeight", "kernels", "boundary"], "Invalid irradiance convolution input.");
55
+ "patchHeight", "kernels", "boundary", "maximumPixels"], "Invalid irradiance convolution input.");
53
56
  const L = IRRADIANCE_CONVOLUTION_LIMITS;
54
57
  if (r.schemaVersion !== IRRADIANCE_CONVOLUTION_SCHEMA_VERSION || r.boundary !== "zero-outside-grid") {
55
58
  throw new InvalidConfigurationError("Unsupported irradiance convolution schema or boundary.");
56
59
  }
57
60
  const width = positiveInteger(r.width, L.maximumDimension), height = positiveInteger(r.height, L.maximumDimension);
58
- if (width * height > L.maximumPixels)
61
+ const maximumPixels = resolveRaisedLimit(r.maximumPixels, L.maximumPixels, L.ceilingPixels, "Irradiance convolution maximumPixels must be an integer from 1 to " + L.ceilingPixels + ".");
62
+ if (width * height > maximumPixels)
59
63
  throw new InvalidConfigurationError("Irradiance convolution grid exceeds the pixel limit.");
60
64
  const radius = r.kernelRadius;
61
65
  if (typeof radius !== "number" || !Number.isSafeInteger(radius) || radius < 0 || radius > L.maximumKernelRadius) {