diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 55f2b8c72..d045bc655 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -645,6 +645,22 @@ final class ScanViewState { var closeUpFailedAttempts = 0 /// Nil until the close-up photo is taken. var meterNumber: MeterNumberState? + /// The meter's maker as read from the close-up, shown above the number candidates. It exists + /// only beside those candidates or the number confirmed from them: while `meterNumber` is + /// nil, reading or skipped it is nil, so a brand from an earlier photo can't outlive its + /// number through a retake, a skip or a new close-up. The homeowner can reject it + /// (`rejectMeterBrand`). Like the number, it stays on the phone. + var meterBrand: String? { + get { + switch meterNumber { + case .choose, .confirmed: offeredMeterBrand + case .reading, .skipped, nil: nil + } + } + set { offeredMeterBrand = newValue } + } + /// Storage for `meterBrand`, set with the candidates it was read with. Read `meterBrand`. + private var offeredMeterBrand: String? var captureCount = 0 var lastCapture: CaptureEvent? @@ -718,6 +734,8 @@ protocol ScanActions: AnyObject { /// The homeowner's pick from `MeterNumberState.choose`; nil means "None of these", which /// asks for a retake. func chooseMeterNumber(_ candidate: MeterNumberCandidate?) + /// "Not " beside the number candidates: drops `ScanViewState.meterBrand`. + func rejectMeterBrand() /// Marks a wall end where `point` meets the wall, on whichever side of the meter that is. func markWallEnd(at point: CGPoint?, viewSize: CGSize) /// "Wall ends here" during the walk: ends the wall on the side being walked where diff --git a/ios/HouseScan/Runtime/MeterNumberReading.swift b/ios/HouseScan/Runtime/MeterNumberReading.swift index 54d995952..30e3117d4 100644 --- a/ios/HouseScan/Runtime/MeterNumberReading.swift +++ b/ios/HouseScan/Runtime/MeterNumberReading.swift @@ -18,6 +18,10 @@ struct MeterReadout: Sendable, Equatable { /// The photo decoded and passed the focus check, whether or not a number was read in it. Only /// such a photo's view goes into coverage (`CloseUpCredit`). var photoPassedChecks: Bool + /// The maker named on the meter (`MeterBrand.read`), or nil when no line names one. Shown + /// beside the candidates; it counts only when the homeowner confirms a number without + /// rejecting it. + var brand: String? = nil } /// Reads the meter number from the close-up JPEG. Runs off the main actor. diff --git a/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift index f2c060c5d..6a1c9c495 100644 --- a/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift +++ b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift @@ -66,7 +66,8 @@ struct VisionMeterNumberReader: MeterNumberReader { }, retake: nil, numberTooSmall: choices.numberTooSmall, - photoPassedChecks: true + photoPassedChecks: true, + brand: MeterBrand.read(lines) ) } diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 1d7453e1a..845c7535f 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -82,11 +82,17 @@ extension ScanEngine: ScanActions { } guard let chosen = candidates.first(where: { $0.id == candidate.id }) else { return } state.meterNumber = .confirmed(chosen.text) - RuntimeLog.engine.info("meter number confirmed (\(chosen.barcodeConfirmed ? "barcode-confirmed" : "text only", privacy: .public))") + RuntimeLog.engine.info("meter number confirmed (\(chosen.barcodeConfirmed ? "barcode-confirmed" : "text only", privacy: .public)), brand \(self.state.meterBrand == nil ? "none" : "kept", privacy: .public)") observeCloseUpView() finishCloseUp() } + func rejectMeterBrand() { + guard state.phase == .meterCloseUp, case .choose = state.meterNumber else { return } + state.meterBrand = nil + RuntimeLog.engine.info("meter brand rejected") + } + /// Puts the close-up photo's view into coverage, under the same rules as a walk keyframe: a /// cell counts once seen from two positions at least 0.25 m apart, and the view is replayed /// with the kept keyframes whenever the wall moves (`CoverageMap.observedCameras`). diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index feeb3e252..860155040 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -531,6 +531,7 @@ final class ScanEngine { } RuntimeLog.engine.info("meter number: \(readout.candidates.count) candidates to choose from") meterReadout = readout + state.meterBrand = readout.brand state.meterNumber = .choose(readout.candidates) } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index cc4e82396..d806b34c4 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -142,6 +142,9 @@ enum ScanCopy { } static let barcodeMatch = "Matches the barcode" + /// The maker read on the close-up, above the number candidates. + static func meterBrand(_ brand: String) -> String { "\(brand) meter" } + static func notMeterBrand(_ brand: String) -> String { "Not \(brand)" } static let noneOfThese = "None of these" // MARK: Features diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 2ef5cdc1b..abf86810b 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -157,6 +157,7 @@ final class DemoEngine: ScanActions { } if arguments.contains("-uiDemoMeterChoose") { state.closeUp = .captured(DemoScene.meterThumbnail) + state.meterBrand = Self.demoBrand state.meterNumber = .choose(Self.demoCandidates) } // Frozen, the upload script never runs, so show where it would end. @@ -377,6 +378,7 @@ final class DemoEngine: ScanActions { state.meterNumber = .reading guard await pause(1.2) else { return } // Waits here for the homeowner's pick (`chooseMeterNumber`). + state.meterBrand = Self.demoBrand state.meterNumber = .choose(Self.demoCandidates) } @@ -641,6 +643,11 @@ final class DemoEngine: ScanActions { enterWalk() } + func rejectMeterBrand() { + guard case .choose = state.meterNumber else { return } + state.meterBrand = nil + } + func chooseMeterNumber(_ candidate: MeterNumberCandidate?) { guard case .choose = state.meterNumber else { return } guard let candidate else { @@ -861,6 +868,9 @@ final class DemoEngine: ScanActions { // MARK: Sample data + /// The made-up meter's maker, as `MeterBrand.read` would name it. + static let demoBrand = "Itron" + /// Made-up readings of a made-up meter: the barcode-confirmed one first, then two near /// misses the way a reader confuses 8 with 6 and 3 with 8. static let demoCandidates = [ diff --git a/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift b/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift index 90acf0552..e4526e98d 100644 --- a/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift +++ b/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift @@ -42,8 +42,13 @@ struct MeterCloseUpScreen: View { ) { VStack(spacing: 12) { if case .choose(let candidates) = state.meterNumber { - MeterNumberPicker(candidates: candidates, choose: actions.chooseMeterNumber) - .transition(reduceMotion ? .opacity : .opacity.combined(with: .offset(y: 12))) + MeterNumberPicker( + candidates: candidates, + brand: state.meterBrand, + choose: actions.chooseMeterNumber, + rejectBrand: actions.rejectMeterBrand + ) + .transition(reduceMotion ? .opacity : .opacity.combined(with: .offset(y: 12))) } else if state.meterNumber == .reading { ProgressView() .controlSize(.large) @@ -193,15 +198,45 @@ struct MeterCloseUpScreen: View { } } -/// "Which number is on your meter?": up to three readings as full-width answers, then "None of -/// these". The numbers are set in a monospaced face so similar readings line up character by -/// character (8 against B, 0 against O) and the homeowner can compare them with the meter. +/// "Which number is on your meter?": the maker when one was read, up to three readings as +/// full-width answers, then "None of these". The numbers are set in a monospaced face so similar +/// readings line up character by character (8 against B, 0 against O) and the homeowner can +/// compare them with the meter. The maker is never taken silently: it sits above the numbers +/// with "Not ", and counts only with the number the homeowner taps. private struct MeterNumberPicker: View { var candidates: [MeterNumberCandidate] + var brand: String? var choose: (MeterNumberCandidate?) -> Void + var rejectBrand: () -> Void var body: some View { VStack(spacing: 8) { + if let brand { + // A chip, not a full-width answer: it states what was read, and the one thing to + // do with it is say it's wrong. Caption size keeps it below the numbers in rank. + HStack(spacing: 10) { + Text(ScanCopy.meterBrand(brand)) + .font(Typeface.caption) + .foregroundStyle(Palette.chalk) + Button(action: rejectBrand) { + Text(ScanCopy.notMeterBrand(brand)) + .font(Typeface.caption) + .underline() + .foregroundStyle(Palette.chalk.opacity(0.8)) + // 44 pt both ways: "Not GE" in caption type is narrower than that. + .frame(minWidth: 44, minHeight: 44) + .contentShape(.rect) + } + .buttonStyle(PressableStyle()) + .accessibilityHint("Leaves the brand out.") + .accessibilityIdentifier("action.rejectMeterBrand") + } + .padding(.horizontal, 16) + .background(ScrimShape.capsule) + .transition(.opacity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("meter.brand") + } ForEach(Array(candidates.prefix(3).enumerated()), id: \.element.id) { index, candidate in Button { choose(candidate) @@ -234,5 +269,7 @@ private struct MeterNumberPicker: View { .accessibilityHint("Takes another photo of the meter.") .accessibilityIdentifier("action.noneOfThese") } + // "Not " fades the chip out, and the numbers close the gap in the same 0.2 s. + .animation(Motion.text, value: brand) } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Meter/MeterBrand.swift b/ios/HouseScanKit/Sources/HouseScanKit/Meter/MeterBrand.swift new file mode 100644 index 000000000..1e5848386 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Meter/MeterBrand.swift @@ -0,0 +1,131 @@ +import Foundation + +// Names the meter's maker from the lines Vision read on the close-up. A port of the autodetect +// lab's brands.py and its pick rule (experiments/autodetect/meter_brand/ on t3/autodetect-lab at +// 538f60e): on #16's photos it named the maker correctly on 17 of 19 where the name is printed in +// plain letters and 22 of 42 where it is part of a logo, named none on the 9 without a name, and +// once named the wrong one, on a plate printed with two makers' names. + +public enum MeterBrand { + /// A maker, as shown to the homeowner, and its spellings as printed on meters. Each printed + /// name is its own brand, even where one company later bought another (Elster and Honeywell, + /// Actaris and Itron), because the phone reports what is printed. A spelling of 4 characters + /// or fewer must be a whole token; one of 6 letters or more may be one edit away. + public static let makers: [(name: String, spellings: [String])] = [ + ("Itron", ["ITRON"]), + ("Actaris", ["ACTARIS"]), + ("Schlumberger", ["SCHLUMBERGER"]), + ("Landis+Gyr", ["LANDIS+GYR", "LANDIS GYR", "LANDIS & GYR", "LANDIS&GYR", "L+G", "LANDIS"]), + ("GE", ["GE", "GENERAL ELECTRIC"]), + ("Aclara", ["ACLARA"]), + ("Elster", ["ELSTER"]), + ("Honeywell", ["HONEYWELL"]), + ("ABB", ["ABB"]), + ("Sensus", ["SENSUS"]), + ("Sangamo", ["SANGAMO"]), + ("Westinghouse", ["WESTINGHOUSE"]), + ("Duncan", ["DUNCAN"]), + ("Siemens", ["SIEMENS"]), + ("AEG", ["AEG"]), + ("Iskra", ["ISKRA", "ISKRAEMECO"]), + ("EMH", ["EMH"]), + ("DZG", ["DZG"]), + ("Kamstrup", ["KAMSTRUP"]), + ("EDMI", ["EDMI"]), + ("ZIV", ["ZIV"]), + ("Sagemcom", ["SAGEMCOM", "SAGEM"]), + ("Hexing", ["HEXING"]), + ("Holley", ["HOLLEY"]), + ("Genus", ["GENUS"]), + ("Secure", ["SECURE METERS"]), + ("Toshiba", ["TOSHIBA"]), + ("Mitsubishi", ["MITSUBISHI"]), + ("Osaki", ["OSAKI"]), + ("Tatung", ["TATUNG"]), + ("Chung-Hsin", ["CHUNGHSIN", "CHUNG HSIN", "CHUNG-HSIN"]), + ("CGE", ["CGE"]), + ("Enermet", ["ENERMET"]), + ("Ampy", ["AMPY"]), + ("Logarex", ["LOGAREX"]), + ("Echelon", ["ECHELON"]), + ] + + /// Every spelling with its maker, longest first, so `LANDIS & GYR` is tried before `LANDIS` + /// and `GENERAL ELECTRIC` before `GE`. Equal lengths keep list order, as Python's stable sort. + private static let spellings: [(spelling: String, maker: String)] = makers + .flatMap { maker in maker.spellings.map { (spelling: $0, maker: maker.name) } } + .enumerated() + .sorted { $0.element.spelling.count != $1.element.spelling.count ? $0.element.spelling.count > $1.element.spelling.count : $0.offset < $1.offset } + .map(\.element) + + /// The maker to show for the close-up: the one named on the tallest line that names any, or + /// nil. Equal heights go to the maker whose name sorts last, as brands.py's `max` over + /// (height, name) does. + public static func read(_ lines: [MeterTextLine]) -> String? { + lines.compactMap { line in match(line.text).map { (height: line.box.height, maker: $0) } } + .max { ($0.height, $0.maker.uppercased()) < ($1.height, $1.maker.uppercased()) }? + .maker + } + + /// The maker one recognized line names, or nil. + /// + /// Long spellings match as a substring of the line, or one edit from a token or a pair of + /// adjacent tokens (Vision drops or swaps a letter, or splits `LANDIS+GYR`). Short spellings + /// such as `GE` must be a whole token, because they occur inside unrelated words. The edit + /// needs 6 letters: at 5, `GENUS` is one edit from `GE US`. + public static func match(_ line: String) -> String? { + let norm = normalized(line) + guard !norm.isEmpty else { return nil } + let tokens = norm.split(separator: " ").map(String.init) + let pairs = zip(tokens, tokens.dropFirst()) + let windows = tokens + pairs.map { $0 + $1 } + pairs.map { $0 + " " + $1 } + let squeezed = norm.replacingOccurrences(of: " ", with: "") + for (spelling, maker) in spellings { + if spelling.count <= 4 { + if tokens.contains(spelling) { return maker } + continue + } + let bare = spelling.replacingOccurrences(of: " ", with: "") + if (" " + norm + " ").contains(" " + spelling + " ") || squeezed.contains(bare) { return maker } + if bare.count >= 6, + windows.contains(where: { withinOneEdit(bare, $0.replacingOccurrences(of: " ", with: "")) }) { + return maker + } + } + return nil + } + + /// Accents folded to plain letters (`Itrón` reads as ITRON) and other non-ASCII dropped, upper + /// case, `&` and `+` kept because they are part of names, other characters to spaces, runs of + /// spaces collapsed. + static func normalized(_ text: String) -> String { + let ascii = String(String.UnicodeScalarView(text.decomposedStringWithCompatibilityMapping.unicodeScalars.filter(\.isASCII))) + let kept = ascii.uppercased().map { character -> Character in + character.isASCII && (character.isUppercase || character.isNumber || character == "+" || character == "&") ? character : " " + } + return String(kept).split(separator: " ").joined(separator: " ") + } + + static func withinOneEdit(_ a: String, _ b: String) -> Bool { + if a == b { return true } + var short = Array(a) + var long = Array(b) + guard abs(short.count - long.count) <= 1 else { return false } + if short.count > long.count { swap(&short, &long) } + var i = 0 + var j = 0 + var edited = false + while i < short.count, j < long.count { + if short[i] == long[j] { + i += 1 + j += 1 + continue + } + if edited { return false } + edited = true + if short.count == long.count { i += 1 } + j += 1 + } + return true + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/MeterBrandTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/MeterBrandTests.swift new file mode 100644 index 000000000..0b95691ab --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/MeterBrandTests.swift @@ -0,0 +1,72 @@ +import Testing +@testable import HouseScanKit + +@Suite struct MeterBrandTests { + private func line(_ text: String, height: Double) -> MeterTextLine { + MeterTextLine(text: text, box: MeterBox(x: 0.1, y: 0.1, width: 0.5, height: height)) + } + + @Test(arguments: [ + ("Landis+Gyr", "Landis+Gyr"), + ("LANDIS + GYR", "Landis+Gyr"), + ("Landis & Gyr AG", "Landis+Gyr"), + ("L+G", "Landis+Gyr"), + ("ITRON", "Itron"), + ("Itrón", "Itron"), // accent folded + ("TATUNG CO.", "Tatung"), + ("Tatunq", "Tatung"), // one edit + ("Iskraemeco", "Iskra"), + ("GE", "GE"), + ("GE (US)", "GE"), // the token GE, not GENUS one edit from "GEUS" + ("CANADIAN GENERAL ELECTRIC", "GE"), + ("Elster", "Elster"), + ("ABB Alpha", "ABB"), + ("Aclaro", "Aclara"), // one edit at 6 letters + ("CHUNG HSIN ELECTRIC", "Chung-Hsin"), + ]) + func namesTheMaker(text: String, maker: String) { + #expect(MeterBrand.match(text) == maker) + } + + @Test(arguments: [ + "ltron", // ITRON has 5 letters, so no edit is allowed + "AGE 5", // GE inside a word is not GE + "GENERAL", + "KILOWATTHOURS", + "CL200 240V 3W", + "", + "大同", + ]) + func namesNothing(text: String) { + #expect(MeterBrand.match(text) == nil) + } + + @Test func readsTheMakerOnTheTallestLineThatNamesOne() { + let lines = [ + line("KILOWATTHOURS", height: 0.09), + line("Aclara", height: 0.02), + line("LANDIS & GYR", height: 0.03), + ] + #expect(MeterBrand.read(lines) == "Landis+Gyr") + } + + @Test func readsNothingWhenNoLineNamesAMaker() { + #expect(MeterBrand.read([line("CL200", height: 0.05), line("240V", height: 0.04)]) == nil) + #expect(MeterBrand.read([]) == nil) + } + + @Test func oneEditMeansOneInsertionDeletionOrSubstitution() { + #expect(MeterBrand.withinOneEdit("TATUNG", "TATUNG")) + #expect(MeterBrand.withinOneEdit("TATUNG", "TATUNQ")) + #expect(MeterBrand.withinOneEdit("TATUNG", "TATNG")) + #expect(MeterBrand.withinOneEdit("TATUNG", "TATUNGS")) + #expect(!MeterBrand.withinOneEdit("TATUNG", "TATNQ")) + #expect(!MeterBrand.withinOneEdit("TATUNG", "TA")) + } + + @Test func normalizesLikeTheEval() { + #expect(MeterBrand.normalized(" Landis+Gyr, Inc. ") == "LANDIS+GYR INC") + #expect(MeterBrand.normalized("Itrón") == "ITRON") + #expect(MeterBrand.normalized("中興 Chung-Hsin") == "CHUNG HSIN") + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 22ea26c38..3e78188b2 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -96,6 +96,20 @@ final class ScreenStatesUITests: XCTestCase { } } + /// The brand read on the close-up is only offered: "Not " removes it and leaves the + /// number candidates to answer. + @MainActor + func testRejectingTheMeterBrandKeepsTheNumbers() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "meterCloseUp", "-uiDemoMeterChoose"] + app.launch() + XCTAssertTrue(element(app, "meter.brand").waitForExistence(timeout: 15)) + tap(app, "action.rejectMeterBrand") + XCTAssertTrue(element(app, "meter.brand").waitForNonExistence(timeout: 5)) + XCTAssertTrue(element(app, "meter.candidate.0").exists) + } + /// The homeowner's path through the real buttons and camera taps, not the autopilot. @MainActor func testWholeFlowThroughTheButtons() throws {