@photivra/engine 0.5.0 → 0.6.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 (79) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CITATION.cff +1 -1
  3. package/README.md +37 -3
  4. package/dist/index.d.ts +15 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +15 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/motion/camera-rotation.d.ts +60 -0
  9. package/dist/motion/camera-rotation.d.ts.map +1 -1
  10. package/dist/motion/camera-rotation.js +95 -0
  11. package/dist/motion/camera-rotation.js.map +1 -1
  12. package/dist/motion/capture-rotation-inverse-mapping.d.ts +100 -0
  13. package/dist/motion/capture-rotation-inverse-mapping.d.ts.map +1 -0
  14. package/dist/motion/capture-rotation-inverse-mapping.js +171 -0
  15. package/dist/motion/capture-rotation-inverse-mapping.js.map +1 -0
  16. package/dist/motion/capture-rotation-temporal-quadrature.d.ts +89 -0
  17. package/dist/motion/capture-rotation-temporal-quadrature.d.ts.map +1 -0
  18. package/dist/motion/capture-rotation-temporal-quadrature.js +116 -0
  19. package/dist/motion/capture-rotation-temporal-quadrature.js.map +1 -0
  20. package/dist/motion/capture-rotation-trajectory.d.ts +98 -0
  21. package/dist/motion/capture-rotation-trajectory.d.ts.map +1 -0
  22. package/dist/motion/capture-rotation-trajectory.js +153 -0
  23. package/dist/motion/capture-rotation-trajectory.js.map +1 -0
  24. package/dist/optics/lateral-chromatic-aberration.d.ts.map +1 -1
  25. package/dist/optics/lateral-chromatic-aberration.js +12 -4
  26. package/dist/optics/lateral-chromatic-aberration.js.map +1 -1
  27. package/dist/optics/radial-distortion.d.ts +4 -2
  28. package/dist/optics/radial-distortion.d.ts.map +1 -1
  29. package/dist/optics/radial-distortion.js +103 -28
  30. package/dist/optics/radial-distortion.js.map +1 -1
  31. package/dist/output/capture-geometry.d.ts.map +1 -1
  32. package/dist/output/capture-geometry.js +24 -11
  33. package/dist/output/capture-geometry.js.map +1 -1
  34. package/dist/sensor/capture-color-sampling-binding.d.ts +211 -0
  35. package/dist/sensor/capture-color-sampling-binding.d.ts.map +1 -0
  36. package/dist/sensor/capture-color-sampling-binding.js +423 -0
  37. package/dist/sensor/capture-color-sampling-binding.js.map +1 -0
  38. package/dist/sensor/capture-mode.d.ts +157 -0
  39. package/dist/sensor/capture-mode.d.ts.map +1 -0
  40. package/dist/sensor/capture-mode.js +434 -0
  41. package/dist/sensor/capture-mode.js.map +1 -0
  42. package/dist/sensor/color-sampling.d.ts +133 -0
  43. package/dist/sensor/color-sampling.d.ts.map +1 -0
  44. package/dist/sensor/color-sampling.js +226 -0
  45. package/dist/sensor/color-sampling.js.map +1 -0
  46. package/dist/sensor/exposure-window.d.ts +103 -0
  47. package/dist/sensor/exposure-window.d.ts.map +1 -0
  48. package/dist/sensor/exposure-window.js +291 -0
  49. package/dist/sensor/exposure-window.js.map +1 -0
  50. package/dist/sensor/optical-stack.d.ts +132 -0
  51. package/dist/sensor/optical-stack.d.ts.map +1 -0
  52. package/dist/sensor/optical-stack.js +286 -0
  53. package/dist/sensor/optical-stack.js.map +1 -0
  54. package/dist/sensor/readout-exposure-linkage.d.ts +111 -0
  55. package/dist/sensor/readout-exposure-linkage.d.ts.map +1 -0
  56. package/dist/sensor/readout-exposure-linkage.js +210 -0
  57. package/dist/sensor/readout-exposure-linkage.js.map +1 -0
  58. package/dist/sensor/readout-timing.d.ts +124 -0
  59. package/dist/sensor/readout-timing.d.ts.map +1 -0
  60. package/dist/sensor/readout-timing.js +230 -0
  61. package/dist/sensor/readout-timing.js.map +1 -0
  62. package/dist/sensor/sampling-aperture.d.ts +154 -0
  63. package/dist/sensor/sampling-aperture.d.ts.map +1 -0
  64. package/dist/sensor/sampling-aperture.js +374 -0
  65. package/dist/sensor/sampling-aperture.js.map +1 -0
  66. package/dist/sensor/spatial-sample-reduction.d.ts +128 -0
  67. package/dist/sensor/spatial-sample-reduction.d.ts.map +1 -0
  68. package/dist/sensor/spatial-sample-reduction.js +299 -0
  69. package/dist/sensor/spatial-sample-reduction.js.map +1 -0
  70. package/dist/sensor/spatial-sampling-quadrature.d.ts +155 -0
  71. package/dist/sensor/spatial-sampling-quadrature.d.ts.map +1 -0
  72. package/dist/sensor/spatial-sampling-quadrature.js +197 -0
  73. package/dist/sensor/spatial-sampling-quadrature.js.map +1 -0
  74. package/docs/ARCHITECTURE.md +17 -0
  75. package/docs/AUTOMATION_COORDINATION.md +188 -0
  76. package/docs/IMAGE_FORMATION.md +26 -0
  77. package/docs/MOTION_AND_SIGNAL.md +142 -0
  78. package/docs/USAGE.md +1273 -3
  79. package/package.json +5 -3
package/docs/USAGE.md CHANGED
@@ -233,7 +233,7 @@ p_distorted = p_ideal × scale
233
233
 
234
234
  The coefficients are dimensionless **only together with the declared physical normalization radius**. Reusing coefficients with a different normalization changes the model.
235
235
 
236
- `maximumNormalizedRadius` is the caller-declared valid operating envelope. Photivra analytically checks that radial distance remains strictly monotonic over that interval; profiles that fold over are rejected so inverse mapping is unique.
236
+ `maximumNormalizedRadius` is the caller-declared valid operating envelope. Photivra analytically checks that radial distance remains strictly monotonic over that interval, including near-linear higher-order coefficient cases; profiles that fold over are rejected so inverse mapping is unique. Derived normalized radii must remain finite, and malformed runtime point/batch inputs fail closed rather than entering the inverse solver.
237
237
 
238
238
  This first field-mapping slice is radial-only and centered on the optical axis. It does not model tangential/decentered distortion, anamorphic mapping, wavelength dependence, or a calibrated named lens.
239
239
 
@@ -657,10 +657,984 @@ Source origin and reuse rights are also independent. Manufacturer or third-party
657
657
 
658
658
  Scalar facts may cite multiple evidence records. Multi-valued capabilities such as rolling/global readout carry evidence independently for each value so one source does not silently support another capability.
659
659
 
660
- `readoutCapabilities` describes hardware capabilities, not the mode selected for one exposure. Capture-specific readout selection and timing belong to later readout/capture-mode models.
660
+ `readoutCapabilities` describes hardware capabilities, not the mode selected for one exposure. The capture-mode profile can declare that a selected mode needs mode-specific readout timing, while the actual timing values remain owned by the separate readout-timing contract.
661
661
 
662
662
  Unknown facts should be omitted instead of inferred. Architecture metadata remains descriptive only: BSI, stacking, readout family, and CFA family do not directly change FOV, crop factor, pixel pitch, exposure, noise, or dynamic range. A separate documented downstream physical/calibration model is required before any such effect can be claimed.
663
663
 
664
+ ## Color-sampling topology
665
+
666
+ Use `parseSensorColorSamplingProfile()` when an exact color-sampling topology is known independently from descriptive sensor-family metadata:
667
+
668
+ ```ts
669
+ import {
670
+ parseSensorColorSamplingProfile,
671
+ resolveColorSamplingSite
672
+ } from "@photivra/engine";
673
+
674
+ const topology = parseSensorColorSamplingProfile({
675
+ schemaVersion: "0.1.0",
676
+ profileId: "example-periodic-layout",
677
+ evidence: [
678
+ {
679
+ sourceOrigin: "photivra",
680
+ sourceReference: "example:periodic-layout",
681
+ reuseStatus: "photivra-owned"
682
+ }
683
+ ],
684
+ coordinateSystem:
685
+ "native-sensor-color-sampling-site-index",
686
+ layout: {
687
+ kind: "periodic-mosaic",
688
+ repeatWidthSites: 2,
689
+ repeatHeightSites: 2,
690
+ siteChannelIds: [
691
+ "red-like",
692
+ "green-a",
693
+ "green-b",
694
+ "blue-like"
695
+ ],
696
+ anchor: "native-sensor-top-left-site"
697
+ }
698
+ });
699
+
700
+ const site = resolveColorSamplingSite({
701
+ profile: topology,
702
+ site: { x: 5, y: 8 }
703
+ });
704
+
705
+ console.log(site.mapping);
706
+ ```
707
+
708
+ The topology coordinate system is a distinct **native sensor color-sampling-site lattice**:
709
+
710
+ - zero-based integer site indices;
711
+ - origin at the native sensor's top-left sampling site;
712
+ - +X right and +Y down;
713
+ - periodic phase anchored to absolute sensor-site indices.
714
+
715
+ It is intentionally **not** `NativeImageRaster`. The existing native image raster is an effective image-sampling grid and does not guarantee one sample per physical photodiode or one sample per color-filter site. A later explicit binding must establish how a selected capture mode's effective raster maps to this topology before RAW/CFA sampling can be simulated.
716
+
717
+ That distinction also protects crop phase. If an active crop begins part-way through a repeating tile, callers must preserve the original absolute native site indices. Crop-local `(0,0)` must not silently become a new CFA origin.
718
+
719
+ `siteChannelIds` are semantic measurement-channel identifiers only. Names such as `red-like`, `green-a`, or `clear` do not establish:
720
+
721
+ - wavelength response;
722
+ - quantum efficiency;
723
+ - spectral sensitivity;
724
+ - colorimetric primaries;
725
+ - white balance behavior;
726
+ - calibrated sensor response.
727
+
728
+ Those belong to later spectral/radiometric contracts.
729
+
730
+ The periodic layout is generic. It can represent a 2×2 Bayer-like phase, larger repeating mosaics, grouped-color tiles, RGBW-like layouts, or other periodic channel arrangements without adding a manufacturer-specific layout enum. Distinct green positions can use the same channel ID or separate IDs when later calibration requires them to remain distinct.
731
+
732
+ The exact topology is also separate from `SensorArchitectureProfile.colorSamplingFamily`. A family such as `bayer`, `quad-bayer`, `custom-rgb-mosaic`, or `layered-color` is descriptive metadata; Photivra never invents an exact repeating tile from that family label.
733
+
734
+ Monochrome is represented separately with one semantic measurement-channel ID and no spatial mosaic.
735
+
736
+ Layered color is deliberately **structural-only** in schema 0.1.0 and uses a separate unresolved spatial-reference marker rather than pretending it shares the periodic site lattice:
737
+
738
+ ```ts
739
+ const layered = parseSensorColorSamplingProfile({
740
+ schemaVersion: "0.1.0",
741
+ profileId: "example-layered",
742
+ evidence: [
743
+ {
744
+ sourceOrigin: "photivra",
745
+ sourceReference: "example:layered",
746
+ reuseStatus: "photivra-owned"
747
+ }
748
+ ],
749
+ coordinateSystem:
750
+ "native-sensor-layered-spatial-relationship-not-resolved",
751
+ layout: {
752
+ kind: "layered",
753
+ layerChannelIds: [
754
+ "layer-a",
755
+ "layer-b",
756
+ "layer-c"
757
+ ],
758
+ spatialSamplingRelationship: "not-resolved"
759
+ }
760
+ });
761
+ ```
762
+
763
+ The engine does not expose per-site layered resolution yet. Real layered designs can use unequal per-layer spatial density or registration, so assuming one coincident equal-resolution RGB triplet at every site would be scientifically unsafe.
764
+
765
+ Schema 0.1.0 also does not model sparse exceptions to an otherwise periodic layout, such as phase-detect sites, masked sites, sensor defects, or other non-periodic overrides.
766
+
767
+ Physical orientation remains downstream. The native topology is not rotated or re-phased merely because the camera is held vertically. Likewise, changing CFA topology does not change physical imaging area, crop factor, FOV, or output geometry.
768
+
769
+ ## Capture-mode/color-sampling binding
770
+
771
+ Use `parseNativeEffectiveRasterColorSamplingBindingProfile()` when evidence establishes how one exact canonical `NativeImageRaster` relates to the separate native color-sampling-site lattice.
772
+
773
+ The first binding is deliberately regular and sensor-anchored:
774
+
775
+ ```ts
776
+ import {
777
+ parseNativeEffectiveRasterColorSamplingBindingProfile,
778
+ resolveNativeEffectiveRasterColorSamplingBinding
779
+ } from "@photivra/engine";
780
+
781
+ const binding = parseNativeEffectiveRasterColorSamplingBindingProfile({
782
+ schemaVersion: "0.1.0",
783
+ bindingId: "example-native-to-cfa",
784
+ colorSamplingProfileId: "example-periodic-layout",
785
+ nativeRaster: {
786
+ pixelWidth: 6000,
787
+ pixelHeight: 4000
788
+ },
789
+ evidence: [
790
+ {
791
+ sourceOrigin: "photivra",
792
+ sourceReference: "example:native-to-cfa-binding",
793
+ reuseStatus: "photivra-owned"
794
+ }
795
+ ],
796
+ relationship: {
797
+ kind: "regular-native-effective-sample-blocks",
798
+ sitesPerNativeSampleX: 1,
799
+ sitesPerNativeSampleY: 1,
800
+ anchor: "shared-native-top-left"
801
+ }
802
+ });
803
+
804
+ const resolvedBinding =
805
+ resolveNativeEffectiveRasterColorSamplingBinding({
806
+ nativeRaster: {
807
+ pixelWidth: 6000,
808
+ pixelHeight: 4000
809
+ },
810
+ colorSamplingProfile: topology,
811
+ bindingProfile: binding
812
+ });
813
+ ```
814
+
815
+ A `1 × 1` relationship is still an **asserted/evidenced binding**. Matching dimensions, matching aspect ratio, output megapixels, or a family label such as Bayer never prove that one `NativeImageRaster` sample corresponds to one color-sampling site.
816
+
817
+ The binding applies only to the exact native raster dimensions recorded in the profile. The first schema supports one native effective sample mapping to a regular rectangular block of color-sampling sites with a shared native top-left anchor. Irregular site relationships require a later explicit mapping.
818
+
819
+ To compose that sensor-level relationship with a capture mode, use `resolveCaptureModeColorSamplingContributors()`:
820
+
821
+ ```ts
822
+ import {
823
+ resolveCaptureModeColorSamplingContributors
824
+ } from "@photivra/engine";
825
+
826
+ const sources =
827
+ resolveCaptureModeColorSamplingContributors({
828
+ nativeRaster: {
829
+ pixelWidth: 6000,
830
+ pixelHeight: 4000
831
+ },
832
+ captureModeProfile: modes,
833
+ modeId: "grouped",
834
+ colorSamplingProfile: topology,
835
+ bindingProfile: binding,
836
+ modeSampleIndexFullFrame: {
837
+ x: 10,
838
+ y: 20
839
+ },
840
+ groupedSamplingAnchor: {
841
+ anchor: "native-effective-raster-top-left",
842
+ evidence: [
843
+ {
844
+ sourceOrigin: "photivra",
845
+ sourceReference: "example:group-phase",
846
+ reuseStatus: "photivra-owned"
847
+ }
848
+ ]
849
+ }
850
+ });
851
+
852
+ console.log(sources.colorSamplingSiteRect);
853
+ console.log(sources.channelSiteCounts);
854
+ console.log(sources.channelComposition);
855
+ ```
856
+
857
+ `modeSampleIndexFullFrame` is always expressed in the selected mode's **full-frame per-frame effective sampling raster**. The API intentionally does not accept crop-local coordinates. If an active crop starts away from the native origin, a caller must preserve or recover the absolute full-frame mode index before resolving CFA structure. This prevents active crops from silently re-phasing the mosaic.
858
+
859
+ For `grouped-native-samples` modes, group width/height alone do not prove group phase. The caller must separately provide evidence that the groups are anchored at the native effective raster top-left. A future contract can add other explicitly evidenced group phases if required.
860
+
861
+ The result is a compact **pre-reconstruction structural source region**:
862
+
863
+ - `nativeEffectiveSampleRect` identifies the absolute native effective samples structurally associated with the mode sample;
864
+ - `colorSamplingSiteRect` identifies the corresponding absolute color-site rectangle;
865
+ - `channelSiteCounts` gives exact counts per semantic channel for monochrome/periodic topology;
866
+ - `channelComposition` reports whether the structural region contains one channel or multiple channels.
867
+
868
+ The bridge does **not** enumerate every contributing site. It also does not establish sum/average weights, spectral response, photon/electron values, or a complete downstream reconstructed-pixel dependency graph.
869
+
870
+ A mixed-channel region therefore remains mixed. Photivra does not collapse a red/green/blue structural region into one synthetic CFA channel merely because a capture mode groups those native effective samples. `combinationDomain` from the capture mode remains separate from any unimplemented weighting/combination equation.
871
+
872
+ `declared-effective-raster` modes fail closed even when their dimensions happen to match the native raster. Their relationship was intentionally declared non-simple in #13 and therefore needs a mode-specific color-site mapping rather than dimension inference.
873
+
874
+ Layered-color topology also remains unbound because schema 0.1.0 does not yet define per-layer spatial density/registration.
875
+
876
+ Pixel-shift/sensor-shift metadata is preserved but does not change CFA assignment. The sensor and its filters move together relative to the optical image; the optical-registration effect of that shift belongs to a later image-formation contract.
877
+
878
+ ## Sensor optical stack
879
+
880
+ Use `parseSensorOpticalStackProfile()` for evidence-backed physical stack metadata and a separately declared **effective anti-aliasing spatial response**:
881
+
882
+ ```ts
883
+ import {
884
+ parseSensorOpticalStackProfile,
885
+ resolveAntiAliasingSpatialKernel
886
+ } from "@photivra/engine";
887
+
888
+ const stack = parseSensorOpticalStackProfile({
889
+ schemaVersion: "0.1.0",
890
+ profileId: "example-stack",
891
+ evidence: [
892
+ {
893
+ sourceOrigin: "photivra",
894
+ sourceReference: "example:stack",
895
+ reuseStatus: "photivra-owned"
896
+ }
897
+ ],
898
+ orderedComponents: [
899
+ {
900
+ componentId: "front-pack",
901
+ roles: [
902
+ "cover-glass",
903
+ "infrared-cut",
904
+ "anti-reflection"
905
+ ],
906
+ evidence: [
907
+ {
908
+ sourceOrigin: "photivra",
909
+ sourceReference: "example:front-pack",
910
+ reuseStatus: "photivra-owned"
911
+ }
912
+ ]
913
+ }
914
+ ],
915
+ effectiveAntiAliasingSpatialResponse: {
916
+ kind: "normalized-point-splitting-kernel",
917
+ evidence: [
918
+ {
919
+ sourceOrigin: "photivra",
920
+ sourceReference: "example:aa-kernel",
921
+ reuseStatus: "photivra-owned"
922
+ }
923
+ ],
924
+ coordinateSystem: "native-sensor-physical",
925
+ scope:
926
+ "field-wavelength-polarization-invariant-approximation",
927
+ components: [
928
+ {
929
+ offsetMicrometers: { x: -1, y: 0 },
930
+ normalizedWeight: 0.5
931
+ },
932
+ {
933
+ offsetMicrometers: { x: 1, y: 0 },
934
+ normalizedWeight: 0.5
935
+ }
936
+ ]
937
+ },
938
+ microlens: {
939
+ presence: "present",
940
+ evidence: [
941
+ {
942
+ sourceOrigin: "photivra",
943
+ sourceReference: "example:microlens-presence",
944
+ reuseStatus: "photivra-owned"
945
+ }
946
+ ],
947
+ opticalEffectModel: "unresolved"
948
+ }
949
+ });
950
+
951
+ const aaKernel =
952
+ resolveAntiAliasingSpatialKernel(stack);
953
+
954
+ console.log(aaKernel.components);
955
+ ```
956
+
957
+ The physical component list and effective AA response are intentionally separate. A stack may contain birefringent/retarder elements while the net intentional low-pass response is cancelled or otherwise effectively absent.
958
+
959
+ `effectiveAntiAliasingSpatialResponse` has three first-stage states:
960
+
961
+ - omitted — **unknown/unasserted**;
962
+ - `kind: "absent"` — evidence says this model should add no intentional AA spatial-splitting term;
963
+ - `kind: "present-unresolved"` — an AA effect exists but its spatial response is not defensibly known;
964
+ - `kind: "normalized-point-splitting-kernel"` — the caller explicitly supplies a normalized spatial point-splitting approximation.
965
+
966
+ Unknown and absent are not interchangeable. Likewise, `absent` does **not** mean the complete sensor stack has an identity PSF, and it does not mean aliasing or moiré are impossible. Lens PSF, cover/filter behavior, microlenses, sampling topology, and scene detail still matter.
967
+
968
+ The point-splitting kernel is deliberately generic:
969
+
970
+ - component count is arbitrary rather than fixed to four rays;
971
+ - offsets are in micrometres in `native-sensor-physical` coordinates (+X right, +Y down);
972
+ - physical camera orientation does not rotate/redefine the stored kernel;
973
+ - normalized weights must sum to one;
974
+ - the weights describe **spatial redistribution only**.
975
+
976
+ They do not represent total optical throughput, spectral transmission, photon efficiency, QE, or fill factor. Those belong to separate radiometric/sensor-response contracts.
977
+
978
+ The first point-splitting scope is explicitly `field-wavelength-polarization-invariant-approximation`. Real OLPF behavior may depend on wavelength, incidence angle/field position, polarization, materials, and stack geometry. Do not upgrade this generic kernel into a calibrated whole-stack PSF.
979
+
980
+ `orderedComponents` is incident-light → sensor descriptive metadata. Component roles can include cover glass, IR/UV filtering, AR treatment, birefringent low-pass elements, wave plates, and other optical filters. Schema 0.1.0 does not derive transmission, refractive focus shift, aberration, thickness effects, or a spectral response from those roles.
981
+
982
+ Microlens presence or absence is also descriptive only. A `present` microlens does not by itself specify angular acceptance, spatial concentration, fill factor, crosstalk, QE improvement, or field behavior.
983
+
984
+ The resolver therefore returns only the **effective AA spatial kernel** and explicitly reports that cover/filter effects, microlens response, throughput, spectral transmission, field dependence, wavelength dependence, and polarization dependence are not included.
985
+
986
+ ## Sensor sampling aperture
987
+
988
+ Use `parseSensorSamplingApertureProfile()` and `resolveSensorSamplingAperture()` when the physical color-site lattice registration and a geometric photosensitive-region approximation are known:
989
+
990
+ ```ts
991
+ import {
992
+ parseSensorSamplingApertureProfile,
993
+ resolveSensorSamplingAperture
994
+ } from "@photivra/engine";
995
+
996
+ const sampling = parseSensorSamplingApertureProfile({
997
+ schemaVersion: "0.1.0",
998
+ profileId: "example-sampling-aperture",
999
+ colorSamplingProfileId: "example-periodic-layout",
1000
+ colorSamplingBindingId: "example-native-to-cfa",
1001
+ evidence: [
1002
+ {
1003
+ sourceOrigin: "photivra",
1004
+ sourceReference: "example:sampling-aperture",
1005
+ reuseStatus: "photivra-owned"
1006
+ }
1007
+ ],
1008
+ siteCenterLattice: {
1009
+ kind: "regular-rectangular-site-center-lattice",
1010
+ coordinateSystem: "native-sensor-physical",
1011
+ pitchXMicrometers: 6,
1012
+ pitchYMicrometers: 6,
1013
+ firstSiteCenterFromImagingAreaTopLeftMicrometers: {
1014
+ x: 3,
1015
+ y: 3
1016
+ },
1017
+ evidence: [
1018
+ {
1019
+ sourceOrigin: "photivra",
1020
+ sourceReference: "example:site-registration",
1021
+ reuseStatus: "photivra-owned"
1022
+ }
1023
+ ]
1024
+ },
1025
+ geometricSensitiveAperture: {
1026
+ kind: "uniform-axis-aligned-rectangle",
1027
+ widthMicrometers: 5,
1028
+ heightMicrometers: 5,
1029
+ centerOffsetFromSiteCenterMicrometers: {
1030
+ x: 0,
1031
+ y: 0
1032
+ },
1033
+ evidence: [
1034
+ {
1035
+ sourceOrigin: "photivra",
1036
+ sourceReference: "example:sensitive-region",
1037
+ reuseStatus: "photivra-owned"
1038
+ }
1039
+ ]
1040
+ }
1041
+ });
1042
+
1043
+ const siteAperture = resolveSensorSamplingAperture({
1044
+ imagingArea: {
1045
+ widthMm: 36,
1046
+ heightMm: 24
1047
+ },
1048
+ nativeRaster: {
1049
+ pixelWidth: 6000,
1050
+ pixelHeight: 4000
1051
+ },
1052
+ colorSamplingProfile: topology,
1053
+ colorSamplingBindingProfile: binding,
1054
+ samplingApertureProfile: sampling,
1055
+ site: { x: 100, y: 200 }
1056
+ });
1057
+
1058
+ console.log(
1059
+ siteAperture.geometricSensitiveAperture
1060
+ .boundsFromOpticalAxisMm
1061
+ );
1062
+ ```
1063
+
1064
+ The site-center lattice is a distinct physical registration contract:
1065
+
1066
+ - coordinates are `native-sensor-physical`: optical-axis origin, +X right, +Y down;
1067
+ - `pitchXMicrometers` and `pitchYMicrometers` are explicit evidence-backed site-center spacings;
1068
+ - the first site's center is explicitly registered relative to the physical imaging area's top-left edge;
1069
+ - the full declared lattice must fit inside the supplied physical imaging area.
1070
+
1071
+ Photivra does **not** derive this site pitch or origin from `NativeImageRaster`. The native effective raster and the color-site lattice already have a structural binding, but physical placement remains a separate fact.
1072
+
1073
+ The geometric sensitive aperture is separate from site pitch. A scalar fill fraction is not sufficient to reconstruct shape or centering, so schema 0.1.0 stores explicit rectangle dimensions and center offset.
1074
+
1075
+ The first resolved footprint is deliberately narrow:
1076
+
1077
+ - uniform axis-aligned rectangle;
1078
+ - contained within one regular lattice cell;
1079
+ - no neighboring geometric-aperture overlap in this model;
1080
+ - normalized spatial weighting is a unit-area average;
1081
+ - physical aperture area is reported separately.
1082
+
1083
+ The engine also derives `geometricSensitiveAreaFractionOfLatticeCell`:
1084
+
1085
+ ```text
1086
+ geometric sensitive rectangle area
1087
+ ----------------------------------
1088
+ nominal site lattice cell area
1089
+ ```
1090
+
1091
+ This is a **geometry diagnostic**, not a calibrated sensitivity result. It does not establish QE, effective radiometric collection area, photon conversion, optical throughput, or physical photodiode truth.
1092
+
1093
+ Use `kind: "unresolved"` when the geometric footprint is known to be required but its shape/dimensions are not defensibly available. Resolution then fails closed instead of fabricating a rectangle from site pitch, megapixels, or a fill-factor percentage.
1094
+
1095
+ The first sampling-aperture model explicitly excludes:
1096
+
1097
+ - effective AA point splitting;
1098
+ - microlens spatial redirection;
1099
+ - charge diffusion;
1100
+ - electrical/optical crosstalk;
1101
+ - wavelength/spectral response;
1102
+ - quantum efficiency;
1103
+ - optical transmission/throughput;
1104
+ - calibrated radiometric collection area.
1105
+
1106
+ Those effects may change the **effective** spatial response or sensitivity even when the geometric sensitive region is unchanged. For example, microlenses can redirect incident light toward the photosensitive portion of a front-illuminated pixel, and their effectiveness can vary with incidence angle. The geometric aperture must therefore not absorb microlens behavior silently.
1107
+
1108
+ Physical camera orientation remains downstream. The site lattice and aperture stay in invariant native sensor physical coordinates.
1109
+
1110
+ ## Sensor spatial-sampling quadrature
1111
+
1112
+ Use `calculateSensorSpatialSamplingQuadrature()` to build deterministic renderer-neutral spatial integration nodes for one resolved color-sampling site after combining:
1113
+
1114
+ 1. the geometric sensitive aperture from `SensorSamplingApertureProfile`; and
1115
+ 2. the effective anti-aliasing point-splitting response from `SensorOpticalStackProfile`.
1116
+
1117
+ ```ts
1118
+ import {
1119
+ calculateSensorSpatialSamplingQuadrature
1120
+ } from "@photivra/engine";
1121
+
1122
+ const quadrature =
1123
+ calculateSensorSpatialSamplingQuadrature({
1124
+ imagingArea: { widthMm: 36, heightMm: 24 },
1125
+ nativeRaster: {
1126
+ pixelWidth: 6000,
1127
+ pixelHeight: 4000
1128
+ },
1129
+ colorSamplingProfile: topology,
1130
+ colorSamplingBindingProfile: binding,
1131
+ samplingApertureProfile: sampling,
1132
+ opticalStackProfile: stack,
1133
+ site: { x: 100, y: 200 },
1134
+ spatialSampleCountX: 4,
1135
+ spatialSampleCountY: 4
1136
+ });
1137
+
1138
+ console.log(quadrature.value.nodes);
1139
+ ```
1140
+
1141
+ The first scheme is a tensor-product uniform midpoint rule over the geometric aperture. Each AA split component multiplies that aperture quadrature.
1142
+
1143
+ AA composition uses **inverse source sampling**. If the optical stack declares that a component displaces optical energy by `+delta` on the sensor plane, a destination aperture point reads the pre-AA optical field at:
1144
+
1145
+ ```text
1146
+ pre-AA source = destination aperture point - AA displacement
1147
+ ```
1148
+
1149
+ The destination sensor site's CFA/color channel applies to every node. A shifted pre-AA source coordinate is an optical-field location, not another CFA site, so it never reassigns the destination CFA channel.
1150
+
1151
+ Two measures are returned separately:
1152
+
1153
+ - `combinedNormalizedSpatialWeight` — dimensionless weight for approximating a spatial average;
1154
+ - `combinedAreaMeasureSquareMicrometers` — geometric area measure for approximating a spatial area integral.
1155
+
1156
+ The area measure is **not** effective radiometric collection area, throughput, QE, or photon count.
1157
+
1158
+ Near sensor edges, AA inverse-source coordinates may lie outside the active physical imaging area. Those nodes remain valid and are retained. Photivra does not clamp them, drop them, or renormalize the remaining weights. A renderer/optical-field provider owns source-coverage policy.
1159
+
1160
+ The API intentionally returns geometry and measures only. It does not evaluate optical-field values, radiance, spectral response, photons/electrons, temporal exposure, RAW values, or reconstruction. Spatial and temporal quadrature remain separate until a later explicit composition evaluates a common downstream field.
1161
+
1162
+ Because spatial integration error depends on the downstream optical field's spatial-frequency/content, Photivra does not report a geometry-only convergence/error estimate. Reference rendering can compare increasing spatial sample counts in the quantity actually being integrated.
1163
+
1164
+ A per-site node allocation safety limit prevents malformed/untrusted counts from creating an oversized array in one call.
1165
+
1166
+ ## Sensor spatial-sample reduction
1167
+
1168
+ Use `reduceSensorSpatialSamplingQuadrature()` after a renderer or optical model has evaluated one scalar value at every spatial quadrature node's `preAntiAliasingSourcePointMm`.
1169
+
1170
+ Node values are identified by the quadrature identity tuple—AA component index plus aperture X/Y sample indices—rather than by array position.
1171
+
1172
+ Two input domains are supported:
1173
+
1174
+ - `relative-linear`: a nonnegative dimensionless irradiance-like proxy for deterministic regression/testing;
1175
+ - `radiometric-irradiance`: physical sensor-plane irradiance in W/m².
1176
+
1177
+ The physical domain follows standard radiometric meaning: irradiance is incident radiant flux per unit area, so integrating W/m² over the geometric aperture area yields incident radiant flux in watts. This still remains upstream of CFA spectral response, microlens response, QE, exposure-time integration, and sensor conversion.
1178
+
1179
+ The reducer returns both a normalized spatial average and an area-weighted integral. For relative values the area integral is relative-value × µm²; for physical irradiance it is converted to watts.
1180
+
1181
+ The CFA `channelId` is descriptive metadata only. No wavelength-dependent filter/sensor response is applied, so the output is **not** a physically color-filtered mosaic value and must not be called RAW.
1182
+
1183
+ Every quadrature node requires an explicit nonnegative finite input, including nodes whose pre-AA source coordinate lies outside the active imaging area. The reducer never clamps, zero-fills, extrapolates, drops, or renormalizes missing edge support.
1184
+
1185
+ Signed color-transform intermediates, RGB display values, gamma-encoded values, radiance, spectral irradiance, temporal exposure, photons/electrons, noise, saturation, black level, ADC quantization, RAW codes, and demosaic/reconstruction are outside this contract.
1186
+
1187
+ ## Capture-mode profiles
1188
+
1189
+ Use `parseCaptureModeProfile()` and `resolveCaptureMode()` to describe how one physical sensor can expose different acquisition/sampling/reconstruction modes without changing sensor identity:
1190
+
1191
+ ```ts
1192
+ import {
1193
+ parseCaptureModeProfile,
1194
+ resolveCaptureMode
1195
+ } from "@photivra/engine";
1196
+
1197
+ const modes = parseCaptureModeProfile({
1198
+ schemaVersion: "0.1.0",
1199
+ modes: [
1200
+ {
1201
+ modeId: "pixel-shift-4",
1202
+ evidence: [
1203
+ {
1204
+ sourceOrigin: "manufacturer",
1205
+ sourceReference: "example:pixel-shift-mode",
1206
+ reuseStatus: "factual-reference-only"
1207
+ }
1208
+ ],
1209
+ acquisition: {
1210
+ kind: "fixed-multi-frame",
1211
+ frameCount: {
1212
+ value: 4,
1213
+ evidence: [
1214
+ {
1215
+ sourceOrigin: "manufacturer",
1216
+ sourceReference: "example:pixel-shift-frame-count",
1217
+ reuseStatus: "factual-reference-only"
1218
+ }
1219
+ ]
1220
+ }
1221
+ },
1222
+ perFrameSampling: {
1223
+ kind: "native-effective-raster"
1224
+ },
1225
+ interFrameSensorOffsetsNativeSamples: {
1226
+ value: [
1227
+ { x: 0, y: 0 },
1228
+ { x: 0.5, y: 0 },
1229
+ { x: 0, y: 0.5 },
1230
+ { x: 0.5, y: 0.5 }
1231
+ ],
1232
+ evidence: [
1233
+ {
1234
+ sourceOrigin: "manufacturer",
1235
+ sourceReference: "example:pixel-shift-offset-sequence",
1236
+ reuseStatus: "factual-reference-only"
1237
+ }
1238
+ ]
1239
+ },
1240
+ reconstructionStages: [
1241
+ {
1242
+ value: "pixel-shift-combination",
1243
+ evidence: [
1244
+ {
1245
+ sourceOrigin: "manufacturer",
1246
+ sourceReference: "example:pixel-shift-combination",
1247
+ reuseStatus: "factual-reference-only"
1248
+ }
1249
+ ]
1250
+ }
1251
+ ],
1252
+ processedImageRaster: {
1253
+ value: {
1254
+ pixelWidth: 12000,
1255
+ pixelHeight: 8000
1256
+ },
1257
+ evidence: [
1258
+ {
1259
+ sourceOrigin: "manufacturer",
1260
+ sourceReference: "example:processed-raster",
1261
+ reuseStatus: "factual-reference-only"
1262
+ }
1263
+ ]
1264
+ },
1265
+ dependencies: [
1266
+ "color-sampling-model",
1267
+ "inter-frame-registration"
1268
+ ]
1269
+ }
1270
+ ]
1271
+ });
1272
+
1273
+ const selected = resolveCaptureMode({
1274
+ nativeRaster: {
1275
+ pixelWidth: 6000,
1276
+ pixelHeight: 4000
1277
+ },
1278
+ profile: modes,
1279
+ modeId: "pixel-shift-4"
1280
+ });
1281
+
1282
+ console.log(selected.processedImageMegapixels);
1283
+ ```
1284
+
1285
+ The model is deliberately **orthogonal**, not one mutually exclusive marketing-style mode enum. A mode independently describes:
1286
+
1287
+ - acquisition as single-frame, fixed multi-frame, or variable multi-frame;
1288
+ - per-frame sampling as the native effective raster, exact integer grouping of native effective samples, or another explicitly evidenced effective raster;
1289
+ - an optional ordered inter-frame sensor-offset sequence for fixed multi-frame modes;
1290
+ - zero or more reconstruction stages such as remosaic, pixel-shift combination, and generic multi-frame computational combination;
1291
+ - the processed-image raster before final output crop/resampling;
1292
+ - explicit downstream model dependencies.
1293
+
1294
+ This lets a computational multi-frame mode also use grouped per-frame sampling instead of forcing it into one exclusive category.
1295
+
1296
+ `NativeImageRaster` keeps its canonical meaning: an **effective native image-sampling grid**, not a physical photosite raster. Neither native megapixels nor processed/final-output megapixels may be used to infer physical photodiode count.
1297
+
1298
+ For grouped sampling, Photivra derives the per-frame sampling raster only when the native dimensions divide exactly by the evidenced grouping factors. The optional `combinationDomain` distinguishes `charge-domain`, `pre-conversion-analog`, and `post-conversion-digital`. When that fact is not known it must be omitted; the word “binning” or a 2×2 resolution ratio does not establish where combination occurred.
1299
+
1300
+ Inter-frame sensor offsets are expressed in units of the **native effective sampling pitch**. They are not evidence of physical photodiode pitch. The offset sequence length must match a fixed multi-frame count when supplied.
1301
+
1302
+ `processedImageRaster` belongs to the capture-mode/reconstruction pipeline. It does not change physical imaging area, crop factor, field of view, or the native effective raster. It may be larger than the native raster for a multi-frame reconstruction without implying a higher-resolution physical sensor.
1303
+
1304
+ Final output crop/resampling remains owned by the existing staged capture/output geometry contract. A processed raster therefore does not itself define final display/export dimensions or viewing FOV.
1305
+
1306
+ The capture-mode schema records dependencies such as `color-sampling-model`, `mode-specific-readout-timing`, `radiometric-calibration`, and `inter-frame-registration` without pretending those downstream models are already implemented.
1307
+
1308
+ ## Sensor readout timing
1309
+
1310
+ Use `calculateSensorReadoutTiming()` for a capture-specific native sensor scan schedule without conflating sensor readout with shutter-curtain timing:
1311
+
1312
+ ```ts
1313
+ import {
1314
+ calculateSensorReadoutTiming,
1315
+ transformNativeRasterVectorToOriented
1316
+ } from "@photivra/engine";
1317
+
1318
+ const timing = calculateSensorReadoutTiming({
1319
+ nativeRaster: { pixelWidth: 6000, pixelHeight: 4000 },
1320
+ activeCaptureRect: { x: 0, y: 0, width: 6000, height: 4000 },
1321
+ shutterMechanism: "electronic",
1322
+ readout: {
1323
+ readoutMode: "rolling",
1324
+ captureReadoutDurationSeconds: {
1325
+ value: 0.024,
1326
+ unit: "s",
1327
+ evidence: [
1328
+ {
1329
+ sourceOrigin: "manufacturer",
1330
+ sourceReference: "manufacturer-spec:example",
1331
+ reuseStatus: "factual-reference-only"
1332
+ }
1333
+ ]
1334
+ },
1335
+ scanDirectionNative: {
1336
+ value: "top-to-bottom",
1337
+ evidence: [
1338
+ {
1339
+ sourceOrigin: "manufacturer",
1340
+ sourceReference: "manufacturer-spec:direction-example",
1341
+ reuseStatus: "factual-reference-only"
1342
+ }
1343
+ ]
1344
+ },
1345
+ spatialSamplingSkewSeconds: {
1346
+ value: 0.020,
1347
+ unit: "s",
1348
+ evidence: [
1349
+ {
1350
+ sourceOrigin: "third-party",
1351
+ sourceReference: "measurement:spatial-skew-example",
1352
+ reuseStatus: "factual-reference-only"
1353
+ }
1354
+ ]
1355
+ }
1356
+ },
1357
+ samplePointsNative: [
1358
+ { x: 3000, y: 0 },
1359
+ { x: 3000, y: 2000 },
1360
+ { x: 3000, y: 4000 }
1361
+ ]
1362
+ });
1363
+
1364
+ console.log(timing.value.samples);
1365
+ console.log(timing.value.scan?.unitVectorNative);
1366
+
1367
+ const orientedDirection = transformNativeRasterVectorToOriented({
1368
+ vector: timing.value.scan?.unitVectorNative ?? { x: 0, y: 0 },
1369
+ orientation: "portrait-clockwise"
1370
+ });
1371
+ ```
1372
+
1373
+ The two timing facts are intentionally separate. `captureReadoutDurationSeconds` records the selected capture's declared data-readout duration; `spatialSamplingSkewSeconds` drives only the spatial phase span of the current rolling approximation. Photivra does not assume those values are equal.
1374
+
1375
+ Global readout declares only the capture data-readout duration. Its spatial phase offset is zero everywhere even when that duration is non-zero.
1376
+
1377
+ The rolling schedule is a `uniform-linear-single-axis` approximation in invariant native sensor coordinates. `NativeImageRaster` remains an effective image-sampling grid, so this API does not claim that each raster row/column corresponds to one physical photodiode row or hardware readout line. Non-uniform, segmented, center-out, interleaved and multi-tap readout patterns are not represented by this first model.
1378
+
1379
+ Physical camera orientation is deliberately excluded from the timing input. Use the existing native↔oriented vector transform when a downstream renderer needs the scan direction in oriented capture coordinates. Output crop/resolution is also excluded and therefore cannot silently change native readout timing.
1380
+
1381
+ Active-crop/capture-mode timing must be supplied explicitly; the engine never scales full-frame timing from crop dimensions. Shutter mechanism is recorded independently and does not modify the schedule. Mechanical-curtain travel, EFCS timing, local exposure windows, rolling-shutter geometric distortion, flash/flicker interaction and motion integration remain future work.
1382
+
1383
+ ## Readout/exposure spatial linkage
1384
+
1385
+ Use `assessReadoutExposureTimingLinkage()` when a specific capture-mode source supports a relationship between rolling sensor readout spatial order and an **electronic** exposure boundary:
1386
+
1387
+ ```ts
1388
+ import { assessReadoutExposureTimingLinkage } from "@photivra/engine";
1389
+
1390
+ const relationship = assessReadoutExposureTimingLinkage({
1391
+ nativeRaster: { pixelWidth: 6000, pixelHeight: 4000 },
1392
+ shutterMechanism: "electronic",
1393
+ readout: {
1394
+ readoutMode: "rolling",
1395
+ captureReadoutDurationSeconds: {
1396
+ value: 0.031,
1397
+ unit: "s",
1398
+ evidence: [
1399
+ {
1400
+ sourceOrigin: "manufacturer",
1401
+ sourceReference: "example:data-readout-duration",
1402
+ reuseStatus: "factual-reference-only"
1403
+ }
1404
+ ]
1405
+ },
1406
+ scanDirectionNative: {
1407
+ value: "top-to-bottom",
1408
+ evidence: [
1409
+ {
1410
+ sourceOrigin: "manufacturer",
1411
+ sourceReference: "example:readout-direction",
1412
+ reuseStatus: "factual-reference-only"
1413
+ }
1414
+ ]
1415
+ },
1416
+ spatialSamplingSkewSeconds: {
1417
+ value: 0.02,
1418
+ unit: "s",
1419
+ evidence: [
1420
+ {
1421
+ sourceOrigin: "photivra",
1422
+ sourceReference: "example:measured-spatial-skew",
1423
+ reuseStatus: "photivra-owned"
1424
+ }
1425
+ ]
1426
+ }
1427
+ },
1428
+ nominalExposureDurationSeconds: {
1429
+ value: 1 / 1000,
1430
+ unit: "s",
1431
+ evidence: [
1432
+ {
1433
+ sourceOrigin: "photivra",
1434
+ sourceReference: "example:capture-config",
1435
+ reuseStatus: "photivra-owned"
1436
+ }
1437
+ ]
1438
+ },
1439
+ opening: {
1440
+ kind: "uniform-linear-native-scan",
1441
+ directionNative: {
1442
+ value: "top-to-bottom",
1443
+ evidence: [
1444
+ {
1445
+ sourceOrigin: "manufacturer",
1446
+ sourceReference: "example:electronic-opening-direction",
1447
+ reuseStatus: "factual-reference-only"
1448
+ }
1449
+ ]
1450
+ },
1451
+ traversalDurationSeconds: {
1452
+ value: 0.01,
1453
+ unit: "s",
1454
+ evidence: [
1455
+ {
1456
+ sourceOrigin: "photivra",
1457
+ sourceReference: "example:opening-traversal",
1458
+ reuseStatus: "photivra-owned"
1459
+ }
1460
+ ]
1461
+ }
1462
+ },
1463
+ closing: {
1464
+ kind: "uniform-linear-native-scan",
1465
+ directionNative: {
1466
+ value: "top-to-bottom",
1467
+ evidence: [
1468
+ {
1469
+ sourceOrigin: "manufacturer",
1470
+ sourceReference: "example:electronic-closing-direction",
1471
+ reuseStatus: "factual-reference-only"
1472
+ }
1473
+ ]
1474
+ },
1475
+ traversalDurationSeconds: {
1476
+ value: 0.01,
1477
+ unit: "s",
1478
+ evidence: [
1479
+ {
1480
+ sourceOrigin: "photivra",
1481
+ sourceReference: "example:closing-traversal",
1482
+ reuseStatus: "photivra-owned"
1483
+ }
1484
+ ]
1485
+ }
1486
+ },
1487
+ linkage: {
1488
+ kind: "spatial-phase-linked",
1489
+ links: [
1490
+ {
1491
+ boundary: "opening",
1492
+ phaseOrientation: "same",
1493
+ evidence: [
1494
+ {
1495
+ sourceOrigin: "manufacturer",
1496
+ sourceReference: "example:documented-spatial-relationship",
1497
+ reuseStatus: "factual-reference-only"
1498
+ }
1499
+ ]
1500
+ }
1501
+ ]
1502
+ }
1503
+ });
1504
+
1505
+ console.log(relationship.value.links[0]);
1506
+ ```
1507
+
1508
+ The positive link means only that the selected electronic exposure boundary shares the rolling readout's **normalized native spatial phase/order**:
1509
+
1510
+ - `same` means boundary phase equals readout phase;
1511
+ - `reversed` means boundary phase equals `1 - readout phase`.
1512
+
1513
+ It does **not** mean the two events occur at the same absolute time.
1514
+
1515
+ The function therefore reports `absoluteTemporalAlignment: "not-established"` even when directions and first-to-last timing spans happen to match.
1516
+
1517
+ The readout spatial skew and exposure-boundary traversal duration may differ. Their ratio is reported as `boundaryTraversalToReadoutSpatialSkewRatio`, which is descriptive only.
1518
+
1519
+ The total `captureReadoutDurationSeconds` remains a separate evidence-backed fact and does not participate in linkage validation.
1520
+
1521
+ Only electronic boundaries can be spatial-phase-linked to sensor readout. A mechanical opening/closing boundary is rejected. Global readout has no rolling spatial phase and therefore cannot participate in this first linkage type.
1522
+
1523
+ Use `linkage: { kind: "unlinked" }` when Photivra should assert no relationship. This means **no relationship is asserted**; it does not establish that the underlying hardware processes are physically independent.
1524
+
1525
+ ## Capture exposure-window timing
1526
+
1527
+ Use `calculateCaptureExposureWindows()` to describe when different native-sensor locations begin and end their local exposure without conflating shutter actuation with sensor readout:
1528
+
1529
+ ```ts
1530
+ import { calculateCaptureExposureWindows } from "@photivra/engine";
1531
+
1532
+ const windows = calculateCaptureExposureWindows({
1533
+ nativeRaster: { pixelWidth: 6000, pixelHeight: 4000 },
1534
+ shutterMechanism: "electronic-first-curtain",
1535
+ nominalExposureDurationSeconds: {
1536
+ value: 1 / 1000,
1537
+ unit: "s",
1538
+ evidence: [
1539
+ {
1540
+ sourceOrigin: "photivra",
1541
+ sourceReference: "capture-config:example",
1542
+ reuseStatus: "photivra-owned"
1543
+ }
1544
+ ]
1545
+ },
1546
+ opening: {
1547
+ kind: "uniform-linear-native-scan",
1548
+ directionNative: {
1549
+ value: "top-to-bottom",
1550
+ evidence: [
1551
+ {
1552
+ sourceOrigin: "photivra",
1553
+ sourceReference: "timing-profile:opening-direction",
1554
+ reuseStatus: "photivra-owned"
1555
+ }
1556
+ ]
1557
+ },
1558
+ traversalDurationSeconds: {
1559
+ value: 0.004,
1560
+ unit: "s",
1561
+ evidence: [
1562
+ {
1563
+ sourceOrigin: "photivra",
1564
+ sourceReference: "timing-profile:opening-traversal",
1565
+ reuseStatus: "photivra-owned"
1566
+ }
1567
+ ]
1568
+ }
1569
+ },
1570
+ closing: {
1571
+ kind: "uniform-linear-native-scan",
1572
+ directionNative: {
1573
+ value: "top-to-bottom",
1574
+ evidence: [
1575
+ {
1576
+ sourceOrigin: "photivra",
1577
+ sourceReference: "timing-profile:closing-direction",
1578
+ reuseStatus: "photivra-owned"
1579
+ }
1580
+ ]
1581
+ },
1582
+ traversalDurationSeconds: {
1583
+ value: 0.004,
1584
+ unit: "s",
1585
+ evidence: [
1586
+ {
1587
+ sourceOrigin: "photivra",
1588
+ sourceReference: "timing-profile:closing-traversal",
1589
+ reuseStatus: "photivra-owned"
1590
+ }
1591
+ ]
1592
+ }
1593
+ },
1594
+ samplePointsNative: [
1595
+ { x: 3000, y: 0 },
1596
+ { x: 3000, y: 2000 },
1597
+ { x: 3000, y: 4000 }
1598
+ ]
1599
+ });
1600
+
1601
+ console.log(windows.value.samples);
1602
+ console.log(windows.value.localExposureDurationRangeSeconds);
1603
+ ```
1604
+
1605
+ The time basis is `first-opening-boundary-phase`: zero means the first spatial phase of the declared opening boundary. This deliberately avoids pretending that a spatially scanned capture has one universal local exposure-start event.
1606
+
1607
+ Opening and closing schedules are independent. Each may currently be either:
1608
+
1609
+ - `simultaneous`; or
1610
+ - `uniform-linear-native-scan` with its own evidence-backed native direction and traversal duration.
1611
+
1612
+ The nominal exposure duration is a separate evidence-backed quantity. For a native point:
1613
+
1614
+ ```text
1615
+ local start = opening phase
1616
+ local end = nominal exposure duration + closing phase
1617
+ local duration = local end - local start
1618
+ ```
1619
+
1620
+ Matched opening/closing scans with equal traversal produce constant local exposure duration. Different directions or traversal durations can intentionally produce spatially varying local duration.
1621
+
1622
+ Photivra validates the **entire active rectangle**, not only supplied diagnostic points. Because the first schedules are affine over the native rectangle, corner extrema are sufficient to reject any declaration that would produce zero or negative local exposure duration somewhere in the capture.
1623
+
1624
+ `shutterMechanism` determines only the conceptual boundary actuators:
1625
+
1626
+ - `mechanical` → mechanical opening + mechanical closing;
1627
+ - `electronic-first-curtain` → electronic opening + mechanical closing;
1628
+ - `electronic` → electronic opening + electronic closing.
1629
+
1630
+ The mechanism does not supply traversal direction or timing.
1631
+
1632
+ This exposure-window model remains separate from `calculateSensorReadoutTiming()`. A later integration layer must explicitly bind exposure boundaries, sensor readout, and time-parameterized motion; Photivra does not assume a sensor readout phase is an exposure-start or exposure-end boundary.
1633
+
1634
+ Physical orientation and digital output geometry remain downstream. The schedule stays in invariant native sensor coordinates and can use the existing native↔oriented vector transforms when a consumer needs presentation-oriented direction.
1635
+
1636
+ The first model does not cover curtain acceleration, curved/nonlinear curtain travel, segmented/center-out/interleaved electronic schedules, flash/flicker interaction, shutter shock, EFCS-specific pupil/bokeh effects, or rolling-shutter image distortion.
1637
+
664
1638
  ## Capture orientation, active area, and output geometry
665
1639
 
666
1640
  Use `resolveCaptureGeometry()` to keep physical sensor identity, active capture, physical camera orientation, and final digital output geometry separate:
@@ -862,6 +1836,302 @@ These relations do not model scene radiance, lens T-stop/transmission, vignettin
862
1836
 
863
1837
  See [Motion and Signal Foundation](MOTION_AND_SIGNAL.md#exposure-relations).
864
1838
 
1839
+ ## Capture rotation exposure trajectories
1840
+
1841
+ Use `calculateCaptureRotationTrajectories()` when you need to evaluate **pure camera rotation over each native point's local exposure interval** while preserving the separation between shutter/exposure timing and sensor readout:
1842
+
1843
+ ```ts
1844
+ import { calculateCaptureRotationTrajectories } from "@photivra/engine";
1845
+
1846
+ const trajectories = calculateCaptureRotationTrajectories({
1847
+ imagingArea: { widthMm: 36, heightMm: 24 },
1848
+ nativeRaster: { pixelWidth: 6000, pixelHeight: 4000 },
1849
+ shutterMechanism: "electronic",
1850
+ nominalExposureDurationSeconds: {
1851
+ value: 1 / 1000,
1852
+ unit: "s",
1853
+ evidence: [
1854
+ {
1855
+ sourceOrigin: "photivra",
1856
+ sourceReference: "capture-config:example",
1857
+ reuseStatus: "photivra-owned"
1858
+ }
1859
+ ]
1860
+ },
1861
+ opening: {
1862
+ kind: "uniform-linear-native-scan",
1863
+ directionNative: {
1864
+ value: "top-to-bottom",
1865
+ evidence: [
1866
+ {
1867
+ sourceOrigin: "photivra",
1868
+ sourceReference: "timing-profile:opening-direction",
1869
+ reuseStatus: "photivra-owned"
1870
+ }
1871
+ ]
1872
+ },
1873
+ traversalDurationSeconds: {
1874
+ value: 0.015,
1875
+ unit: "s",
1876
+ evidence: [
1877
+ {
1878
+ sourceOrigin: "photivra",
1879
+ sourceReference: "timing-profile:opening-traversal",
1880
+ reuseStatus: "photivra-owned"
1881
+ }
1882
+ ]
1883
+ }
1884
+ },
1885
+ closing: {
1886
+ kind: "uniform-linear-native-scan",
1887
+ directionNative: {
1888
+ value: "top-to-bottom",
1889
+ evidence: [
1890
+ {
1891
+ sourceOrigin: "photivra",
1892
+ sourceReference: "timing-profile:closing-direction",
1893
+ reuseStatus: "photivra-owned"
1894
+ }
1895
+ ]
1896
+ },
1897
+ traversalDurationSeconds: {
1898
+ value: 0.015,
1899
+ unit: "s",
1900
+ evidence: [
1901
+ {
1902
+ sourceOrigin: "photivra",
1903
+ sourceReference: "timing-profile:closing-traversal",
1904
+ reuseStatus: "photivra-owned"
1905
+ }
1906
+ ]
1907
+ }
1908
+ },
1909
+ focalLengthMm: 50,
1910
+ angularVelocityRadPerSec: {
1911
+ pitch: 0,
1912
+ yaw: 0.1,
1913
+ roll: 0
1914
+ },
1915
+ samplePointsNative: [
1916
+ { x: 3000, y: 0 },
1917
+ { x: 3000, y: 2000 },
1918
+ { x: 3000, y: 4000 }
1919
+ ]
1920
+ });
1921
+
1922
+ console.log(trajectories.value.samples);
1923
+ ```
1924
+
1925
+ The function maps each native point to the pre-orientation image plane at the capture reference time, then evaluates the existing `calculateCameraRotationImageMapping()` primitive at that point's local exposure start and end.
1926
+
1927
+ The explicit time reference is the **first opening-boundary phase**. For this composition only, that reference is bound to `t = 0` of the low-level rotation model. This does not redefine the standalone rotation API for other callers.
1928
+
1929
+ The returned trajectory is **forward temporal geometry for a stationary world ray**:
1930
+
1931
+ - `referenceImagePointMm` is the ray's image-plane position at the capture reference;
1932
+ - `atLocalExposureStart` and `atLocalExposureEnd` are its mapped positions at the local window endpoints;
1933
+ - `localExposureTrajectoryEndpointDeltaMm` is the chord between those endpoints.
1934
+
1935
+ The chord is not an integrated blur kernel. It also is not a renderer-ready rolling-shutter warp.
1936
+
1937
+ A capture-scan image mapping is location-dependent because capture time varies across the image. For the current pure-rotation model, destination-to-reference inversion is analytic once the destination location selects its local capture time. More general motion models may require additional solving and global-validity analysis. Finite-exposure rendering still requires integration over the full local interval rather than one endpoint or midpoint displacement.
1938
+
1939
+ `calculateSensorReadoutTiming()` is intentionally not an input. If a specific capture mode has a defensible relationship between electronic exposure timing and sensor readout timing, that relationship should be represented by a future explicit mode/link contract rather than assumed by this API.
1940
+
1941
+ The current trajectory model remains pure rotation only. It excludes camera translation/parallax, subject motion, rotating/deforming subjects, occlusion changes, panning intent, exposure integration, flash/flicker, shutter shock, and final rolling-shutter inverse warping.
1942
+
1943
+ ## Capture rotation temporal quadrature
1944
+
1945
+ Use `calculateCaptureRotationTemporalQuadrature()` when a downstream renderer or reference evaluator needs deterministic pure-rotation geometry samples across each destination point's complete local exposure interval:
1946
+
1947
+ ```ts
1948
+ import { calculateCaptureRotationTemporalQuadrature } from "@photivra/engine";
1949
+
1950
+ const quadrature = calculateCaptureRotationTemporalQuadrature({
1951
+ imagingArea: { widthMm: 36, heightMm: 24 },
1952
+ nativeRaster: { pixelWidth: 6000, pixelHeight: 4000 },
1953
+ shutterMechanism: "electronic",
1954
+ nominalExposureDurationSeconds: {
1955
+ value: 1 / 1000,
1956
+ unit: "s",
1957
+ evidence: [
1958
+ {
1959
+ sourceOrigin: "photivra",
1960
+ sourceReference: "capture-config:example",
1961
+ reuseStatus: "photivra-owned"
1962
+ }
1963
+ ]
1964
+ },
1965
+ opening: {
1966
+ kind: "simultaneous"
1967
+ },
1968
+ closing: {
1969
+ kind: "simultaneous"
1970
+ },
1971
+ focalLengthMm: 50,
1972
+ angularVelocityRadPerSec: {
1973
+ pitch: 0,
1974
+ yaw: 0.1,
1975
+ roll: 0
1976
+ },
1977
+ orientation: "landscape",
1978
+ temporalSampleCount: 8,
1979
+ samplePointsNative: [{ x: 3000, y: 2000 }]
1980
+ });
1981
+
1982
+ console.log(quadrature.value.points[0]?.nodes);
1983
+ ```
1984
+
1985
+ The first model uses deterministic uniform midpoint quadrature. With `N` samples, node phases are:
1986
+
1987
+ ```text
1988
+ phase_i = (i + 0.5) / N
1989
+ ```
1990
+
1991
+ Each phase is passed through `calculateCaptureRotationInverseMappings()`, so instantaneous destination-to-reference geometry remains defined by one existing public model.
1992
+
1993
+ Every node exposes two different temporal weights:
1994
+
1995
+ - `normalizedTimeWeight = 1 / N` for a downstream time-average under the declared uniform temporal-response approximation;
1996
+ - `timeMeasureSeconds = localExposureDuration / N` for a downstream time integral.
1997
+
1998
+ Do not interchange them. The first is dimensionless; the second carries seconds. Neither includes shutter transmission, scene radiance, sensor response, photon conversion, vignetting, or other radiometric throughput.
1999
+
2000
+ Different sensor locations may have different local exposure start/end times. If opening and closing scans produce different local durations, `timeMeasureSeconds` therefore differs by destination point while the normalized average weights still sum to approximately one for each point.
2001
+
2002
+ This API returns **temporal geometry nodes and measures only**. It does not:
2003
+
2004
+ - fetch or sample scene radiance;
2005
+ - average the reference coordinates;
2006
+ - calculate a blur radius/kernel or PSF;
2007
+ - resolve visibility/occlusion changes;
2008
+ - apply sensor readout timing;
2009
+ - apply flash/flicker or shutter-shock behavior;
2010
+ - write or resample output pixels.
2011
+
2012
+ Averaging geometric coordinates is not equivalent to integrating radiance along moving rays.
2013
+
2014
+ Photivra also does not report a geometry-only integration-error estimate. Temporal image error depends on downstream radiance, visibility, texture/edge frequency, reconstruction, and the motion path. A reference renderer can compare increasing `temporalSampleCount` values in the radiance/output domain when convergence evidence is needed.
2015
+
2016
+ ## Instantaneous capture rotation inverse mapping
2017
+
2018
+ Use `calculateCaptureRotationInverseMappings()` to map captured destination locations back to the **first-opening-boundary reference image** under pure camera rotation:
2019
+
2020
+ ```ts
2021
+ import {
2022
+ calculateCaptureRotationInverseMappings
2023
+ } from "@photivra/engine";
2024
+
2025
+ const mapping = calculateCaptureRotationInverseMappings({
2026
+ imagingArea: { widthMm: 36, heightMm: 24 },
2027
+ nativeRaster: { pixelWidth: 6000, pixelHeight: 4000 },
2028
+ shutterMechanism: "electronic",
2029
+ nominalExposureDurationSeconds: {
2030
+ value: 1 / 1000,
2031
+ unit: "s",
2032
+ evidence: [
2033
+ {
2034
+ sourceOrigin: "photivra",
2035
+ sourceReference: "capture-config:example",
2036
+ reuseStatus: "photivra-owned"
2037
+ }
2038
+ ]
2039
+ },
2040
+ opening: {
2041
+ kind: "uniform-linear-native-scan",
2042
+ directionNative: {
2043
+ value: "top-to-bottom",
2044
+ evidence: [
2045
+ {
2046
+ sourceOrigin: "photivra",
2047
+ sourceReference: "timing-profile:opening-direction",
2048
+ reuseStatus: "photivra-owned"
2049
+ }
2050
+ ]
2051
+ },
2052
+ traversalDurationSeconds: {
2053
+ value: 0.015,
2054
+ unit: "s",
2055
+ evidence: [
2056
+ {
2057
+ sourceOrigin: "photivra",
2058
+ sourceReference: "timing-profile:opening-traversal",
2059
+ reuseStatus: "photivra-owned"
2060
+ }
2061
+ ]
2062
+ }
2063
+ },
2064
+ closing: {
2065
+ kind: "uniform-linear-native-scan",
2066
+ directionNative: {
2067
+ value: "top-to-bottom",
2068
+ evidence: [
2069
+ {
2070
+ sourceOrigin: "photivra",
2071
+ sourceReference: "timing-profile:closing-direction",
2072
+ reuseStatus: "photivra-owned"
2073
+ }
2074
+ ]
2075
+ },
2076
+ traversalDurationSeconds: {
2077
+ value: 0.015,
2078
+ unit: "s",
2079
+ evidence: [
2080
+ {
2081
+ sourceOrigin: "photivra",
2082
+ sourceReference: "timing-profile:closing-traversal",
2083
+ reuseStatus: "photivra-owned"
2084
+ }
2085
+ ]
2086
+ }
2087
+ },
2088
+ focalLengthMm: 50,
2089
+ angularVelocityRadPerSec: {
2090
+ pitch: 0,
2091
+ yaw: 0.1,
2092
+ roll: 0
2093
+ },
2094
+ localExposurePhase: 0.5,
2095
+ orientation: "landscape",
2096
+ samplePointsNative: [
2097
+ { x: 3000, y: 0 },
2098
+ { x: 3000, y: 2000 },
2099
+ { x: 3000, y: 4000 }
2100
+ ]
2101
+ });
2102
+
2103
+ console.log(mapping.value.samples);
2104
+ ```
2105
+
2106
+ `localExposurePhase` is mandatory:
2107
+
2108
+ - `0` evaluates local exposure start;
2109
+ - `0.5` evaluates the local temporal midpoint;
2110
+ - `1` evaluates local exposure end.
2111
+
2112
+ Photivra does not choose a default phase because a finite exposure does not have one uniquely correct sharp geometry.
2113
+
2114
+ Each destination native point first selects its local exposure time. Under the current constant-axis pure-rotation model, the captured ray can then be rotated **analytically** back to the exposure-start reference frame with `calculateInverseCameraRotationImageMapping()`. No iterative/fixed-point solver or convergence tolerance is needed.
2115
+
2116
+ The result reports:
2117
+
2118
+ - destination native and ideal image-plane coordinates;
2119
+ - the local exposure window and selected capture time;
2120
+ - the corresponding reference image-plane ray;
2121
+ - reference-minus-destination displacement in image-plane millimetres;
2122
+ - equivalent continuous native effective-sample displacement;
2123
+ - that displacement rotated into the requested physical capture orientation.
2124
+
2125
+ The mapping deliberately remains in the ideal pre-lens geometry stage. It does not apply radial distortion, lateral CA, PSF, output crop, or output resampling.
2126
+
2127
+ Reference rays are **not clamped** to the active source region. Camera motion can cause a valid captured destination to require reference-image data outside that region. A renderer must decide how to handle unavailable source coverage rather than silently clamping/stretching an edge.
2128
+
2129
+ This is an **instantaneous** mapping only. It is not a complete finite-exposure rolling/capture-scan image. Motion blur requires multiple temporal samples or another defensible integration method over each local exposure window, including an explicit exposure-weighting model if the weighting is not uniform.
2130
+
2131
+ Sensor data-readout timing is intentionally not part of this calculation. Capture exposure boundaries remain the authoritative timing input until a separate capture-mode/link contract establishes otherwise.
2132
+
2133
+ The current model is pure rotation only. Translation/parallax, subject motion, deformation, occlusion changes, global injectivity/fold analysis, flash/flicker, shutter shock, and finite-exposure integration remain outside this slice.
2134
+
865
2135
  ## Spatial camera-rotation mapping
866
2136
 
867
2137
  Use `calculateCameraRotationImageMapping()` when you need the image-plane location of a stationary world ray after **pure camera rotation** at an arbitrary physical time from exposure start.
@@ -1138,7 +2408,7 @@ The current composed POC reports X/Y geometric sample pitch but still uses one b
1138
2408
 
1139
2409
  POC API 0.20 composes the capture-geometry foundation through final output/viewing semantics. Existing requests remain valid. New callers may supply an optional `capture` object with physical orientation, an optional native active-capture rectangle, an optional oriented output crop, and an optional final output raster.
1140
2410
 
1141
- Capture mode deliberately keeps legacy `crop.factor` separate: it must remain `1` when `capture` is present. Staged capture can use the existing `subjectCrop` request, but the result appears under `capture.subjectFraming` because it is a post-output framing stage rather than a legacy total-crop factor.
2411
+ The POC staged `capture` geometry request deliberately keeps legacy `crop.factor` separate: it must remain `1` when `capture` is present. This geometry request is distinct from the sensor/capture-mode profile contract. Staged capture can use the existing `subjectCrop` request, but the result appears under `capture.subjectFraming` because it is a post-output framing stage rather than a legacy total-crop factor.
1142
2412
 
1143
2413
  Equivalent-viewing CoC in capture mode uses the final retained physical image region, including subject framing when present; changing only output pixel resolution does not change the criterion. Explicit `circleOfConfusionMm` remains unchanged.
1144
2414