From 97b883b077105ecf163e9a216e86187150fbf6b1 Mon Sep 17 00:00:00 2001 From: Facebook GitHub Bot Date: Tue, 7 Apr 2026 08:52:29 -0700 Subject: [PATCH] Re-sync with internal repository The internal and external repositories are out of sync. This Pull Request attempts to brings them back in sync by patching the GitHub repository. Please carefully review this patch. You must disable ShipIt for your project in order to merge this pull request. DO NOT IMPORT this pull request. Instead, merge it directly on GitHub using the MERGE BUTTON. Re-enable ShipIt after merging. fbshipit-source-id: 276c1f04c2b080d1b8417b29237989dbcc3f636e --- caliper/caliper.go | 244 +++++++++++++++++ caliper/caliper_test.go | 569 ++++++++++++++++++++++++++++++++++++++++ caliper/peaks.go | 332 +++++++++++++++++++++++ caliper/svg.go | 302 +++++++++++++++++++++ caliper/tor.go | 276 +++++++++++++++++++ 5 files changed, 1723 insertions(+) create mode 100644 caliper/caliper.go create mode 100644 caliper/caliper_test.go create mode 100644 caliper/peaks.go create mode 100644 caliper/svg.go create mode 100644 caliper/tor.go diff --git a/caliper/caliper.go b/caliper/caliper.go new file mode 100644 index 00000000..c24685c2 --- /dev/null +++ b/caliper/caliper.go @@ -0,0 +1,244 @@ +/* +Copyright (c) Facebook, Inc. and its affiliates. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package caliper + +import ( + "fmt" + "math" +) + +// AntennaGen represents antenna generation and phase +type AntennaGen string + +const ( + Gen2Phase0 AntennaGen = "gen2-p0" + Gen2Phase1 AntennaGen = "gen2-p1" + Gen2Phase2 AntennaGen = "gen2-p2" + Gen2aPhase2 AntennaGen = "gen2a-p2" +) + +// AntennaElectricalDelayNs returns the electrical delay in nanoseconds for a given antenna generation +func AntennaElectricalDelayNs(gen AntennaGen) (float64, error) { + switch gen { + case Gen2Phase0, Gen2Phase1: + return 20.5, nil + case Gen2Phase2, Gen2aPhase2: + return 39.3, nil + default: + return 0, fmt.Errorf("unknown antenna generation: %q", gen) + } +} + +// ReceiverModel represents the Huber-Suhner GNSS receiver model +type ReceiverModel string + +const ( + GNSSoF16RxE ReceiverModel = "GNSSoF16-RxE" + GNSSPoF164RxE ReceiverModel = "GNSSPoF16-4RxE" +) + +// SMAPortOffsetNs returns the delay in nanoseconds between the FO Out port +// (where OA is measured) and the SMA port (where downstream devices connect). +func SMAPortOffsetNs(model ReceiverModel) (float64, error) { + switch model { + case GNSSoF16RxE: + return 2.0, nil + case GNSSPoF164RxE: + return 5.0, nil + default: + return 0, fmt.Errorf("unknown receiver model: %q", model) + } +} + +// PeakDescription returns the descriptions for OA/OB/OC/OD based on receiver model +func PeakDescription(model ReceiverModel) map[string]string { + switch model { + case GNSSPoF164RxE: + return map[string]string{ + "OA": "FO Out", + "OB": "FO In (LC connector)", + "OC": "FC APC connector (antenna)", + "OD": "Antenna optical isolator", + } + default: // GNSSoF16-RxE + return map[string]string{ + "OA": "FO Out (front port)", + "OB": "Q-ODC-12 connector (back of unit)", + "OC": "Q-ODC-12 connector (antenna)", + "OD": "Antenna optical isolator", + } + } +} + +// TracePoint is a single data point in the OTDR trace for the report +type TracePoint struct { + TimeNs float64 `json:"time_ns"` + AmplitudeDB float64 `json:"amplitude_db"` +} + +// MeasurementResult is the detailed per-device JSON output +type MeasurementResult struct { + DeviceName string `json:"device_name"` + SerialNumber string `json:"serial_number"` + Model ReceiverModel `json:"model"` + AntennaGen AntennaGen `json:"antenna_gen"` + TORFile string `json:"tor_file"` + DateTime string `json:"date_time"` + Settings TORSettings `json:"settings"` + Peaks []PeakResult `json:"peaks"` + Delays DelayResult `json:"delays"` + Trace []TracePoint `json:"trace"` +} + +// TORSettings captures the relevant OTDR measurement settings +type TORSettings struct { + Wavelength int `json:"wavelength_nm"` + PulseWidth int `json:"pulse_width"` + RefractiveIndex float64 `json:"refractive_index"` + Resolution float64 `json:"resolution_m"` + Start float64 `json:"start_m"` + End float64 `json:"end_m"` + BackscatterCoefficient float64 `json:"backscatter_coefficient"` + Average int `json:"average"` + LaunchCableLengthM float64 `json:"launch_cable_length_m"` +} + +// PeakResult captures a single detected peak +type PeakResult struct { + Label string `json:"label"` + Description string `json:"description"` + DistanceM float64 `json:"distance_m"` + AmplitudeDB float64 `json:"amplitude_db"` + TimeNs float64 `json:"time_ns"` +} + +// CoaxDelayNsPerM is the propagation delay of RG58 coaxial cable in ns/m +const CoaxDelayNsPerM = 5.05 + +// DelayResult captures the computed delays between peaks +type DelayResult struct { + SMAPortOffsetNs float64 `json:"sma_port_offset_ns"` + RxDelayNs float64 `json:"rx_delay_ns"` + CableDelayNs float64 `json:"cable_delay_ns"` + AntennaOpticalDelayNs float64 `json:"antenna_optical_delay_ns"` + AntennaElectricalDelayNs float64 `json:"antenna_electrical_delay_ns"` + TotalDelayNs float64 `json:"total_delay_ns"` + CoaxCableLengthM float64 `json:"coax_cable_length_m"` + CoaxCableDelayNs float64 `json:"coax_cable_delay_ns"` + EndToEndDelayNs float64 `json:"end_to_end_delay_ns"` +} + +// ComputeResult computes the full measurement result from parsed TOR data and peaks. +func ComputeResult( + tor *TORFile, + peaks []Peak, + name, serial string, + model ReceiverModel, + antennaGen AntennaGen, + coaxCableLengthM, launchCableLengthM float64, +) (*MeasurementResult, error) { + if len(peaks) < 4 { + return nil, fmt.Errorf("expected 4 peaks (OA, OB, OC, OD) but got %d", len(peaks)) + } + for _, label := range []string{"OA", "OB", "OC", "OD"} { + found := false + for _, p := range peaks { + if p.Label == label { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("missing expected peak %s", label) + } + } + + elecDelay, err := AntennaElectricalDelayNs(antennaGen) + if err != nil { + return nil, err + } + + smaOffset, err := SMAPortOffsetNs(model) + if err != nil { + return nil, err + } + + descriptions := PeakDescription(model) + + peakResults := make([]PeakResult, len(peaks)) + for i, p := range peaks { + peakResults[i] = PeakResult{ + Label: p.Label, + Description: descriptions[p.Label], + DistanceM: p.DistanceM, + AmplitudeDB: p.AmplitudeDB, + TimeNs: p.TimeNs, + } + } + + peakByLabel := make(map[string]Peak, len(peaks)) + for _, p := range peaks { + peakByLabel[p.Label] = p + } + oa, ob, oc, od := peakByLabel["OA"], peakByLabel["OB"], peakByLabel["OC"], peakByLabel["OD"] + + coaxDelay := CoaxDelayNsPerM * coaxCableLengthM + delays := DelayResult{ + SMAPortOffsetNs: smaOffset, + RxDelayNs: ob.TimeNs - oa.TimeNs, + CableDelayNs: oc.TimeNs - ob.TimeNs, + AntennaOpticalDelayNs: od.TimeNs - oc.TimeNs, + AntennaElectricalDelayNs: elecDelay, + CoaxCableLengthM: coaxCableLengthM, + CoaxCableDelayNs: coaxDelay, + } + delays.TotalDelayNs = delays.SMAPortOffsetNs + delays.RxDelayNs + delays.CableDelayNs + + delays.AntennaOpticalDelayNs + delays.AntennaElectricalDelayNs + delays.EndToEndDelayNs = delays.TotalDelayNs + delays.CoaxCableDelayNs + + ri := tor.RefractiveIndex + trace := make([]TracePoint, len(tor.DataPoints)) + for i, dp := range tor.DataPoints { + trace[i] = TracePoint{ + TimeNs: math.Round(dp.TimeNs(ri)*1000) / 1000, + AmplitudeDB: dp.AmplitudeDB, + } + } + + return &MeasurementResult{ + DeviceName: name, + SerialNumber: serial, + Model: model, + AntennaGen: antennaGen, + TORFile: tor.DateTime.Format("2006-01-02") + "_" + name + ".tor", + DateTime: tor.DateTime.Format("2006-01-02T15:04:05Z"), + Settings: TORSettings{ + Wavelength: tor.Wavelength, + PulseWidth: tor.PulseWidth, + RefractiveIndex: tor.RefractiveIndex, + Resolution: tor.Resolution, + Start: tor.Start, + End: tor.End, + BackscatterCoefficient: tor.BackscatterCoefficient, + Average: tor.Average, + LaunchCableLengthM: launchCableLengthM, + }, + Peaks: peakResults, + Delays: delays, + Trace: trace, + }, nil +} diff --git a/caliper/caliper_test.go b/caliper/caliper_test.go new file mode 100644 index 00000000..5ffdaffb --- /dev/null +++ b/caliper/caliper_test.go @@ -0,0 +1,569 @@ +/* +Copyright (c) Facebook, Inc. and its affiliates. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package caliper + +import ( + "bytes" + "encoding/json" + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildTestTOR creates a synthetic TOR file with 4 clear reflective peaks. +func buildTestTOR() *TORFile { + ri := 1.4682 + n := 2000 + + peakPositions := []float64{0.5, 3.0, 25.0, 26.5} + peakAmplitudes := []float64{15.0, 8.0, 10.0, 5.0} + + points := make([]DataPoint, n) + for i := range points { + dist := float64(i) * 0.05 + amp := -30.0 - dist*0.2 + + for j, pos := range peakPositions { + d := dist - pos + if math.Abs(d) < 0.3 { + amp += peakAmplitudes[j] * math.Exp(-d*d/(2*0.01)) + } + } + points[i] = DataPoint{DistanceM: dist, AmplitudeDB: amp} + } + + return &TORFile{ + DateTime: time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC), + InstrumentInfo: "1310 nm - 1 ns -", + Wavelength: 1310, + PulseWidth: 1, + RefractiveIndex: ri, + Resolution: 0.05, + Start: 0, + End: 100, + Average: 30000, + BackscatterCoefficient: -81.0, + DataPoints: points, + } +} + +func TestParseTOR(t *testing.T) { + content := strings.Join([]string{ + "Some header line", + "[DateTime]", + "1742036400", + "[-]", + "[InstrumentInfo]", + "1310 nm - 1 ns -", + "[-]", + "[Wavelength]", + "0", + "[-]", + "[PulseWidth]", + "1", + "[-]", + "[RefractiveIndex]", + "1.4682", + "[-]", + "[Resolution]", + "0.05", + "[-]", + "[Start]", + "0", + "[-]", + "[End]", + "100", + "[-]", + "[Average]", + "30000", + "[-]", + "[BackscatterCoefficient]", + "-81.0", + "[-]", + "[DataPoints]", + "0.00\t-30.00", + "0.05\t-30.01", + "0.10\t-30.02", + "[-]", + }, "\n") + + dir := t.TempDir() + path := filepath.Join(dir, "test.tor") + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + + tor, err := ParseTOR(path) + require.NoError(t, err) + + assert.Equal(t, 1310, tor.Wavelength, "wavelength should be extracted from InstrumentInfo when field is 0") + assert.Equal(t, 1.4682, tor.RefractiveIndex) + assert.Equal(t, 1, tor.PulseWidth) + assert.Len(t, tor.DataPoints, 3) + assert.InDelta(t, 0.05, tor.DataPoints[1].DistanceM, 0.001) + assert.InDelta(t, -30.01, tor.DataPoints[1].AmplitudeDB, 0.001) +} + +func TestParseTORReader(t *testing.T) { + content := strings.Join([]string{ + "[DateTime]", + "1742036400", + "[-]", + "[InstrumentInfo]", + "1550 nm", + "OTDR module S/N: ABC123", + "[-]", + "[Wavelength]", + "1550", + "[-]", + "[RefractiveIndex]", + "1.4680", + "[-]", + "[DataPoints]", + "0.00\t-25.00", + "0.10\t-25.50", + "[-]", + }, "\n") + + tor, err := ParseTORReader(strings.NewReader(content)) + require.NoError(t, err) + + assert.Equal(t, 1550, tor.Wavelength) + assert.Equal(t, "ABC123", tor.ModuleSerialNumber) + assert.Equal(t, 1.468, tor.RefractiveIndex) + assert.Len(t, tor.DataPoints, 2) +} + +func TestParseTORNoDataPoints(t *testing.T) { + content := "[DateTime]\n1742036400\n[-]\n[RefractiveIndex]\n1.4682\n[-]\n" + _, err := ParseTORReader(strings.NewReader(content)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no data points") +} + +func TestParseTORFileNotFound(t *testing.T) { + _, err := ParseTOR("/nonexistent/path/test.tor") + assert.Error(t, err) +} + +func TestParseWavelengthFromInfo(t *testing.T) { + tests := []struct { + input string + expected int + ok bool + }{ + {"1310 nm - 1 ns -", 1310, true}, + {"1550 nm", 1550, true}, + {"850nm", 850, true}, + {"no wavelength here", 0, false}, + {"", 0, false}, + {"abc nm", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + wl, ok := parseWavelengthFromInfo(tt.input) + assert.Equal(t, tt.ok, ok) + if ok { + assert.Equal(t, tt.expected, wl) + } + }) + } +} + +func TestDataPointTimeNs(t *testing.T) { + dp := DataPoint{DistanceM: 100.0, AmplitudeDB: -30.0} + ri := 1.4682 + + expected := 100.0 * ri / SpeedOfLight * 1e9 + assert.InDelta(t, expected, dp.TimeNs(ri), 0.0001) +} + +func TestDetectPeaks(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + require.Len(t, peaks, 4) + + for i, label := range []string{"OA", "OB", "OC", "OD"} { + assert.Equal(t, label, peaks[i].Label) + } + + expectedDists := []float64{0.5, 3.0, 25.0, 26.5} + for i, p := range peaks { + assert.InDelta(t, expectedDists[i], p.DistanceM, 0.15, "peak %s distance", p.Label) + } + + ri := tor.RefractiveIndex + for _, p := range peaks { + expectedTime := p.DistanceM * ri / SpeedOfLight * 1e9 + assert.InDelta(t, expectedTime, p.TimeNs, 0.001) + } +} + +func TestDetectPeaksTapSplitter(t *testing.T) { + ri := 1.4682 + n := 2000 + + peakPositions := []float64{0.5, 1.0, 3.0, 25.0, 26.5} + peakAmplitudes := []float64{15.0, 12.0, 8.0, 10.0, 5.0} + + points := make([]DataPoint, n) + for i := range points { + dist := float64(i) * 0.05 + amp := -30.0 - dist*0.2 + for j, pos := range peakPositions { + d := dist - pos + if math.Abs(d) < 0.3 { + amp += peakAmplitudes[j] * math.Exp(-d*d/(2*0.01)) + } + } + points[i] = DataPoint{DistanceM: dist, AmplitudeDB: amp} + } + + tor := &TORFile{ + RefractiveIndex: ri, + DataPoints: points, + } + + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + require.Len(t, peaks, 4) + + assert.Equal(t, "OA", peaks[0].Label) + assert.InDelta(t, 0.5, peaks[0].DistanceM, 0.15) + assert.Equal(t, "OB", peaks[1].Label) + assert.InDelta(t, 3.0, peaks[1].DistanceM, 0.15) + assert.Equal(t, "OC", peaks[2].Label) + assert.Equal(t, "OD", peaks[3].Label) +} + +func TestDetectPeaksLaunchCableFilter(t *testing.T) { + ri := 1.4682 + n := 2000 + + // Place a peak at 2.0m (within a 3m launch cable), then real peaks at 4.0, 7.0, 28.0, 29.5 + peakPositions := []float64{2.0, 4.0, 7.0, 28.0, 29.5} + peakAmplitudes := []float64{15.0, 15.0, 8.0, 10.0, 5.0} + + points := make([]DataPoint, n) + for i := range points { + dist := float64(i) * 0.05 + amp := -30.0 - dist*0.2 + for j, pos := range peakPositions { + d := dist - pos + if math.Abs(d) < 0.3 { + amp += peakAmplitudes[j] * math.Exp(-d*d/(2*0.01)) + } + } + points[i] = DataPoint{DistanceM: dist, AmplitudeDB: amp} + } + + tor := &TORFile{RefractiveIndex: ri, DataPoints: points} + + // With launch cable = 3m, the peak at 2.0m should be filtered + peaks, err := DetectPeaks(tor, 3.0) + require.NoError(t, err) + require.Len(t, peaks, 4) + assert.Equal(t, "OA", peaks[0].Label) + assert.InDelta(t, 4.0, peaks[0].DistanceM, 0.15, "OA should be at 4.0m, not 2.0m") +} + +func TestDetectPeaksInsufficientData(t *testing.T) { + tor := &TORFile{ + RefractiveIndex: 1.4682, + DataPoints: make([]DataPoint, 5), + } + _, err := DetectPeaks(tor, 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "insufficient data points") +} + +func TestComputeResult(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + result, err := ComputeResult(tor, peaks, "gnss01.cln1", "PF000142", GNSSoF16RxE, Gen2Phase2, 1.5, 3.0) + require.NoError(t, err) + + assert.Equal(t, "gnss01.cln1", result.DeviceName) + assert.Equal(t, "PF000142", result.SerialNumber) + assert.Equal(t, GNSSoF16RxE, result.Model) + assert.Equal(t, Gen2Phase2, result.AntennaGen) + + assert.Equal(t, 2.0, result.Delays.SMAPortOffsetNs) + assert.Equal(t, 39.3, result.Delays.AntennaElectricalDelayNs) + assert.Greater(t, result.Delays.RxDelayNs, 0.0) + assert.Greater(t, result.Delays.CableDelayNs, result.Delays.RxDelayNs) + + assert.InDelta(t, 1.5*CoaxDelayNsPerM, result.Delays.CoaxCableDelayNs, 0.001) + assert.Equal(t, 1.5, result.Delays.CoaxCableLengthM) + + expectedTotal := result.Delays.SMAPortOffsetNs + + result.Delays.RxDelayNs + + result.Delays.CableDelayNs + + result.Delays.AntennaOpticalDelayNs + + result.Delays.AntennaElectricalDelayNs + assert.InDelta(t, expectedTotal, result.Delays.TotalDelayNs, 0.001) + assert.InDelta(t, result.Delays.TotalDelayNs+result.Delays.CoaxCableDelayNs, + result.Delays.EndToEndDelayNs, 0.001) + + assert.Len(t, result.Trace, len(tor.DataPoints)) + + assert.Equal(t, 1310, result.Settings.Wavelength) + assert.Equal(t, tor.RefractiveIndex, result.Settings.RefractiveIndex) + assert.Equal(t, 3.0, result.Settings.LaunchCableLengthM) +} + +func TestComputeResultModels(t *testing.T) { + tests := []struct { + model ReceiverModel + expectedSMANs float64 + antennaGen AntennaGen + expectedElecNs float64 + }{ + {GNSSoF16RxE, 2.0, Gen2Phase0, 20.5}, + {GNSSoF16RxE, 2.0, Gen2Phase1, 20.5}, + {GNSSoF16RxE, 2.0, Gen2Phase2, 39.3}, + {GNSSPoF164RxE, 5.0, Gen2aPhase2, 39.3}, + } + + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + for _, tt := range tests { + t.Run(string(tt.model)+"_"+string(tt.antennaGen), func(t *testing.T) { + result, err := ComputeResult(tor, peaks, "test", "PF000001", tt.model, tt.antennaGen, 0, 0) + require.NoError(t, err) + assert.Equal(t, tt.expectedSMANs, result.Delays.SMAPortOffsetNs) + assert.Equal(t, tt.expectedElecNs, result.Delays.AntennaElectricalDelayNs) + }) + } +} + +func TestComputeResultMissingPeaks(t *testing.T) { + tor := buildTestTOR() + peaks := []Peak{ + {Label: "OA", TimeNs: 1.0}, + {Label: "OB", TimeNs: 2.0}, + {Label: "OC", TimeNs: 3.0}, + } + _, err := ComputeResult(tor, peaks, "test", "PF000001", GNSSoF16RxE, Gen2Phase2, 0, 0) + assert.Error(t, err) +} + +func TestComputeResultZeroCoax(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + result, err := ComputeResult(tor, peaks, "test", "PF000001", GNSSoF16RxE, Gen2Phase2, 0, 0) + require.NoError(t, err) + + assert.Equal(t, 0.0, result.Delays.CoaxCableLengthM) + assert.Equal(t, 0.0, result.Delays.CoaxCableDelayNs) + assert.Equal(t, result.Delays.TotalDelayNs, result.Delays.EndToEndDelayNs) +} + +func TestComputeResultUnknownModel(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + _, err = ComputeResult(tor, peaks, "test", "PF000001", "UnknownModel", Gen2Phase2, 0, 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown receiver model") +} + +func TestComputeResultUnknownAntennaGen(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + _, err = ComputeResult(tor, peaks, "test", "PF000001", GNSSoF16RxE, "unknown-gen", 0, 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown antenna generation") +} + +func TestComputeResultJSONRoundTrip(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + result, err := ComputeResult(tor, peaks, "gnss01.cln1", "PF000142", GNSSoF16RxE, Gen2Phase2, 1.5, 3.0) + require.NoError(t, err) + + data, err := json.MarshalIndent(result, "", " ") + require.NoError(t, err) + + var decoded MeasurementResult + require.NoError(t, json.Unmarshal(data, &decoded)) + + assert.Equal(t, result.DeviceName, decoded.DeviceName) + assert.Equal(t, result.SerialNumber, decoded.SerialNumber) + assert.Equal(t, result.Model, decoded.Model) + assert.Equal(t, result.AntennaGen, decoded.AntennaGen) + assert.Equal(t, result.DateTime, decoded.DateTime) + assert.Equal(t, result.Settings.LaunchCableLengthM, decoded.Settings.LaunchCableLengthM) + assert.Equal(t, result.Settings.Wavelength, decoded.Settings.Wavelength) + assert.Equal(t, result.Settings.RefractiveIndex, decoded.Settings.RefractiveIndex) + assert.Len(t, decoded.Peaks, 4) + assert.InDelta(t, result.Delays.EndToEndDelayNs, decoded.Delays.EndToEndDelayNs, 0.001) + assert.Len(t, decoded.Trace, len(result.Trace)) +} + +func TestAntennaElectricalDelayNs(t *testing.T) { + tests := []struct { + gen AntennaGen + expected float64 + wantErr bool + }{ + {Gen2Phase0, 20.5, false}, + {Gen2Phase1, 20.5, false}, + {Gen2Phase2, 39.3, false}, + {Gen2aPhase2, 39.3, false}, + {"unknown", 0, true}, + } + for _, tt := range tests { + t.Run(string(tt.gen), func(t *testing.T) { + got, err := AntennaElectricalDelayNs(tt.gen) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + } + }) + } +} + +func TestSMAPortOffsetNs(t *testing.T) { + tests := []struct { + model ReceiverModel + expected float64 + wantErr bool + }{ + {GNSSoF16RxE, 2.0, false}, + {GNSSPoF164RxE, 5.0, false}, + {"unknown", 0, true}, + } + for _, tt := range tests { + t.Run(string(tt.model), func(t *testing.T) { + got, err := SMAPortOffsetNs(tt.model) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + } + }) + } +} + +func TestPeakDescriptions(t *testing.T) { + rxeDescs := PeakDescription(GNSSoF16RxE) + assert.Contains(t, rxeDescs["OA"], "FO Out") + assert.Contains(t, rxeDescs["OB"], "Q-ODC-12") + + pofDescs := PeakDescription(GNSSPoF164RxE) + assert.Contains(t, pofDescs["OA"], "FO Out") + assert.Contains(t, pofDescs["OB"], "LC connector") +} + +func TestGenerateSVG(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + result, err := ComputeResult(tor, peaks, "gnss01.cln1", "PF000142", GNSSoF16RxE, Gen2Phase2, 0, 0) + require.NoError(t, err) + + var buf bytes.Buffer + err = GenerateSVG(tor, peaks, result, &buf) + require.NoError(t, err) + + svg := buf.String() + assert.True(t, strings.HasPrefix(svg, "")) + + for _, p := range peaks { + assert.Contains(t, svg, p.Label) + } + assert.Contains(t, svg, "gnss01.cln1") + assert.Contains(t, svg, "GNSSoF16-RxE") +} + +func TestGenerateSVGZoomed(t *testing.T) { + tor := buildTestTOR() + peaks, err := DetectPeaks(tor, 0) + require.NoError(t, err) + + result, err := ComputeResult(tor, peaks, "test", "PF000001", GNSSoF16RxE, Gen2Phase2, 0, 0) + require.NoError(t, err) + + var buf bytes.Buffer + err = GenerateSVGZoomed(tor, peaks, result, &buf, 50) + require.NoError(t, err) + + svg := buf.String() + assert.Contains(t, svg, "first 50 ns") + assert.Contains(t, svg, " 0 { + filtered := groups[:0] + for _, g := range groups { + if g.PeakDistM >= launchCableLengthM { + filtered = append(filtered, g) + } + } + groups = filtered + } + + ri := tor.RefractiveIndex + + // Sort groups by distance for ordered selection + sort.Slice(groups, func(i, j int) bool { + return groups[i].PeakDistM < groups[j].PeakDistM + }) + + if len(groups) < 1 { + return nil, fmt.Errorf("expected at least 1 prominent peak but found 0") + } + + // OA: the first prominent peak by distance (nearest connector) + oa := groups[0] + oaTimeNs := oa.PeakDistM * ri / SpeedOfLight * 1e9 + + // OB: search raw data points in the 10-20 ns window after OA for the + // highest amplitude point. The Q-ODC-12 connector can produce a very + // subtle reflection that doesn't exceed the prominence threshold. + obGroup, err := findOBInWindow(tor.DataPoints, oaTimeNs, ri) + if err != nil { + return nil, err + } + + // Determine end-of-cable: find where the trace amplitude drops to the + // noise floor. The cable section (between OB and the cable end) has a + // characteristic amplitude around -30 to -50 dB with gradual loss. After + // the cable ends the amplitude drops sharply. We detect this by computing + // the median amplitude of the cable section, then finding where the + // amplitude drops more than 10 dB below that median for a sustained run. + obMaxDistM := (oaTimeNs + maxOBOffsetNs) * SpeedOfLight / (ri * 1e9) + cableEndDistM := findCableEnd(tor.DataPoints, obMaxDistM) + + // OC and OD: the two most prominent peaks after OB but before the cable + // end. Cable-end filtering prevents post-cable noise from being selected, + // while prominence ranking ensures we pick the real connector reflections + // rather than minor noise between OB and OC. + var afterOB []PeakGroup + for _, g := range groups { + if g.PeakDistM > obMaxDistM && g.PeakDistM <= cableEndDistM { + afterOB = append(afterOB, g) + } + } + if len(afterOB) < 2 { + return nil, fmt.Errorf( + "expected at least 2 prominent peaks after OB (before cable end at %.1f m) but found %d", + cableEndDistM, len(afterOB), + ) + } + sort.Slice(afterOB, func(i, j int) bool { + return afterOB[i].Prominence > afterOB[j].Prominence + }) + ocod := afterOB[:2] + // Re-sort OC/OD by distance so OC < OD + sort.Slice(ocod, func(i, j int) bool { + return ocod[i].PeakDistM < ocod[j].PeakDistM + }) + + selected := []PeakGroup{oa, obGroup, ocod[0], ocod[1]} + labels := []string{"OA", "OB", "OC", "OD"} + peaks := make([]Peak, 4) + for i, g := range selected { + peaks[i] = Peak{ + Label: labels[i], + DistanceM: g.PeakDistM, + AmplitudeDB: g.PeakAmpDB, + TimeNs: g.PeakDistM * ri / SpeedOfLight * 1e9, + Index: g.PeakIdx, + } + } + + return peaks, nil +} + +// findOBInWindow finds the highest-amplitude data point in the OB time window +// (minOBOffsetNs to maxOBOffsetNs after OA). This searches raw data points +// rather than prominent groups because the Q-ODC-12 connector can produce a +// very subtle reflection that doesn't exceed the prominence threshold. +func findOBInWindow(points []DataPoint, oaTimeNs, ri float64) (PeakGroup, error) { + minDistM := (oaTimeNs + minOBOffsetNs) * SpeedOfLight / (ri * 1e9) + maxDistM := (oaTimeNs + maxOBOffsetNs) * SpeedOfLight / (ri * 1e9) + bestIdx := -1 + bestAmp := math.Inf(-1) + startIdx := sort.Search(len(points), func(i int) bool { + return points[i].DistanceM >= minDistM + }) + for i := startIdx; i < len(points); i++ { + dp := points[i] + if dp.DistanceM > maxDistM { + break + } + if dp.AmplitudeDB > bestAmp { + bestAmp = dp.AmplitudeDB + bestIdx = i + } + } + if bestIdx == -1 { + return PeakGroup{}, fmt.Errorf( + "no data point found in OB window (%.0f-%.0f ns after OA)", + minOBOffsetNs, maxOBOffsetNs, + ) + } + return PeakGroup{ + StartIdx: bestIdx, + EndIdx: bestIdx, + PeakIdx: bestIdx, + PeakDistM: points[bestIdx].DistanceM, + PeakAmpDB: points[bestIdx].AmplitudeDB, + }, nil +} + +// findCableEnd finds the distance where the OTDR trace drops to the noise floor, +// indicating the physical end of the fiber cable. +func findCableEnd(points []DataPoint, startDistM float64) float64 { + startIdx := sort.Search(len(points), func(i int) bool { + return points[i].DistanceM >= startDistM + }) + if startIdx >= len(points) { + return points[len(points)-1].DistanceM + } + + baselineEnd := min(startIdx+50, len(points)) + baselineAmps := make([]float64, baselineEnd-startIdx) + for i := startIdx; i < baselineEnd; i++ { + baselineAmps[i-startIdx] = points[i].AmplitudeDB + } + sort.Float64s(baselineAmps) + cableBaseline := baselineAmps[len(baselineAmps)/2] + + const dropThresholdDB = 10.0 + const sustainedCount = 10 + threshold := cableBaseline - dropThresholdDB + consecutive := 0 + for i := startIdx; i < len(points); i++ { + if points[i].AmplitudeDB < threshold { + consecutive++ + if consecutive >= sustainedCount { + return points[i-sustainedCount+1].DistanceM + } + } else { + consecutive = 0 + } + } + + return points[len(points)-1].DistanceM +} + +// computeLocalProminence computes how far above the local baseline each point is. +func computeLocalProminence(points []DataPoint) []float64 { + n := len(points) + prominences := make([]float64, n) + + outerRadius := 50 + innerRadius := 5 + + localAmps := make([]float64, 0, 2*outerRadius) + for i := range points { + localAmps = localAmps[:0] + for j := max(0, i-outerRadius); j <= min(n-1, i+outerRadius); j++ { + if j < i-innerRadius || j > i+innerRadius { + localAmps = append(localAmps, points[j].AmplitudeDB) + } + } + if len(localAmps) == 0 { + continue + } + sort.Float64s(localAmps) + localMedian := localAmps[len(localAmps)/2] + prominences[i] = points[i].AmplitudeDB - localMedian + } + + return prominences +} + +// findProminentGroups finds contiguous regions where local prominence exceeds the threshold +func findProminentGroups(points []DataPoint, prominences []float64, minProminence float64) []PeakGroup { + var groups []PeakGroup + inGroup := false + var current PeakGroup + + for i := range points { + if prominences[i] > minProminence { + if !inGroup { + inGroup = true + current = PeakGroup{ + StartIdx: i, + PeakIdx: i, + PeakDistM: points[i].DistanceM, + PeakAmpDB: points[i].AmplitudeDB, + Prominence: prominences[i], + } + } + if prominences[i] > current.Prominence { + current.PeakIdx = i + current.PeakDistM = points[i].DistanceM + current.PeakAmpDB = points[i].AmplitudeDB + current.Prominence = prominences[i] + } + } else { + if inGroup { + current.EndIdx = i - 1 + groups = append(groups, current) + inGroup = false + } + } + } + if inGroup { + current.EndIdx = len(points) - 1 + groups = append(groups, current) + } + + return groups +} + +// mergeCloseGroups merges peak groups that are within maxGapM meters of each other +func mergeCloseGroups(points []DataPoint, groups []PeakGroup, maxGapM float64) []PeakGroup { + if len(groups) <= 1 { + return groups + } + + var merged []PeakGroup + current := groups[0] + + for i := 1; i < len(groups); i++ { + gapM := points[groups[i].StartIdx].DistanceM - points[current.EndIdx].DistanceM + if math.Abs(gapM) <= maxGapM { + current.EndIdx = groups[i].EndIdx + if groups[i].Prominence > current.Prominence { + current.PeakIdx = groups[i].PeakIdx + current.PeakDistM = groups[i].PeakDistM + current.PeakAmpDB = groups[i].PeakAmpDB + current.Prominence = groups[i].Prominence + } + } else { + merged = append(merged, current) + current = groups[i] + } + } + merged = append(merged, current) + + return merged +} diff --git a/caliper/svg.go b/caliper/svg.go new file mode 100644 index 00000000..ab751e47 --- /dev/null +++ b/caliper/svg.go @@ -0,0 +1,302 @@ +/* +Copyright (c) Facebook, Inc. and its affiliates. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package caliper + +import ( + "bytes" + "fmt" + "html" + "io" + "math" + "strconv" + "time" +) + +const ( + svgWidth = 1600 + svgHeight = 920 + svgMarginL = 100 + svgMarginR = 40 + svgMarginT = 60 + svgMarginB = 200 + svgPlotWidth = svgWidth - svgMarginL - svgMarginR + svgPlotHeight = svgHeight - svgMarginT - svgMarginB +) + +// GenerateSVG creates an SVG plot of the full OTDR trace with peaks annotated +func GenerateSVG(tor *TORFile, peaks []Peak, result *MeasurementResult, w io.Writer) error { + return generateSVG(tor, peaks, result, w, 0, 0) +} + +// GenerateSVGZoomed creates an SVG plot of the OTDR trace zoomed to the first +// maxTimeNs nanoseconds, with peaks annotated. +func GenerateSVGZoomed(tor *TORFile, peaks []Peak, result *MeasurementResult, w io.Writer, maxTimeNs float64) error { + return generateSVG(tor, peaks, result, w, 0, maxTimeNs) +} + +// GenerateSVGWindow creates an SVG plot of the OTDR trace for a specific time +// window (minTimeNs to maxTimeNs), with peaks annotated. +func GenerateSVGWindow( + tor *TORFile, peaks []Peak, result *MeasurementResult, w io.Writer, minTimeNs, maxTimeNs float64, +) error { + return generateSVG(tor, peaks, result, w, minTimeNs, maxTimeNs) +} + +func generateSVG( + tor *TORFile, peaks []Peak, result *MeasurementResult, w io.Writer, minTimeNs, maxTimeNs float64, +) error { + if tor == nil { + return fmt.Errorf("tor must not be nil") + } + if result == nil { + return fmt.Errorf("result must not be nil") + } + if len(tor.DataPoints) == 0 { + return fmt.Errorf("no data points to plot") + } + + ri := tor.RefractiveIndex + + // Filter data points if zoomed + points := tor.DataPoints + if minTimeNs > 0 || maxTimeNs > 0 { + points = make([]DataPoint, 0, len(tor.DataPoints)) + for _, dp := range tor.DataPoints { + t := dp.TimeNs(ri) + if minTimeNs > 0 && t < minTimeNs { + continue + } + if maxTimeNs > 0 && t > maxTimeNs { + continue + } + points = append(points, dp) + } + if len(points) == 0 { + return fmt.Errorf("no data points within %.0f-%.0f ns window", minTimeNs, maxTimeNs) + } + } + + // Filter peaks to visible range + visiblePeaks := peaks + if minTimeNs > 0 || maxTimeNs > 0 { + visiblePeaks = nil + for _, p := range peaks { + if minTimeNs > 0 && p.TimeNs < minTimeNs { + continue + } + if maxTimeNs > 0 && p.TimeNs > maxTimeNs { + continue + } + visiblePeaks = append(visiblePeaks, p) + } + } + + // Compute axis ranges + minT := math.Inf(1) + maxT := math.Inf(-1) + minA := math.Inf(1) + maxA := math.Inf(-1) + for _, dp := range points { + t := dp.TimeNs(ri) + if t < minT { + minT = t + } + if t > maxT { + maxT = t + } + if dp.AmplitudeDB < minA { + minA = dp.AmplitudeDB + } + if dp.AmplitudeDB > maxA { + maxA = dp.AmplitudeDB + } + } + ampRange := maxA - minA + if ampRange == 0 { + ampRange = 1.0 + } + minA -= ampRange * 0.05 + maxA += ampRange * 0.05 + + timeRange := maxT - minT + if timeRange == 0 { + timeRange = 1.0 + } + ampRange = maxA - minA + + scaleX := func(tNs float64) float64 { + return svgMarginL + (tNs-minT)/timeRange*svgPlotWidth + } + scaleY := func(aDB float64) float64 { + return svgMarginT + (1-(aDB-minA)/ampRange)*svgPlotHeight + } + + var buf bytes.Buffer + buf.Grow(len(points) * 20) + + fmt.Fprintf(&buf, ``, + svgWidth, svgHeight, svgWidth, svgHeight) + buf.WriteString("\n") + + fmt.Fprintf(&buf, ``, svgWidth, svgHeight) + buf.WriteString("\n") + + title := "Caliper: OTDR Trace Data" + if minTimeNs > 0 && maxTimeNs > 0 { + title = fmt.Sprintf("Caliper: OTDR Trace Data (%.0f-%.0f ns)", minTimeNs, maxTimeNs) + } else if maxTimeNs > 0 { + title = fmt.Sprintf("Caliper: OTDR Trace Data (first %.0f ns)", maxTimeNs) + } + fmt.Fprintf(&buf, + `%s`, + svgWidth/2, html.EscapeString(title)) + buf.WriteString("\n") + + fmt.Fprintf(&buf, + ``, + svgMarginL, svgMarginT, svgPlotWidth, svgPlotHeight) + buf.WriteString("\n") + + // Grid lines and axis labels - X axis + xTicks := niceTicksFloat(minT, maxT, 10) + for _, t := range xTicks { + x := scaleX(t) + fmt.Fprintf(&buf, ``, + x, svgMarginT, x, svgMarginT+svgPlotHeight) + fmt.Fprintf(&buf, + `%.1f`, + x, svgHeight-svgMarginB+20, t) + buf.WriteString("\n") + } + + fmt.Fprintf(&buf, + `Time (ns)`, + svgMarginL+svgPlotWidth/2, svgHeight-10) + buf.WriteString("\n") + + // Grid lines and axis labels - Y axis + yTicks := niceTicksFloat(minA, maxA, 8) + for _, a := range yTicks { + y := scaleY(a) + fmt.Fprintf(&buf, ``, + svgMarginL, y, svgMarginL+svgPlotWidth, y) + fmt.Fprintf(&buf, + `%.1f`, + svgMarginL-8, y, a) + buf.WriteString("\n") + } + + fmt.Fprintf(&buf, + `Amplitude (dB)`, + svgMarginT+svgPlotHeight/2, svgMarginT+svgPlotHeight/2) + buf.WriteString("\n") + + // Trace path + buf.WriteString(``) + buf.WriteString("\n") + + // Peak annotations + colors := []string{"#dc2626", "#16a34a", "#d97706", "#9333ea"} + labelYOffsets := []int{0, 16, 0, 16} + for i, p := range visiblePeaks { + x := scaleX(p.TimeNs) + y := scaleY(p.AmplitudeDB) + color := colors[i%len(colors)] + + fmt.Fprintf(&buf, + ``, + x, svgMarginT, x, svgMarginT+svgPlotHeight, color) + + fmt.Fprintf(&buf, ``, x, y, color) + + labelY := svgMarginT + 15 + labelYOffsets[i%len(labelYOffsets)] + fmt.Fprintf(&buf, + `%s %.2f ns`, + x+6, labelY, color, html.EscapeString(p.Label), p.TimeNs) + buf.WriteString("\n") + } + + // Info block below the graph + infoY := svgMarginT + svgPlotHeight + 55 + lineHeight := 18 + infoLines := []string{ + fmt.Sprintf("Device Name: %s", result.DeviceName), + fmt.Sprintf("Device Model: %s", result.Model), + fmt.Sprintf("Antenna Generation: %s", result.AntennaGen), + fmt.Sprintf("Serial Number: %s", result.SerialNumber), + fmt.Sprintf("Capture Date/Time: %s", tor.DateTime.Format("2006-01-02 15:04:05 UTC")), + fmt.Sprintf("Parsed Date/Time: %s", time.Now().UTC().Format("2006-01-02 15:04:05 UTC")), + fmt.Sprintf("Resolution: %.4f m", tor.Resolution), + fmt.Sprintf("Wavelength: %d nm", tor.Wavelength), + fmt.Sprintf("Backscatter Coefficient: %.2f dB", tor.BackscatterCoefficient), + fmt.Sprintf("Refractive Index: %.4f", tor.RefractiveIndex), + } + for i, line := range infoLines { + fmt.Fprintf(&buf, `%s`, + svgMarginL, infoY+i*lineHeight, html.EscapeString(line)) + buf.WriteString("\n") + } + + buf.WriteString("\n") + + _, err := w.Write(buf.Bytes()) + return err +} + +func niceTicksFloat(lo, hi float64, count int) []float64 { + if lo == hi { + return []float64{lo} + } + rawStep := (hi - lo) / float64(count) + magnitude := math.Pow(10, math.Floor(math.Log10(rawStep))) + normalized := rawStep / magnitude + + var step float64 + switch { + case normalized <= 1.5: + step = magnitude + case normalized <= 3.5: + step = 2 * magnitude + case normalized <= 7.5: + step = 5 * magnitude + default: + step = 10 * magnitude + } + + start := math.Ceil(lo/step) * step + var ticks []float64 + for v := start; v <= hi && len(ticks) < count*2; v += step { + ticks = append(ticks, v) + } + return ticks +} diff --git a/caliper/tor.go b/caliper/tor.go new file mode 100644 index 00000000..ef175490 --- /dev/null +++ b/caliper/tor.go @@ -0,0 +1,276 @@ +/* +Copyright (c) Facebook, Inc. and its affiliates. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package caliper + +import ( + "bufio" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" +) + +// SpeedOfLight in meters per second +const SpeedOfLight = 299792458.0 + +// TORFile represents a parsed Luciol LOR-220 OTDR .tor file +type TORFile struct { + DateTime time.Time + InstrumentInfo string + ModuleSerialNumber string + CableID string + FiberID string + FiberType int + Wavelength int + PulseWidth int + RefractiveIndex float64 + DistanceUnit int + Average int + End float64 + Start float64 + Resolution float64 + DistanceRange int + LossThreshold float64 + ReflectanceThreshold float64 + EndOfFiberThreshold float64 + BackscatterCoefficient float64 + HighResolution int + DataPoints []DataPoint +} + +// DataPoint is a single OTDR measurement +type DataPoint struct { + DistanceM float64 + AmplitudeDB float64 +} + +// TimeNs converts distance in meters to time in nanoseconds using the refractive index +func (d DataPoint) TimeNs(refractiveIndex float64) float64 { + return d.DistanceM * refractiveIndex / SpeedOfLight * 1e9 +} + +// ParseTOR parses a Luciol OTDR .tor text file from a path +func ParseTOR(path string) (*TORFile, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening tor file: %w", err) + } + defer f.Close() + return ParseTORReader(f) +} + +// ParseTORReader parses a Luciol OTDR .tor text file from a reader +func ParseTORReader(r io.Reader) (*TORFile, error) { + tor := &TORFile{} + scanner := bufio.NewScanner(r) + + // Skip header lines until we find the first section + for scanner.Scan() { + line := cleanLine(scanner.Text()) + if line == "[DateTime]" { + break + } + } + + currentSection := "DateTime" + for scanner.Scan() { + line := cleanLine(scanner.Text()) + + if line == "[-]" { + currentSection = "" + continue + } + + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + currentSection = line[1 : len(line)-1] + continue + } + + if currentSection == "" && line == "" { + continue + } + + switch currentSection { + case "DateTime": + ts, err := strconv.ParseInt(line, 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing DateTime %q: %w", line, err) + } + tor.DateTime = time.Unix(ts, 0) + case "InstrumentInfo": + if tor.InstrumentInfo == "" { + tor.InstrumentInfo = line + } else if sn, ok := strings.CutPrefix(line, "OTDR module S/N:"); ok { + tor.ModuleSerialNumber = strings.TrimSpace(sn) + } + case "CableID": + tor.CableID = line + case "FiberID": + tor.FiberID = line + case "FiberType": + v, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parsing FiberType %q: %w", line, err) + } + tor.FiberType = v + case "Wavelength": + v, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parsing Wavelength %q: %w", line, err) + } + tor.Wavelength = v + case "PulseWidth": + v, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parsing PulseWidth %q: %w", line, err) + } + tor.PulseWidth = v + case "RefractiveIndex": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing RefractiveIndex %q: %w", line, err) + } + tor.RefractiveIndex = v + case "DistanceUnit": + v, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parsing DistanceUnit %q: %w", line, err) + } + tor.DistanceUnit = v + case "Average": + v, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parsing Average %q: %w", line, err) + } + tor.Average = v + case "End": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing End %q: %w", line, err) + } + tor.End = v + case "Start": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing Start %q: %w", line, err) + } + tor.Start = v + case "Resolution": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing Resolution %q: %w", line, err) + } + tor.Resolution = v + case "DistanceRange": + v, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parsing DistanceRange %q: %w", line, err) + } + tor.DistanceRange = v + case "LossThreshold": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing LossThreshold %q: %w", line, err) + } + tor.LossThreshold = v + case "ReflectanceThreshold": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing ReflectanceThreshold %q: %w", line, err) + } + tor.ReflectanceThreshold = v + case "EndOfFiberThreshold": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing EndOfFiberThreshold %q: %w", line, err) + } + tor.EndOfFiberThreshold = v + case "BackscatterCoefficient": + v, err := strconv.ParseFloat(line, 64) + if err != nil { + return nil, fmt.Errorf("parsing BackscatterCoefficient %q: %w", line, err) + } + tor.BackscatterCoefficient = v + case "HighResolution": + v, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parsing HighResolution %q: %w", line, err) + } + tor.HighResolution = v + case "DataPoints": + dp, err := parseDataPoint(line) + if err != nil { + return nil, fmt.Errorf("parsing data point: %w", err) + } + tor.DataPoints = append(tor.DataPoints, dp) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading tor file: %w", err) + } + + if len(tor.DataPoints) == 0 { + return nil, fmt.Errorf("no data points found in tor file") + } + + // Extract wavelength from InstrumentInfo if Wavelength field is an index + if tor.Wavelength == 0 && tor.InstrumentInfo != "" { + if wl, ok := parseWavelengthFromInfo(tor.InstrumentInfo); ok { + tor.Wavelength = wl + } + } + + return tor, nil +} + +func parseDataPoint(line string) (DataPoint, error) { + parts := strings.Split(line, "\t") + if len(parts) != 2 { + return DataPoint{}, fmt.Errorf("invalid data point: %q", line) + } + dist, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) + if err != nil { + return DataPoint{}, fmt.Errorf("parsing distance: %w", err) + } + amp, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + if err != nil { + return DataPoint{}, fmt.Errorf("parsing amplitude: %w", err) + } + return DataPoint{DistanceM: dist, AmplitudeDB: amp}, nil +} + +// parseWavelengthFromInfo extracts the wavelength in nm from the InstrumentInfo string. +// Example: "1310 nm - 1 ns -" -> 1310 +func parseWavelengthFromInfo(info string) (int, bool) { + numStr, _, ok := strings.Cut(info, "nm") + if !ok { + return 0, false + } + numStr = strings.TrimSpace(numStr) + wl, err := strconv.Atoi(numStr) + if err != nil { + return 0, false + } + return wl, true +} + +func cleanLine(line string) string { + return strings.TrimRight(line, "\r\n ") +}