react-native-magnetkit 0.0.0-stage → 0.1.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -2
  3. package/android/build.gradle +18 -0
  4. package/android/src/main/java/dev/libraries/magnetkit/MagnetDipole.kt +76 -0
  5. package/android/src/main/java/dev/libraries/magnetkit/MagnetModel.kt +189 -0
  6. package/android/src/main/java/dev/libraries/magnetkit/MagnetSource.kt +598 -0
  7. package/android/src/main/java/dev/libraries/magnetkit/MfxlNet.kt +92 -0
  8. package/android/src/main/java/dev/libraries/magnetkit/Vec3.kt +30 -0
  9. package/android/src/main/java/dev/libraries/magnetkit/rn/MagnetKitModule.kt +172 -0
  10. package/app.plugin.js +1 -0
  11. package/build/MagnetCalibration.d.ts +13 -0
  12. package/build/MagnetCalibration.js +58 -0
  13. package/build/MagnetKitModule.d.ts +52 -0
  14. package/build/MagnetKitModule.js +2 -0
  15. package/build/MagneticView.d.ts +15 -0
  16. package/build/MagneticView.js +129 -0
  17. package/build/core/calibration-core.d.ts +43 -0
  18. package/build/core/calibration-core.js +84 -0
  19. package/build/core/field.d.ts +64 -0
  20. package/build/core/field.js +341 -0
  21. package/build/core/fit.d.ts +13 -0
  22. package/build/core/fit.js +50 -0
  23. package/build/core/types.d.ts +37 -0
  24. package/build/core/types.js +2 -0
  25. package/build/hooks.d.ts +7 -0
  26. package/build/hooks.js +29 -0
  27. package/build/index.d.ts +9 -0
  28. package/build/index.js +7 -0
  29. package/build/magnet.d.ts +29 -0
  30. package/build/magnet.js +66 -0
  31. package/expo-module.config.json +9 -0
  32. package/ios/MagnetKit/MFXLNet.swift +89 -0
  33. package/ios/MagnetKit/MagnetLocator.swift +252 -0
  34. package/ios/MagnetKit/MagnetModel.swift +56 -0
  35. package/ios/MagnetKit/MagnetPresence.swift +81 -0
  36. package/ios/MagnetKit/MagnetSource.swift +967 -0
  37. package/ios/MagnetKit/MagnetTap.swift +53 -0
  38. package/ios/MagnetKitModule.swift +147 -0
  39. package/ios/MagnetKitRN.podspec +26 -0
  40. package/package.json +54 -4
  41. package/plugin/withMagnetKit.js +60 -0
  42. package/src/MagnetCalibration.tsx +87 -0
  43. package/src/MagnetKitModule.ts +42 -0
  44. package/src/MagneticView.tsx +153 -0
  45. package/src/core/calibration-core.ts +112 -0
  46. package/src/core/field.ts +295 -0
  47. package/src/core/fit.ts +55 -0
  48. package/src/core/types.ts +32 -0
  49. package/src/hooks.ts +32 -0
  50. package/src/index.ts +9 -0
  51. package/src/magnet.ts +77 -0
@@ -0,0 +1,89 @@
1
+ import Foundation
2
+ import Accelerate
3
+
4
+ /// A small fully connected network stored as MFXL: "MFXL", a little-endian
5
+ /// UInt32 header length, a JSON header (`kind`, `layers`: [{in, out, act}],
6
+ /// plus whatever the network needs), then each layer's weights (out × in,
7
+ /// row-major) and biases as float16. Shared by the magnet networks.
8
+ struct MFXLNet {
9
+ private struct Layer { let inN: Int, outN: Int; let w: [Float]; let b: [Float]; let silu: Bool }
10
+ private let layers: [Layer]
11
+ let header: [String: Any]
12
+ var inputs: Int { layers.first?.inN ?? 0 }
13
+ var outputs: Int { layers.last?.outN ?? 0 }
14
+
15
+ init?(data: Data, kind: String) {
16
+ guard data.count > 8, data.prefix(4) == Data("MFXL".utf8) else { return nil }
17
+ let hlen = Int(data[4]) | Int(data[5]) << 8 | Int(data[6]) << 16 | Int(data[7]) << 24
18
+ guard data.count >= 8 + hlen,
19
+ let header = try? JSONSerialization.jsonObject(with: data.subdata(in: 8..<(8 + hlen))) as? [String: Any],
20
+ (header["kind"] as? String) == kind,
21
+ let specs = header["layers"] as? [[String: Any]] else { return nil }
22
+ var offset = 8 + hlen
23
+ func halves(_ count: Int) -> [Float]? {
24
+ let bytes = count * 2
25
+ guard offset + bytes <= data.count else { return nil }
26
+ var out = [Float](repeating: 0, count: count)
27
+ data.withUnsafeBytes { raw in
28
+ let base = raw.baseAddress!.advanced(by: offset)
29
+ for i in 0..<count {
30
+ let lo = UInt16(base.load(fromByteOffset: i * 2, as: UInt8.self))
31
+ let hi = UInt16(base.load(fromByteOffset: i * 2 + 1, as: UInt8.self))
32
+ out[i] = Self.halfToFloat(lo | hi << 8)
33
+ }
34
+ }
35
+ offset += bytes
36
+ return out
37
+ }
38
+ var parsed: [Layer] = []
39
+ for s in specs {
40
+ guard let inN = s["in"] as? Int, let outN = s["out"] as? Int,
41
+ let w = halves(inN * outN), let b = halves(outN) else { return nil }
42
+ parsed.append(Layer(inN: inN, outN: outN, w: w, b: b, silu: (s["act"] as? String) == "silu"))
43
+ }
44
+ guard !parsed.isEmpty else { return nil }
45
+ self.header = header
46
+ layers = parsed
47
+ }
48
+
49
+ func forward(_ input: [Float]) -> [Float] {
50
+ var cur = input
51
+ for L in layers {
52
+ var dst = [Float](repeating: 0, count: L.outN)
53
+ L.w.withUnsafeBufferPointer { w in
54
+ cur.withUnsafeBufferPointer { x in
55
+ dst.withUnsafeMutableBufferPointer { y in
56
+ vDSP_mmul(w.baseAddress!, 1, x.baseAddress!, 1, y.baseAddress!, 1,
57
+ vDSP_Length(L.outN), 1, vDSP_Length(L.inN))
58
+ }
59
+ }
60
+ }
61
+ vDSP_vadd(dst, 1, L.b, 1, &dst, 1, vDSP_Length(L.outN))
62
+ if L.silu {
63
+ var neg = [Float](repeating: 0, count: L.outN)
64
+ var minus: Float = -1
65
+ vDSP_vsmul(dst, 1, &minus, &neg, 1, vDSP_Length(L.outN))
66
+ var count = Int32(L.outN)
67
+ vvexpf(&neg, neg, &count)
68
+ var one: Float = 1
69
+ vDSP_vsadd(neg, 1, &one, &neg, 1, vDSP_Length(L.outN))
70
+ vDSP_vdiv(neg, 1, dst, 1, &dst, 1, vDSP_Length(L.outN))
71
+ }
72
+ cur = dst
73
+ }
74
+ return cur
75
+ }
76
+
77
+ private static func halfToFloat(_ h: UInt16) -> Float {
78
+ let sign = UInt32(h >> 15) << 31
79
+ let exp = Int((h >> 10) & 0x1f)
80
+ let mant = UInt32(h & 0x3ff)
81
+ if exp == 0 {
82
+ if mant == 0 { return Float(bitPattern: sign) }
83
+ let v = Float(mant) * powf(2, -24)
84
+ return sign != 0 ? -v : v
85
+ }
86
+ if exp == 31 { return Float(bitPattern: sign | 0x7f80_0000 | (mant << 13)) }
87
+ return Float(bitPattern: sign | UInt32(exp - 15 + 127) << 23 | mant << 13)
88
+ }
89
+ }
@@ -0,0 +1,252 @@
1
+ import Foundation
2
+ import Accelerate
3
+ import simd
4
+
5
+ /// Where a magnet is, from one magnetometer reading: a small network
6
+ /// (7 → 512 → 512 → 512 → 256 → 6, ~660k weights, 1.3 MB in half precision)
7
+ /// trained only on simulated magnet physics — a dipole hovering over a phone
8
+ /// at random heights, tilts, faces and strengths, with sensor noise, drift
9
+ /// and small distortions. No data from users went into it.
10
+ ///
11
+ /// It answers relative to the magnetometer, in device axes, so any iPhone
12
+ /// works once its sensor spot is known (`MagnetConfig.sensorPosition`):
13
+ /// one forward pass per reading, run with Accelerate — no Core ML.
14
+ ///
15
+ /// Accuracy is best near the sensor and falls off with distance from it;
16
+ /// the network reports its own spread so the UI can soften what it is unsure
17
+ /// of. Knowing which face of the magnet points at the screen matters: without
18
+ /// it, a magnet beside or above the sensor reads the same as a flipped one on
19
+ /// the other side, and the network falls back on "usually below the sensor".
20
+ public final class MagnetLocator {
21
+ private struct Layer {
22
+ let inN: Int, outN: Int
23
+ let w: [Float] // outN × inN, row-major
24
+ let b: [Float]
25
+ let silu: Bool
26
+ }
27
+
28
+ private let layers: [Layer]
29
+ private let logbMid: Float, logkMid: Float, logrMid: Float
30
+ private let lvMin: Float, lvMax: Float
31
+ /// Whether this file's network takes the magnet's face (version 3+).
32
+ public let takesFace: Bool
33
+ private var scratchA: [Float] = []
34
+ private var scratchB: [Float] = []
35
+
36
+ /// Parse an MFXL file: "MFXL", a little-endian UInt32 header length, a
37
+ /// JSON header, then float16 weights and biases layer by layer.
38
+ public init?(data: Data) {
39
+ guard data.count > 8, data.prefix(4) == Data("MFXL".utf8) else { return nil }
40
+ let hlen = Int(data[4]) | Int(data[5]) << 8 | Int(data[6]) << 16 | Int(data[7]) << 24
41
+ guard data.count >= 8 + hlen,
42
+ let header = try? JSONSerialization.jsonObject(with: data.subdata(in: 8..<(8 + hlen))) as? [String: Any],
43
+ let specs = header["layers"] as? [[String: Any]] else { return nil }
44
+ logbMid = Float(header["logbMid"] as? Double ?? log(40))
45
+ logkMid = Float(header["logkMid"] as? Double ?? log(10000))
46
+ logrMid = Float(header["logrMid"] as? Double ?? log(5))
47
+ guard let version = header["version"] as? Int, version == 2 || version == 3 else { return nil }
48
+ let clamp = header["logvarClamp"] as? [Double] ?? [-10, 4]
49
+ lvMin = Float(clamp.first ?? -10); lvMax = Float(clamp.last ?? 4)
50
+
51
+ var offset = 8 + hlen
52
+ func halves(_ count: Int) -> [Float]? {
53
+ let bytes = count * 2
54
+ guard offset + bytes <= data.count else { return nil }
55
+ var out = [Float](repeating: 0, count: count)
56
+ data.withUnsafeBytes { raw in
57
+ let base = raw.baseAddress!.advanced(by: offset)
58
+ for i in 0..<count {
59
+ let lo = UInt16(base.load(fromByteOffset: i * 2, as: UInt8.self))
60
+ let hi = UInt16(base.load(fromByteOffset: i * 2 + 1, as: UInt8.self))
61
+ out[i] = Self.halfToFloat(lo | hi << 8)
62
+ }
63
+ }
64
+ offset += bytes
65
+ return out
66
+ }
67
+ var parsed: [Layer] = []
68
+ for s in specs {
69
+ guard let inN = s["in"] as? Int, let outN = s["out"] as? Int,
70
+ let w = halves(inN * outN), let b = halves(outN) else { return nil }
71
+ parsed.append(Layer(inN: inN, outN: outN, w: w, b: b, silu: (s["act"] as? String) == "silu"))
72
+ }
73
+ let nIn = version == 3 ? 7 : 6
74
+ guard parsed.first?.inN == nIn, parsed.last?.outN == 6 else { return nil }
75
+ layers = parsed
76
+ takesFace = version == 3
77
+ let widest = parsed.map { max($0.inN, $0.outN) }.max() ?? 6
78
+ scratchA = [Float](repeating: 0, count: widest)
79
+ scratchB = [Float](repeating: 0, count: widest)
80
+ }
81
+
82
+ /// Where the magnet is relative to the sensor (cm, device axes: x right,
83
+ /// y toward the top edge, z out of the screen) and the network's 1-σ
84
+ /// spread of that position, cm.
85
+ /// - Parameters:
86
+ /// - field: change in µT since the baseline, device axes.
87
+ /// - strength: the magnet's dipole constant in µT·cm³ if known (a 10×8×2 mm
88
+ /// N52 ring is ~6500), nil if not.
89
+ /// - face: +1 if the magnet's north pole points out of the screen
90
+ /// (toward the user), −1 if south, nil if not known.
91
+ public func locate(field raw: SIMD3<Double>, strength: Double?, face: Int? = nil) -> (position: SIMD3<Double>, spread: Double)? {
92
+ let nb = simd_length(raw)
93
+ guard nb > 1e-6 else { return nil }
94
+ let knownFace = takesFace ? face.map { $0 < 0 ? -1.0 : 1.0 } : nil
95
+ // Known face: turn the field to face +1. Unknown: a flipped magnet
96
+ // gives −B at the same spot, so the network sees one face (Bz ≥ 0).
97
+ let field = knownFace.map { raw * $0 } ?? (raw.z < 0 ? -raw : raw)
98
+ var input: [Float] = [
99
+ Float(field.x / nb), Float(field.y / nb), Float(field.z / nb),
100
+ (Float(log(nb)) - logbMid) / 2,
101
+ strength.map { (Float(log(max(1, $0))) - logkMid) / 1.5 } ?? 0,
102
+ strength == nil ? 0 : 1,
103
+ ]
104
+ if takesFace { input.append(knownFace == nil ? 0 : 1) }
105
+ for i in input.indices { scratchA[i] = input[i] }
106
+ var cur = 0 // 0: scratchA holds the activations, 1: scratchB
107
+ for L in layers {
108
+ let src = cur == 0 ? scratchA : scratchB
109
+ var dst = [Float](repeating: 0, count: L.outN)
110
+ L.w.withUnsafeBufferPointer { w in
111
+ src.withUnsafeBufferPointer { x in
112
+ dst.withUnsafeMutableBufferPointer { y in
113
+ // y = W · x (outN×inN · inN×1)
114
+ vDSP_mmul(w.baseAddress!, 1, x.baseAddress!, 1, y.baseAddress!, 1,
115
+ vDSP_Length(L.outN), 1, vDSP_Length(L.inN))
116
+ }
117
+ }
118
+ }
119
+ vDSP_vadd(dst, 1, L.b, 1, &dst, 1, vDSP_Length(L.outN))
120
+ if L.silu {
121
+ // x · sigmoid(x)
122
+ var neg = [Float](repeating: 0, count: L.outN)
123
+ var minus: Float = -1
124
+ vDSP_vsmul(dst, 1, &minus, &neg, 1, vDSP_Length(L.outN))
125
+ var count = Int32(L.outN)
126
+ vvexpf(&neg, neg, &count)
127
+ var one: Float = 1
128
+ vDSP_vsadd(neg, 1, &one, &neg, 1, vDSP_Length(L.outN))
129
+ vDSP_vdiv(neg, 1, dst, 1, &dst, 1, vDSP_Length(L.outN))
130
+ }
131
+ if cur == 0 {
132
+ for i in 0..<L.outN { scratchB[i] = dst[i] }
133
+ cur = 1
134
+ } else {
135
+ for i in 0..<L.outN { scratchA[i] = dst[i] }
136
+ cur = 0
137
+ }
138
+ }
139
+ let o = cur == 0 ? scratchA : scratchB
140
+ // Direction from the sensor and distance: p = r · û.
141
+ let d = SIMD3(Double(o[0]), Double(o[1]), Double(o[2]))
142
+ let dl = simd_length(d)
143
+ guard dl > 1e-9 else { return nil }
144
+ let r = Double(exp(o[3] + logrMid))
145
+ func sd(_ v: Float) -> Double { Double(exp(0.5 * min(lvMax, max(lvMin, v)))) }
146
+ let spread = r * hypot(sd(o[4]), sd(o[5])) // radial (relative) and sideways (angle) parts
147
+ return (d / dl * r, spread)
148
+ }
149
+
150
+ private static func halfToFloat(_ h: UInt16) -> Float {
151
+ let sign = UInt32(h >> 15) << 31
152
+ let exp = Int((h >> 10) & 0x1f)
153
+ let mant = UInt32(h & 0x3ff)
154
+ if exp == 0 {
155
+ if mant == 0 { return Float(bitPattern: sign) }
156
+ // Subnormal half.
157
+ let v = Float(mant) * powf(2, -24)
158
+ return sign != 0 ? -v : v
159
+ }
160
+ if exp == 31 { return Float(bitPattern: sign | 0x7f80_0000 | (mant << 13)) }
161
+ return Float(bitPattern: sign | UInt32(exp - 15 + 127) << 23 | mant << 13)
162
+ }
163
+ }
164
+
165
+ /// Where an upright magnet is, worked out directly from the physics: no
166
+ /// network, exact for a magnet whose axis points roughly at the screen
167
+ /// (standing, or held flat-on), anywhere above the sensor's height — over the
168
+ /// glass or beside the phone. It needs the magnet's face; the strength only
169
+ /// sets the distance (2× off moves the answer by about a quarter).
170
+ ///
171
+ /// For such a magnet the field direction gives the bearing exactly:
172
+ /// with u = (sin α cos φ, sin α sin φ, cos α) from the sensor to the magnet,
173
+ /// B ∝ face · (3 cos α sin α cos φ, 3 cos α sin α sin φ, 3 cos²α − 1), and
174
+ /// |B| = K √(1 + 3 cos²α) / r³.
175
+ public enum MagnetDipole {
176
+ /// cos²α from the field's vertical share q = face·Bz/|B| (closed form).
177
+ static func cos2(_ q: Double) -> Double {
178
+ let q = min(1, max(-1, q))
179
+ let u = (q + (q * q + 8).squareRoot()) / 2 // √(1 + 3 cos²α)
180
+ return min(1, max(0, (u * u - 1) / 3))
181
+ }
182
+
183
+ /// The magnet's position relative to the sensor (cm, device axes: x right,
184
+ /// y toward the top edge, z out of the screen), and how much of the field
185
+ /// lies in the screen's plane (0–1: small means the bearing is fragile —
186
+ /// a magnet resting at the sensor's own height).
187
+ /// - Parameters:
188
+ /// - field: change since the baseline, µT, device axes.
189
+ /// - face: +1 north toward the user, −1 south.
190
+ /// - strength: dipole constant, µT·cm³.
191
+ /// - maxHeight: cap on the height above the sensor, cm. Far from the
192
+ /// sensor a hand's tilt makes the magnet look higher than it is, which
193
+ /// pulls it in toward the sensor; a cap keeps the distance honest.
194
+ public static func locate(field b: SIMD3<Double>, face: Int, strength: Double, maxHeight: Double? = nil) -> (position: SIMD3<Double>, inPlane: Double)? {
195
+ let nb = simd_length(b)
196
+ guard nb > 1e-6, strength > 0 else { return nil }
197
+ let f: Double = face < 0 ? -1 : 1
198
+ let c2 = cos2(f * b.z / nb)
199
+ let c = c2.squareRoot(), s = max(0, 1 - c2).squareRoot()
200
+ var phi = atan2(b.y, b.x)
201
+ if f < 0 { phi += .pi }
202
+ let r = pow(strength * (1 + 3 * c2).squareRoot() / nb, 1.0 / 3.0)
203
+ let inPlane = (b.x * b.x + b.y * b.y).squareRoot() / nb
204
+ var h = r * c, lateral = r * s
205
+ if let cap = maxHeight, h > cap { h = cap; lateral = max(0, r * r - h * h).squareRoot() }
206
+ return (SIMD3(lateral * cos(phi), lateral * sin(phi), h), inPlane)
207
+ }
208
+
209
+ /// Learn the face and strength from one reading with the magnet held over
210
+ /// a known spot (`offset`: from the sensor, cm, device axes, in the screen
211
+ /// plane). Each face, at heights from 0.6 to 3.6 cm above the sensor,
212
+ /// predicts a direction for the field; the face and height that match the
213
+ /// reading best win, and the strength follows from the size of the field
214
+ /// there. The whole direction counts: a few cm to the side of the sensor
215
+ /// the field is mostly vertical (its sign alone gives the face), while the
216
+ /// sideways part that carries the bearing is small and swings with the
217
+ /// magnet's tilt in the fingers — judged by the bearing alone, 1 grab in 9
218
+ /// hovering under 2 cm got the face wrong (simulated, tilts up to 35°).
219
+ /// Best with the spot 2–5 cm from the sensor.
220
+ public static func learn(field b: SIMD3<Double>, magnetAt offset: SIMD2<Double>) -> (face: Int, strength: Double)? {
221
+ let nb = simd_length(b), lateral = simd_length(offset)
222
+ guard nb > 1e-6, lateral > 0.5 else { return nil }
223
+ let u = b / nb
224
+ var best: (score: Double, face: Int, strength: Double)?
225
+ for face in [1, -1] {
226
+ let m = SIMD3<Double>(0, 0, Double(face))
227
+ for h in stride(from: 0.6, through: 3.6, by: 0.2) {
228
+ // The magnet at (offset, h) from the sensor; r̂ points from it to the sensor.
229
+ let p = SIMD3(offset.x, offset.y, h)
230
+ let r = simd_length(p)
231
+ let rh = -p / r
232
+ let dir = 3 * simd_dot(m, rh) * rh - m
233
+ let score = simd_dot(u, dir / simd_length(dir))
234
+ if best == nil || score > best!.score { best = (score, face, nb * r * r * r / simd_length(dir)) }
235
+ }
236
+ }
237
+ guard let b = best, b.score > 0.6 else { return nil }
238
+ return (b.face, b.strength)
239
+ }
240
+
241
+ /// The strength from a magnet held over a spot far from the sensor
242
+ /// (`offset`, cm, device axes; 6 cm or more is best) at roughly `height`
243
+ /// cm above the sensor. Far away the distance barely depends on the
244
+ /// height or the hand's tilt, so this is the sturdy way to get the scale.
245
+ public static func strength(field b: SIMD3<Double>, magnetAt offset: SIMD2<Double>, height: Double = 2.4) -> Double? {
246
+ let nb = simd_length(b), lateral = simd_length(offset)
247
+ guard nb > 1e-6, lateral > 0.5 else { return nil }
248
+ let r = (lateral * lateral + height * height).squareRoot()
249
+ let c = height / r
250
+ return nb * r * r * r / (1 + 3 * c * c).squareRoot()
251
+ }
252
+ }
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+
3
+ /// The magnet model: one on-device model that reads the iPhone's
4
+ /// magnetometer and answers three things — where the magnet is, whether it is
5
+ /// really there (or the phone's own magnetised parts are lying), and air taps.
6
+ /// Trained only on simulated magnet physics, its simulator fitted to real
7
+ /// recordings; it is one file (Magnet.mfxl, ~2 MB, half precision) and runs
8
+ /// with Accelerate — no Core ML, no network.
9
+ ///
10
+ /// The model is not part of this MIT package: it is licensed separately under
11
+ /// the Libraries.dev Model License (LICENSE-MODEL.md) and downloaded from
12
+ /// libraries.dev. Add Magnet.mfxl to your app target and `bundled` finds it.
13
+ ///
14
+ /// `MagnetSource` runs it for you every frame; use the model directly only
15
+ /// to feed it your own readings.
16
+ public final class MagnetModel {
17
+ /// The model in the app bundle (Magnet.mfxl in your app target), or nil
18
+ /// if the file is missing.
19
+ public static let bundled: MagnetModel? = {
20
+ guard let url = Bundle.main.url(forResource: "Magnet", withExtension: "mfxl"),
21
+ let data = try? Data(contentsOf: url) else { return nil }
22
+ return MagnetModel(data: data)
23
+ }()
24
+
25
+ /// The model's version, e.g. "1.0".
26
+ public let version: String
27
+ /// Where the magnet is, from one reading (relative to the sensor, cm).
28
+ public let locator: MagnetLocator
29
+ /// Is a magnet really there, from the last 15 s.
30
+ public let presence: MagnetPresence
31
+ /// Did an air tap just finish, from the last 1.28 s.
32
+ public let tap: MagnetTap
33
+
34
+ /// "MFXL", a little-endian UInt32 header length, a JSON header (kind
35
+ /// "magnet", version, parts with offsets and lengths), then each part.
36
+ public init?(data: Data) {
37
+ guard data.count > 8, data.prefix(4) == Data("MFXL".utf8) else { return nil }
38
+ let hlen = Int(data[4]) | Int(data[5]) << 8 | Int(data[6]) << 16 | Int(data[7]) << 24
39
+ guard data.count >= 8 + hlen,
40
+ let header = try? JSONSerialization.jsonObject(with: data.subdata(in: 8..<(8 + hlen))) as? [String: Any],
41
+ (header["kind"] as? String) == "magnet",
42
+ let parts = header["parts"] as? [[String: Any]] else { return nil }
43
+ let start = 8 + hlen
44
+ func part(_ name: String) -> Data? {
45
+ guard let p = parts.first(where: { ($0["name"] as? String) == name }),
46
+ let off = p["offset"] as? Int, let len = p["length"] as? Int,
47
+ start + off + len <= data.count else { return nil }
48
+ return data.subdata(in: (start + off)..<(start + off + len))
49
+ }
50
+ guard let l = part("locator").flatMap(MagnetLocator.init(data:)),
51
+ let p = part("presence").flatMap(MagnetPresence.init(data:)),
52
+ let t = part("tap").flatMap(MagnetTap.init(data:)) else { return nil }
53
+ version = header["version"] as? String ?? "1.0"
54
+ locator = l; presence = p; tap = t
55
+ }
56
+ }
@@ -0,0 +1,81 @@
1
+ import Foundation
2
+ import simd
3
+
4
+ /// Is there really a magnet — or is the phone's own sensor lying?
5
+ ///
6
+ /// A magnet brought within ~2 cm of an iPhone's magnetometer (above
7
+ /// ~2,000 µT) magnetises parts near it and leaves an offset of 50–250 µT that
8
+ /// lasts minutes; a calibration taken with a magnet nearby leaves one too.
9
+ /// One reading of such an offset looks exactly like a magnet. Over time they
10
+ /// differ — a hand-held magnet always trembles, a resting one arrived by
11
+ /// approaching, a leftover stays behind after a strong reading drops — and
12
+ /// this small network (236 → 256 → 256 → 128 → 4, ~160k weights, trained
13
+ /// only on simulated sequences) reads the last 15 s of the field to answer:
14
+ /// the probability a magnet is present, and the part of the reading that is
15
+ /// not a magnet (the offset to subtract).
16
+ ///
17
+ /// `MagnetSource` runs it every frame when `config.presenceModel` is on:
18
+ /// readings carry `presence` and `offsetEstimate`, and a confident "no
19
+ /// magnet" with a steady field re-zeros the sensor.
20
+ public final class MagnetPresence {
21
+ private let net: MFXLNet
22
+ /// Input layout (must match training): `short` samples at `hz` of the
23
+ /// field, `longN` magnitudes every `longStep` samples, 4 context values.
24
+ public let hz: Double
25
+ public let short: Int, longN: Int, longStep: Int
26
+ /// How much history the features look at, seconds.
27
+ public let window: Double
28
+ /// Extra inputs from the kit's memory (v2: overloaded since the last zero,
29
+ /// and how long ago); 0 for v1 networks.
30
+ public let contextInputs: Int
31
+
32
+ public init?(data: Data) {
33
+ guard let net = MFXLNet(data: data, kind: "presence") else { return nil }
34
+ let h = net.header
35
+ hz = h["hz"] as? Double ?? 50
36
+ short = h["short"] as? Int ?? 64
37
+ longN = h["longN"] as? Int ?? 40
38
+ longStep = h["longStep"] as? Int ?? 5
39
+ window = h["window"] as? Double ?? 15
40
+ contextInputs = h["context"] as? Int ?? 0
41
+ guard net.inputs == short * 3 + longN + 4 + contextInputs, net.outputs == 4 else { return nil }
42
+ self.net = net
43
+ }
44
+
45
+ /// - Parameter history: the field change (µT, device axes, against the
46
+ /// current zero) sampled at `hz`, oldest first, ending now; at least
47
+ /// `short` and `longN * longStep` samples (pad the start if needed).
48
+ /// - overloadedFor: seconds since the phone was last overloaded (a field
49
+ /// over ~2,000 µT), if it has not been zeroed since — and only while
50
+ /// the field has just dropped (the magnet moving away), so a magnet
51
+ /// resting or held still is not mistaken for the leftover. nil: no.
52
+ /// - Returns: magnet present (0–1) and the non-magnet part of the reading now, µT.
53
+ public func evaluate(history h: [SIMD3<Double>], overloadedFor: Double? = nil) -> (presence: Double, offset: SIMD3<Double>) {
54
+ guard let last = h.last, h.count >= max(short, longN * longStep) else { return (1, .zero) }
55
+ let n = h.count
56
+ let S = max(10, simd_length(last))
57
+ var x = [Float](); x.reserveCapacity(short * 3 + longN + 4)
58
+ for i in (n - short)..<n {
59
+ let v = h[i] / S
60
+ x.append(Float(min(6, max(-6, v.x)))); x.append(Float(min(6, max(-6, v.y)))); x.append(Float(min(6, max(-6, v.z))))
61
+ }
62
+ // The long view: every `longStep`-th sample of the last longN·longStep, oldest first.
63
+ var j = n - longN * longStep
64
+ for _ in 0..<longN {
65
+ x.append(Float(log1p(simd_length(h[j]) / 10) / 6))
66
+ j += longStep
67
+ }
68
+ var peak = 0.0, peakAt = 0
69
+ for (i, v) in h.enumerated() { let m = simd_length(v); if m > peak { peak = m; peakAt = i } }
70
+ let since = Double(n - 1 - peakAt) / hz / 15
71
+ x.append(Float(log1p(peak / 100) / 4)); x.append(Float(since))
72
+ x.append(Float(log1p(S / 10) / 6)); x.append(peak > 2000 ? 1 : 0)
73
+ if contextInputs >= 2 {
74
+ x.append(overloadedFor == nil ? 0 : 1)
75
+ x.append(overloadedFor.map { Float(min(1.6, log1p($0 / 10) / log1p(60))) } ?? 0)
76
+ }
77
+ let o = net.forward(x)
78
+ let p = 1 / (1 + exp(-Double(o[0])))
79
+ return (p, SIMD3(Double(o[1]), Double(o[2]), Double(o[3])) * S)
80
+ }
81
+ }