From 069647ed00013ad6b04ee5b0b6d2c6f2f1fe8fd2 Mon Sep 17 00:00:00 2001 From: Paullo9 Date: Fri, 30 Oct 2026 10:23:20 +0100 Subject: [PATCH 1/3] feat: SyntheticProbe centre, public FloatFFT2D.supports, consensus diagnostics for a supplied basis - SyntheticProbe.make takes centre: (row:, column:)? = nil. nil is py4DSTEM's (rows/2, columns/2) in Double, bit for bit as before; a pixel centre gives the pixel-centred disk exactly (tested against that construction on 25x23, 125x125 and 32x30). - FloatFFT2D is public with rows, columns, init? and supports(rows:columns:); the transforms stay package access. - LatticeStrain.consensusDiagnostics(_:basis:origin:minimumRadius:) scores a supplied basis (an initial basis) with the consensus's observations and Float32 scoring; for the chosen basis it equals chooseBasisConsensusResult exactly. The consumer check gains three checks (it failed to build before this change); 151 pass. Done: #10 items 1, 2 and 4. Next: item 3, the enums named like their modules, as its own breaking PR. Co-Authored-By: Claude Opus 5.5 --- Sources/CuboCore/FloatFFT.swift | 12 +++-- Sources/CuboStrain/LatticeStrain.swift | 20 +++++++ Sources/CuboVirtual/VacuumProbe.swift | 10 ++-- .../CuboCoreTests/FloatFFTSupportTests.swift | 15 ++++++ .../CuboStrainTests/LatticeStrainTests.swift | 21 ++++++++ .../SyntheticProbeCentreTests.swift | 54 +++++++++++++++++++ docs/open-items.md | 4 +- docs/parity.md | 4 +- .../Sources/consumer-check/main.swift | 7 +++ 9 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 Tests/CuboCoreTests/FloatFFTSupportTests.swift create mode 100644 Tests/CuboVirtualTests/SyntheticProbeCentreTests.swift diff --git a/Sources/CuboCore/FloatFFT.swift b/Sources/CuboCore/FloatFFT.swift index e4b8e9e..adb0bac 100644 --- a/Sources/CuboCore/FloatFFT.swift +++ b/Sources/CuboCore/FloatFFT.swift @@ -1,5 +1,5 @@ // -// FloatFFT.swift (CuboCore, package) +// FloatFFT.swift (CuboCore; the plan and `supports` public, the transforms package) // The Float32 2-D transform of the Float32 paths (Bragg `.single`, single-slice ptychography `.single`): vDSP's complex DFT (vDSP_DFT_zop) along the rows, a transpose, // and the DFT along what were the columns. Forward is NumPy's fft2 (e^{-2πi…}, unnormalised); inverse is the unnormalised // conjugate transform (the caller applies 1/(rows · columns)). The exact N-point DFT at the pattern's own size: nothing is @@ -16,13 +16,15 @@ import Foundation /// A plan for one `rows × columns` grid, immutable after creation and shared by concurrent workers (vDSP's DFT setups are /// shareable across threads); the buffers are the caller's. -package final class FloatFFT2D: @unchecked Sendable { - package let rows: Int, columns: Int +/// Public: the plan and ``supports(rows:columns:)``, so a caller can ask before choosing a Float32 path; the transforms +/// themselves stay package access. +public final class FloatFFT2D: @unchecked Sendable { + public let rows: Int, columns: Int private let rowForward: vDSP_DFT_Setup, rowInverse: vDSP_DFT_Setup // length `columns`, along each row private let columnForward: vDSP_DFT_Setup, columnInverse: vDSP_DFT_Setup // length `rows`, along each row of the transpose /// nil when vDSP has no DFT of one of the two lengths. - package init?(rows: Int, columns: Int) { + public init?(rows: Int, columns: Int) { guard rows > 0, columns > 0, let rf = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(columns), .FORWARD), let ri = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(columns), .INVERSE), @@ -38,7 +40,7 @@ package final class FloatFFT2D: @unchecked Sendable { } /// Whether vDSP can transform a grid of this size. - package static func supports(rows: Int, columns: Int) -> Bool { + public static func supports(rows: Int, columns: Int) -> Bool { FloatFFT2D(rows: rows, columns: columns) != nil } diff --git a/Sources/CuboStrain/LatticeStrain.swift b/Sources/CuboStrain/LatticeStrain.swift index 074a4eb..e8f8af5 100644 --- a/Sources/CuboStrain/LatticeStrain.swift +++ b/Sources/CuboStrain/LatticeStrain.swift @@ -229,6 +229,26 @@ public enum LatticeStrain { observationCount: e.observationCount, supportCount: e.supportCount, residual: Double(e.residualPixels), conditionNumber: Double(e.conditionNumber), indexingTolerance: Double(e.indexingTolerancePixels)) } + /// The diagnostics of ``chooseBasisConsensusResult(_:origin:minimumRadius:isCancelled:)`` for a basis the caller supplies + /// (an initial basis, for example): the same observations (peaks beyond `minimumRadius` from `origin`) and the same + /// Float32 scoring, without the clustering or the acceptance limits (condition at most 8, half the observations). For the + /// basis the consensus chooses it returns the consensus entry exactly. `basis` is returned as given (the scoring reads it + /// in Float32). With no supporting observation, `supportCount` is 0 and `residual` is infinity. nil for a basis that is + /// singular or degenerate in Float32 (a vector shorter than about 1e-5 or components so large the Gram trace overflows). + public static func consensusDiagnostics(_ positions: [[LatticePeak]], basis: LatticeBasis, origin: LatticeVector = .init(row:0,column:0), + minimumRadius: Double = 2) throws -> ConsensusBasis? { + guard positions.allSatisfy(valid), finite(origin), finite(basis.g1), finite(basis.g2), minimumRadius.isFinite, minimumRadius >= 0, + representable([origin.row,origin.column,minimumRadius,basis.g1.row,basis.g1.column,basis.g2.row,basis.g2.column] + + positions.flatMap { $0.flatMap { [$0.vector.row,$0.vector.column] } }) else { throw LatticeStrainError.invalidInput } + let grid = ConsensusPeakGrid(peaks:positions.map { $0.map { ConsensusPeak(x:Float($0.vector.row),y:Float($0.vector.column),intensity:Float($0.intensity)) } }) + let observations = ConsensusNumerics.vectorObservations(bragg:grid,x0:Float(origin.row),y0:Float(origin.column),minRadius:Float(minimumRadius),cancellation:nil) + let g1 = (x: Float(basis.g1.row), y: Float(basis.g1.column)), g2 = (x: Float(basis.g2.row), y: Float(basis.g2.column)) + guard let condition = ConsensusNumerics.conditionNumber(g1: g1, g2: g2) else { return nil } + let tolerance = ConsensusNumerics.indexingTolerance(g1: g1, g2: g2) + let score = ConsensusNumerics.scoreBasis(g1: g1, g2: g2, observations: observations, tolerance: tolerance) + return .init(basis: basis, observationCount: observations.count, supportCount: score.support, residual: Double(score.residual), + conditionNumber: Double(condition), indexingTolerance: Double(tolerance)) + } /// Compatibility option: cluster repeated vectors, score bounded candidate pairs by consensus. /// Uses Float32 source arithmetic, min radius 2 by default, condition limit 8, /// cluster support 8% of occupied positions, and at least 50% observation support. diff --git a/Sources/CuboVirtual/VacuumProbe.swift b/Sources/CuboVirtual/VacuumProbe.swift index 6cd48c4..cfd4786 100644 --- a/Sources/CuboVirtual/VacuumProbe.swift +++ b/Sources/CuboVirtual/VacuumProbe.swift @@ -210,13 +210,17 @@ public enum ProbeKernel { /// py4DSTEM's Probe.generate_synthetic_probe: a disk of `radius` with a logistic edge of full width `width`, centred on /// (rows/2, columns/2): 1 / (1 + exp(4 (r − radius) / width)). public enum SyntheticProbe { - public static func make(radius: Double, width: Double, detectorRows qy: Int, detectorColumns qx: Int) throws - -> DetectorImage { + /// - Parameter centre: the disk's centre in detector pixels. nil is py4DSTEM's (rows / 2, columns / 2) in Double, a half + /// pixel off the grid for an odd side; `(row: Double(rows / 2), column: Double(columns / 2))` centres it on a pixel. + public static func make(radius: Double, width: Double, detectorRows qy: Int, detectorColumns qx: Int, + centre: (row: Double, column: Double)? = nil) throws -> DetectorImage { guard radius.isFinite, width > 0, width.isFinite, qy > 0, qx > 0 else { throw CuboVirtualError.invalidInput("radius finite, width positive, a non-empty detector") } + let (centreRow, centreColumn) = centre ?? (Double(qy) / 2.0, Double(qx) / 2.0) + guard centreRow.isFinite, centreColumn.isFinite else { throw CuboVirtualError.invalidInput("a finite centre") } let v = (0..<(qy * qx)).map { k -> Double in - let r = Double(k / qx) - Double(qy) / 2.0, c = Double(k % qx) - Double(qx) / 2.0 + let r = Double(k / qx) - centreRow, c = Double(k % qx) - centreColumn let qr = (r * r + c * c).squareRoot() - radius return 1 / (1 + exp(4 * qr / width)) } diff --git a/Tests/CuboCoreTests/FloatFFTSupportTests.swift b/Tests/CuboCoreTests/FloatFFTSupportTests.swift new file mode 100644 index 0000000..c8d66b1 --- /dev/null +++ b/Tests/CuboCoreTests/FloatFFTSupportTests.swift @@ -0,0 +1,15 @@ +import Testing +import CuboCore + +// `FloatFFT2D.supports(rows:columns:)` is public: a caller asks before it picks a Float32 path. vDSP's DFT takes f · 2^n +// lengths (f in 1, 3, 5, 15); a prime like 7 has none. +@Suite struct FloatFFTSupportTests { + @Test func supportsAnswersForVDSPLengths() { + #expect(FloatFFT2D.supports(rows: 64, columns: 48)) + #expect(FloatFFT2D.supports(rows: 120, columns: 80)) + #expect(!FloatFFT2D.supports(rows: 7, columns: 64)) + #expect(!FloatFFT2D.supports(rows: 0, columns: 64)) + let plan = FloatFFT2D(rows: 64, columns: 48) + #expect(plan?.rows == 64 && plan?.columns == 48) + } +} diff --git a/Tests/CuboStrainTests/LatticeStrainTests.swift b/Tests/CuboStrainTests/LatticeStrainTests.swift index aabe30c..9556a1a 100644 --- a/Tests/CuboStrainTests/LatticeStrainTests.swift +++ b/Tests/CuboStrainTests/LatticeStrainTests.swift @@ -161,6 +161,27 @@ import Testing // Invalid input still throws the input error first. #expect(throws: LatticeStrainError.self) { try LatticeStrain.chooseBasisConsensusResult([[.init(row: .nan, column: 0, intensity: 1)]], isCancelled: { true }) } } + @Test func consensusDiagnosticsOfASuppliedBasis() throws { + let positions = plantedPositions(), origin = LatticeVector(row: 0, column: 0) + let full = try #require(try LatticeStrain.chooseBasisConsensusResult(positions)) + // The chosen basis, supplied: the same scoring, so the same entry exactly. + #expect(try LatticeStrain.consensusDiagnostics(positions, basis: full.basis) == full) + // Another basis of the planted lattice (g1 + g2, g2): filled from the same observations, checked by rescoring. + let other = LatticeBasis(g1: .init(row: full.basis.g1.row + full.basis.g2.row, column: full.basis.g1.column + full.basis.g2.column), + g2: full.basis.g2) + let d = try #require(try LatticeStrain.consensusDiagnostics(positions, basis: other, origin: origin, minimumRadius: 2)) + #expect(d.basis == other) + let r = rescore(other, positions, origin: origin, minimumRadius: 2) + #expect(d.observationCount == r.obs && d.supportCount == r.support) + #expect(d.supportCount > 0) + #expect(abs(d.residual - r.residual) <= 1e-5 && abs(d.indexingTolerance - r.tolerance) <= 1e-5) + #expect(abs(d.conditionNumber - r.condition) <= 1e-5 * max(1, r.condition)) + // A singular basis has no entry; invalid input throws. + #expect(try LatticeStrain.consensusDiagnostics(positions, basis: LatticeBasis(g1: full.basis.g1, g2: full.basis.g1)) == nil) + #expect(throws: LatticeStrainError.self) { + try LatticeStrain.consensusDiagnostics(positions, basis: LatticeBasis(g1: .init(row: .nan, column: 0), g2: full.basis.g2)) + } + } @Test func referenceMADInliers() throws { let b = try array("appBases"), bases = stride(from: 0, to: b.count, by: 4).map { basis(Array(b[$0..<$0+4])) } let plain = try LatticeStrain.referenceMAD(bases), full = try LatticeStrain.referenceMADResult(bases) diff --git a/Tests/CuboVirtualTests/SyntheticProbeCentreTests.swift b/Tests/CuboVirtualTests/SyntheticProbeCentreTests.swift new file mode 100644 index 0000000..62d4de0 --- /dev/null +++ b/Tests/CuboVirtualTests/SyntheticProbeCentreTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +import CuboVirtual + +// `SyntheticProbe.make(..., centre:)`: a caller-chosen centre for the logistic disk. nil keeps the (rows/2, columns/2) centre +// in Double, a half pixel off the grid for an odd side. A pixel centre gives the disk a pixel-centred kernel builds. +@Suite struct SyntheticProbeCentreTests { + /// The logistic disk 1 / (1 + exp(4 (|q - centre| - radius) / width)), written out independently of the kit. + private func disk(radius: Double, width: Double, rows: Int, columns: Int, centre: (Double, Double)) -> [Double] { + (0..<(rows * columns)).map { k in + let r = Double(k / columns) - centre.0, c = Double(k % columns) - centre.1 + return 1 / (1 + exp(4 * ((r * r + c * c).squareRoot() - radius) / width)) + } + } + + /// A pixel-centred disk the way a pixel-centred kernel builds it: integer offsets from (rows / 2, columns / 2), integer + /// division, Float radius and width widened to Double. + private func pixelCentredDisk(radius: Float, width: Float, rows: Int, columns: Int) -> [Double] { + let centreRow = rows / 2, centreColumn = columns / 2 + var disk = [Double](repeating: 0, count: rows * columns) + for y in 0..= 1 && (result?.indexingTolerance ?? 0) >= 0.5 && cancelled) + // A supplied basis (an initial basis) gets the same diagnostics entry. + let supplied = try LatticeStrain.consensusDiagnostics(positions, basis: LatticeBasis(g1: .init(row: 8, column: 0), g2: .init(row: 0, column: 10))) + check("LatticeStrain.consensusDiagnostics", supplied?.observationCount == 100 && supplied?.supportCount == 100 && (supplied?.residual ?? 1) < 1e-5) let mad = try LatticeStrain.referenceMADResult([LatticeBasis(g1: .init(row: 8, column: 0), g2: .init(row: 0, column: 10)), LatticeBasis(g1: .init(row: 8, column: 0), g2: .init(row: 0, column: 10)), LatticeBasis(g1: .init(row: 8, column: 0), g2: .init(row: 0, column: 10))]) @@ -465,6 +468,10 @@ let synthetic = try SyntheticProbe.make(radius: 5, width: 2, detectorRows: 32, d check("SyntheticProbe.make is 1/2 on the radius, near 1 inside and near 0 outside", synthetic.detectorRows == 32 && synthetic[16, 21] == 0.5 && synthetic[16, 11] == 0.5 && synthetic[16, 16] > 0.9 && synthetic[0, 0] < 0.001) +let pixelCentred = try SyntheticProbe.make(radius: 5, width: 2, detectorRows: 31, detectorColumns: 31, centre: (row: 15, column: 15)) +check("SyntheticProbe.make with a pixel centre peaks on that pixel", + pixelCentred[15, 20] == 0.5 && pixelCentred[15, 10] == 0.5 && pixelCentred[10, 15] == 0.5) +check("FloatFFT2D.supports", FloatFFT2D.supports(rows: 64, columns: 48) && !FloatFFT2D.supports(rows: 7, columns: 64)) check("SyntheticProbe.make: a zero width is invalidInput", await isInvalidInput { _ = try SyntheticProbe.make(radius: 5, width: 0, detectorRows: 32, detectorColumns: 32) }) check("CuboVirtualError.invalidInput", CuboVirtualError.invalidInput("x") == .invalidInput("x") From b22de85174462aa3a28693300d72d9c04ce7022a Mon Sep 17 00:00:00 2001 From: Paullo9 Date: Fri, 30 Oct 2026 10:28:26 +0100 Subject: [PATCH 2/3] feat!: remove the enums named like their modules (CuboPtycho, CuboStrain, CuboEmbedding) Each held only the deprecated scope string, and its name shadowed the module, so a module-qualified name such as CuboStrain.LatticeStrain did not resolve. A renamed enum would be an empty namespace, so they are removed. The consumer check gains a module-qualified check that does not compile with the enums present. Migration: CuboPtycho, CuboStrain and CuboEmbedding (the enums) and their deprecated scope are removed; nothing replaces them. Module-qualified names such as CuboStrain.LatticeStrain now resolve. Done: #10 item 3, so all four plan items are in PRs. Next: mac4DSTEM deletes its centre interim and re-pins after the tag. Co-Authored-By: Claude Opus 5.5 --- Sources/CuboEmbedding/CuboEmbedding.swift | 11 ++--------- Sources/CuboPtycho/CuboPtycho.docc/CuboPtycho.md | 1 - Sources/CuboPtycho/CuboPtycho.swift | 10 ---------- Sources/CuboStrain/CuboStrain.swift | 6 ------ docs/open-items.md | 2 +- .../consumer-check/Sources/consumer-check/main.swift | 5 +++++ 6 files changed, 8 insertions(+), 27 deletions(-) delete mode 100644 Sources/CuboPtycho/CuboPtycho.swift delete mode 100644 Sources/CuboStrain/CuboStrain.swift diff --git a/Sources/CuboEmbedding/CuboEmbedding.swift b/Sources/CuboEmbedding/CuboEmbedding.swift index e79118f..67407a8 100644 --- a/Sources/CuboEmbedding/CuboEmbedding.swift +++ b/Sources/CuboEmbedding/CuboEmbedding.swift @@ -1,14 +1,7 @@ import Foundation -/// PCA, k-means and the diffraction-pattern featurisation behind them. -/// -/// The primary entry points follow scikit-learn 1.4.2 (``PCA/Variant/scikitLearn``, ``KMeans/fit(_:rows:columns:initialCentroids:maxIterations:tolerance:isCancelled:)``). -/// The numerics of mac4DSTEM's diffraction-embedding pipeline are separate, explicitly named options. -public enum CuboEmbedding { - /// What this module computes. - @available(*, deprecated, message: "scope carries no information and will be removed") - public static let scope = "PCA (scikit-learn and mac4DSTEM covariance variants), Lloyd k-means (scikit-learn and mac4DSTEM k-means++ variants), binned diffraction feature vectors, and py4DSTEM's Featurization (scikit-learn NMF and Gaussian mixtures, NumPy RandomState)." -} +// PCA, k-means and the diffraction-pattern featurisation behind them. The primary entry points follow scikit-learn 1.4.2; +// the numerics of mac4DSTEM's diffraction-embedding pipeline are separate, explicitly named options. /// Why an embedding computation was refused. public enum EmbeddingError: Error, Sendable, Equatable, CustomStringConvertible { diff --git a/Sources/CuboPtycho/CuboPtycho.docc/CuboPtycho.md b/Sources/CuboPtycho/CuboPtycho.docc/CuboPtycho.md index 0876f32..030ab18 100644 --- a/Sources/CuboPtycho/CuboPtycho.docc/CuboPtycho.md +++ b/Sources/CuboPtycho/CuboPtycho.docc/CuboPtycho.md @@ -24,4 +24,3 @@ CPU ptychography preprocessing, probe construction and single-slice, mixed-state ### Supported scope - -- ``CuboPtycho`` diff --git a/Sources/CuboPtycho/CuboPtycho.swift b/Sources/CuboPtycho/CuboPtycho.swift deleted file mode 100644 index 670c6a8..0000000 --- a/Sources/CuboPtycho/CuboPtycho.swift +++ /dev/null @@ -1,10 +0,0 @@ -// CPU ptychography preprocessing, probe construction and single-slice, mixed-state and multislice reconstruction. - -import CuboCore - -/// The numerical algorithms currently available in this module. -public enum CuboPtycho { - /// Preprocessing, probe construction and the CPU single-slice, mixed-state and multislice engines. - @available(*, deprecated, message: "scope carries no information and will be removed") - public static let scope = "Preprocessing (amplitudes, scan positions, object canvas, starting probe), probe construction and CPU single-slice, mixed-state and multislice ptychography; full batches; probe-centre, probe-amplitude, aperture and support constraints; py4DSTEM's error normalisation; position correction (single-slice)." -} diff --git a/Sources/CuboStrain/CuboStrain.swift b/Sources/CuboStrain/CuboStrain.swift deleted file mode 100644 index 2ef1acc..0000000 --- a/Sources/CuboStrain/CuboStrain.swift +++ /dev/null @@ -1,6 +0,0 @@ -/// CPU strain maps from indexed Bragg vectors. -public enum CuboStrain { - /// Supported numerical scope. - @available(*, deprecated, message: "scope carries no information and will be removed") - public static let scope = "Basis selection, intensity-weighted lattice fits, reference lattices and strain tensors; the Bragg-vector histogram and the scan-level strain map." -} diff --git a/docs/open-items.md b/docs/open-items.md index 34ba5dc..92a943e 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -11,7 +11,7 @@ Slower than py4DSTEM at the typical size (100 x 100 x 64 x 64, Float32); the num Missing, in the order the study needs them (`docs/parity.md`, "study" rows): -- The app asks that remain: a vDSP-backed shifter for padded parallax sizes, names that clash with module names. The +- The app asks that remain: a vDSP-backed shifter for padded parallax sizes. The hot-pixel mean stays Float32 on purpose (`HotPixelFilter` documents why). - A single `reconstruct` for parallax; ptychography probe constraints, mixed-state and multislice engines. diff --git a/tools/consumer-check/Sources/consumer-check/main.swift b/tools/consumer-check/Sources/consumer-check/main.swift index 009e519..ba4aab1 100644 --- a/tools/consumer-check/Sources/consumer-check/main.swift +++ b/tools/consumer-check/Sources/consumer-check/main.swift @@ -471,6 +471,11 @@ check("SyntheticProbe.make is 1/2 on the radius, near 1 inside and near 0 outsid let pixelCentred = try SyntheticProbe.make(radius: 5, width: 2, detectorRows: 31, detectorColumns: 31, centre: (row: 15, column: 15)) check("SyntheticProbe.make with a pixel centre peaks on that pixel", pixelCentred[15, 20] == 0.5 && pixelCentred[15, 10] == 0.5 && pixelCentred[10, 15] == 0.5) +// No type shares its module's name, so module-qualified names resolve to the module. +check("module-qualified names (CuboStrain., CuboPtycho., CuboEmbedding.)", + ObjectIdentifier(CuboStrain.LatticeStrain.self) == ObjectIdentifier(LatticeStrain.self) + && ObjectIdentifier(CuboPtycho.PtychographyError.self) == ObjectIdentifier(PtychographyError.self) + && ObjectIdentifier(CuboEmbedding.PCA.self) == ObjectIdentifier(PCA.self)) check("FloatFFT2D.supports", FloatFFT2D.supports(rows: 64, columns: 48) && !FloatFFT2D.supports(rows: 7, columns: 64)) check("SyntheticProbe.make: a zero width is invalidInput", await isInvalidInput { _ = try SyntheticProbe.make(radius: 5, width: 0, detectorRows: 32, detectorColumns: 32) }) From 83b87749ec058e342eb7b23da8274299c0912383 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:14:18 +0000 Subject: [PATCH 3/3] Restore main's lines in two files the merge resolution dropped The merge took this branch's whole files for docs/open-items.md and the consumer check. That dropped main's newer lstsq note and its renamed consumer-check comments. Now both files are main's, plus this branch's two changes: the module-name check, and the open item it closes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VkKZEWLQDNR7GTJSa5geSZ --- docs/open-items.md | 2 +- .../Sources/consumer-check/main.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/open-items.md b/docs/open-items.md index 92a943e..878aed9 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -28,7 +28,7 @@ Decisions to keep an eye on: - The polar cube's radius is on the minor-axis scale (`rr = (b / a) * hypot`), the radial background's lookup radius on the major-axis scale. Both are py4DSTEM's and are ported as they are; for `a != b` the background profile is stretched by `a / b` against the polar bins. A pull request upstream (or a deliberate deviation here) is Paul's call; `testRadialBackgroundOfACircle` shows the circle agreeing with the planted exponential to 0.2%. - The amorphous-ring fit is chaotic at rounding level in its poorly determined parameters (py4DSTEM's own result moves by up to 7e-4 in the axes when the start changes by one ulp on the `tilted` case); tests compare ellipses at 1e-6 and the residual sum of squares at 1e-6 relative, not parameter by parameter. - `PolarCube` keeps the whole cube in Double when asked for (`PolarCubeData`: scan x 180 x ~180 doubles per pattern); a lazy `CubeSource` adapter or a Float32 variant is for the consumer that needs it. The polar cube and radial background are CPU only (1024 patterns of 256 x 256 in 0.26 s); a Metal path would be bound by the host copy. -- `CuboBragg`'s `np.linalg.lstsq` is still its own one-sided Jacobi SVD (`ClassificationNumerics.leastSquares`), not CuboEmbedding's LAPACK SVD: moving it to LAPACK would shift H within np.allclose but not bit for bit, so it waits for a numerics change of its own. The NMF, the Gaussian filter and the median are shared since #14, except `Featurization`'s feature median (CuboEmbedding does not depend on CuboCore) and the sorted-window medians of `HotPixelFilter` and `medianFilterMaskedPixels`. +- The NMF, the Gaussian filter, the median and the SVD are shared since #14 (CuboBragg's `lstsq` runs on CuboEmbedding's LAPACK SVD), except `Featurization`'s feature median (CuboEmbedding does not depend on CuboCore) and the sorted-window medians of `HotPixelFilter` and `medianFilterMaskedPixels`. - `Featurization.from_braggvectors`, ICA, `RobustScaler`, `spatial_separation` and `consensus` are not ported. The GMM k-means start is a transcription of scikit-learn 1.4.2's Lloyd loop with the data centred as 1.9.1 does (every pin agrees on labels). Upstream `NMF(init=None)` with `random_state=None` is not bit repeatable (an unseeded randomised SVD). - `fit_ellipse_1D` with default `fitradii` raises `TypeError` upstream; the kit's default is `(0, min(shape)/2)`. The DocC landing pages list the new public names; `tools/consumer-check` does not exercise them yet. - DiffractoKit follow-up: consume the polar cube (`PolarCube`, `PolarCubeData`) and `EllipseFit` for the ring and ellipse work; no code there yet. diff --git a/tools/consumer-check/Sources/consumer-check/main.swift b/tools/consumer-check/Sources/consumer-check/main.swift index ba4aab1..830b460 100644 --- a/tools/consumer-check/Sources/consumer-check/main.swift +++ b/tools/consumer-check/Sources/consumer-check/main.swift @@ -183,7 +183,7 @@ do { check("StrainMapping scan-level", false) } do { - // Round 6: consensus diagnostics with cancellation, MAD inliers. + // Strain consensus: diagnostics with cancellation, MAD inliers. let positions = (0..<20).map { _ in [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (-1.0, 0.0), (0.0, -1.0)].map { LatticePeak(row: 8 * $0.0, column: 10 * $0.1, intensity: 1) } } let result = try LatticeStrain.chooseBasisConsensusResult(positions, isCancelled: { false }) let plain = try LatticeStrain.chooseBasisConsensus(positions) @@ -206,7 +206,7 @@ do { origin: LatticeVector(row: 16, column: 16), directions: lat, options: one) check("StrainMapResult.referenceInlierCount", m.referenceInlierCount == 4 && m.referencePositionCount == 4) } catch { - check("round 6 strain", false) + check("strain consensus", false) } do { // Crop-and-bin view, ptychography preprocessing and the three engines, through the public API. @@ -269,7 +269,7 @@ do { && mixed.object.real.allSatisfy(\.isFinite) && multi.probe.real.allSatisfy(\.isFinite)) } check("ElementType list", ElementType.allCases.count == 10 && ElementType.uint16.npyDescriptor == " Void) async -> Bool { do { try await body(); return false } catch let error as CuboVirtualError { @@ -360,8 +360,8 @@ check("VirtualDiffraction.pattern: weights all one give the mean", let firstWeighted = try await VirtualDiffraction.pattern(of: tiny, statistic: .mean, scan: .weights([1, 0, 0])) check("VirtualDiffraction.pattern: weights [1, 0, 0] give the first pattern", firstWeighted.values == tinyP0.map(Double.init)) let allScan = try await VirtualDiffraction.pattern(of: eager, statistic: .max, scan: .all) -let slice1Max = try await VirtualDiffraction.max(of: eager, path: .cpu) -check("VirtualDiffraction.pattern .all .max equals slice 1's max", allScan.values == slice1Max.values && plainMean.values.count == 72) +let plainMax = try await VirtualDiffraction.max(of: eager, path: .cpu) +check("VirtualDiffraction.pattern .all .max equals VirtualDiffraction.max", allScan.values == plainMax.values && plainMean.values.count == 72) check("ScanSelection is Hashable", ScanSelection.all == ScanSelection.all && ScanSelection.mask([true]) != ScanSelection.mask([false]) && Set([ScanSelection.all, .weights([1]), .weights([1])]).count == 2) let shiftedSelection = try await VirtualDiffraction.pattern(of: lazy, statistic: .mean, scan: .all, shift: .rounded(origins))