diff --git a/Directory.Build.props b/Directory.Build.props index d8ffc35..3c8ac3b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,14 +12,14 @@ LICENSE true icon.png - 0.4.0 + 0.5.0 10.0 strict - + diff --git a/OpenEphys.ProbeInterface.NET.Tests/ProbeGroupValidationTests.cs b/OpenEphys.ProbeInterface.NET.Tests/ProbeGroupValidationTests.cs index f3ad4be..a901938 100644 --- a/OpenEphys.ProbeInterface.NET.Tests/ProbeGroupValidationTests.cs +++ b/OpenEphys.ProbeInterface.NET.Tests/ProbeGroupValidationTests.cs @@ -146,8 +146,8 @@ public void DeviceChannelIndices_PopulateChannelMap() var group = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); var map = group.Probes.First().ChannelMap; Assert.NotNull(map); - Assert.Equal(3, map![0]); - Assert.Equal(7, map[1]); + Assert.Equal(3, map.Keys.ElementAt(0)); + Assert.Equal(7, map.Keys.ElementAt(1)); } [Fact] @@ -169,34 +169,43 @@ public void DuplicateDeviceChannelIndices_Throws() public void WireChannels_FirstCall_AssignsSpecifiedContacts() { var group = Deserialize(MakeJson()); - ChannelWiring.WireChannels(group,0, new Dictionary { { 0, 3 } }); + ChannelWiring.WireChannels(group,0, new Dictionary { { 3, 0 } }); // channel 3 -> contact 0 var map = group.Probes.First().ChannelMap; Assert.NotNull(map); - Assert.Equal(3, map![0]); - Assert.False(map.ContainsKey(1)); // contact 1 was not assigned + Assert.Equal(3, map.Keys.ElementAt(0)); + Assert.Equal(0, map![3]); + Assert.False(map.ContainsKey(1)); // channel 1 was not assigned } [Fact] public void WireChannels_SecondCall_IsIncremental() { var group = Deserialize(MakeJson()); - ChannelWiring.WireChannels(group,0, new Dictionary { { 0, 3 } }); - ChannelWiring.WireChannels(group,0, new Dictionary { { 1, 7 } }); + ChannelWiring.WireChannels(group,0, new Dictionary { { 3, 0 } }); // channel 3 -> contact 0 + ChannelWiring.WireChannels(group,0, new Dictionary { { 7, 1 } }); // channel 7 -> contact 1 var map = group.Probes.First().ChannelMap; - Assert.Equal(3, map![0]); // still assigned from first call - Assert.Equal(7, map[1]); // added by second call + Assert.Equal(3, map!.Keys.ElementAt(0)); // still assigned from first call + Assert.Equal(7, map.Keys.ElementAt(1)); // added by second call } [Fact] public void WireChannels_ChannelConflict_DisplacesExistingContact() { var group = Deserialize(MakeJson()); - ChannelWiring.WireChannels(group,0, new Dictionary { { 0, 5 } }); - // Assign channel 5 to contact 1 — should displace contact 0 - ChannelWiring.WireChannels(group,0, new Dictionary { { 1, 5 } }); + ChannelWiring.WireChannels(group,0, new Dictionary { { 5, 0 } }); // channel 5 -> contact 0 var map = group.Probes.First().ChannelMap; - Assert.False(map!.ContainsKey(0)); // displaced - Assert.Equal(5, map[1]); + Assert.NotNull(map); + Assert.True(map!.ContainsKey(5)); + Assert.Equal(0, map[5]); + Assert.NotEqual(1, map[5]); + + // Assign channel 5 to contact 1 — should displace contact 0 + ChannelWiring.WireChannels(group,0, new Dictionary { { 5, 1 } }); // channel 5 -> contact 1 + map = group.Probes.First().ChannelMap; + Assert.NotNull(map); + Assert.True(map!.ContainsKey(5)); + Assert.Equal(1, map[5]); + Assert.NotEqual(0, map[5]); // displaced } [Fact] @@ -204,7 +213,7 @@ public void WireChannels_OutOfRangeContactIndex_Throws() { var group = Deserialize(MakeJson()); Assert.Throws(() => - ChannelWiring.WireChannels(group,0, new Dictionary { { 5, 0 } })); + ChannelWiring.WireChannels(group,0, new Dictionary { { 0, 5 } })); // contact 5 is out of range } [Fact] @@ -212,15 +221,15 @@ public void WireChannels_NegativeChannelValue_Throws() { var group = Deserialize(MakeJson()); Assert.Throws(() => - ChannelWiring.WireChannels(group,0, new Dictionary { { 0, -1 } })); + ChannelWiring.WireChannels(group,0, new Dictionary { { -1, 0 } })); // channel -1 is negative } [Fact] - public void WireChannels_DuplicateChannelWithinCall_Throws() + public void WireChannels_DuplicateContactWithinCall_Throws() { var group = Deserialize(MakeJson()); Assert.Throws(() => - ChannelWiring.WireChannels(group,0, new Dictionary { { 0, 5 }, { 1, 5 } })); + ChannelWiring.WireChannels(group,0, new Dictionary { { 5, 0 }, { 7, 0 } })); // contact 0 twice } [Fact] @@ -229,7 +238,7 @@ public void WireChannels_CrossProbeConflict_ThrowsAndRollsBack() var group = Deserialize(MakeTwoProbeJson(probe0ChannelIndices: "[10]")); // Probe 0 already has channel 10; try to assign 10 to probe 1 Assert.Throws(() => - ChannelWiring.WireChannels(group,1, new Dictionary { { 0, 10 } })); + ChannelWiring.WireChannels(group,1, new Dictionary { { 10, 0 } })); // Probe 1 map must be rolled back to null Assert.Null(group.Probes.ElementAt(1).ChannelMap); } @@ -239,18 +248,18 @@ public void WireChannel_AssignsSingleContact() { var group = Deserialize(MakeJson()); ChannelWiring.WireChannel(group,0, 1, 42); - Assert.Equal(42, group.Probes.First().ChannelMap![1]); + Assert.Equal(42, group.Probes.First().ChannelMap!.ElementAt(0).Key); } [Fact] public void UnwireChannel_RemovesEntry() { var group = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); - ChannelWiring.UnwireChannel(group,0, 0); + ChannelWiring.UnwireChannel(group, 0, 0); var map = group.Probes.First().ChannelMap; Assert.NotNull(map); - Assert.False(map!.ContainsKey(0)); - Assert.Equal(7, map[1]); + Assert.False(map!.ContainsKey(3)); + Assert.Equal(7, map.Keys.ElementAt(0)); } [Fact] @@ -289,7 +298,7 @@ public void UnwireChannels_Probe_ClearsAllOnThatProbe() public void UnwireChannels_Probe_DoesNotThrowWhenAlreadyEmpty() { var group = Deserialize(MakeJson()); // no channel indices - var ex = Record.Exception(() => ChannelWiring.UnwireChannels(group,0)); + var ex = Record.Exception(() => ChannelWiring.UnwireChannels(group, 0)); Assert.Null(ex); } @@ -303,66 +312,139 @@ public void UnwireChannels_All_ClearsEveryProbe() } [Fact] - public void GetChannelMap_NoChannelsAssigned_ReturnsNull() + public void ChannelMap_NoChannelsAssigned_IsNull() { var group = Deserialize(MakeJson()); - Assert.Null(group.GetChannelMap()); + Assert.Null(group.Probes.First().ChannelMap); } [Fact] - public void GetChannelMap_ReturnsChannelToContactMapping() + public void ChannelMap_ReturnsChannelToContactIndexMapping() { var group = Deserialize(MakeJson(contactIds: "[\"e0\", \"e1\"]", deviceChannelIndices: "[3, 7]")); - var map = group.GetChannelMap(); + var probe = group.Probes.First(); + var map = probe.ChannelMap; Assert.NotNull(map); - Assert.Equal("e0", map![3].Contact.ContactId); - Assert.Equal("e1", map[7].Contact.ContactId); + Assert.Equal("e0", probe.Contacts[map![3]].ContactId); + Assert.Equal("e1", probe.Contacts[map[7]].ContactId); } [Fact] - public void GetChannelMap_ContactPropertiesAreAccessible() + public void ChannelMap_ContactsAccessibleViaIndex() { var group = Deserialize(MakeJson(deviceChannelIndices: "[0, 1]")); - var map = group.GetChannelMap()!; - Assert.Equal(0.0, map[0].Contact.PosX); - Assert.Equal(0.0, map[0].Contact.PosY); - Assert.Equal(0.0, map[1].Contact.PosX); - Assert.Equal(20.0, map[1].Contact.PosY); + var probe = group.Probes.First(); + var map = probe.ChannelMap!; + Assert.Equal(0.0, probe.Contacts[map[0]].PosX); + Assert.Equal(0.0, probe.Contacts[map[0]].PosY); + Assert.Equal(0.0, probe.Contacts[map[1]].PosX); + Assert.Equal(20.0, probe.Contacts[map[1]].PosY); } [Fact] - public void GetChannelMap_ContactIndex_IsCorrect() + public void ChannelMap_ContactIndex_IsCorrect() { // contact_positions has 2 contacts; device_channel_indices assigns channel 99 to contact 1 var group = Deserialize(MakeJson(deviceChannelIndices: "[-1, 99]")); - var map = group.GetChannelMap()!; - Assert.Equal(1, map[99].ContactIndex); + var map = group.Probes.First().ChannelMap!; + Assert.Equal(1, map[99]); } [Fact] - public void GetChannelMap_MultiProbe_CombinesBothProbes() + public void ChannelMap_MultiProbe_EachProbeHasItsOwnMap() { var group = Deserialize(MakeTwoProbeJson( probe0ContactIds: "[\"a0\"]", probe0ChannelIndices: "[10]", probe1ContactIds: "[\"b0\"]", probe1ChannelIndices: "[20]")); - var map = group.GetChannelMap()!; - Assert.Equal(2, map.Count); - Assert.Equal("a0", map[10].Contact.ContactId); - Assert.Equal(0, map[10].ProbeIndex); - Assert.Equal("b0", map[20].Contact.ContactId); - Assert.Equal(1, map[20].ProbeIndex); + var map0 = group.Probes.ElementAt(0).ChannelMap!; + var map1 = group.Probes.ElementAt(1).ChannelMap!; + Assert.Equal(0, map0[10]); + Assert.Equal("a0", group.Probes.ElementAt(0).Contacts[map0[10]].ContactId); + Assert.Equal(0, map1[20]); + Assert.Equal("b0", group.Probes.ElementAt(1).Contacts[map1[20]].ContactId); } [Fact] - public void GetChannelMap_AfterWireChannel_ReflectsUpdate() + public void ChannelMap_AfterWireChannel_ReflectsUpdate() { var group = Deserialize(MakeJson()); ChannelWiring.WireChannel(group,0, 0, 42); - var map = group.GetChannelMap()!; + var map = group.Probes.First().ChannelMap!; Assert.Single(map); Assert.True(map.ContainsKey(42)); - Assert.Equal(0, map[42].ProbeIndex); - Assert.Equal(0, map[42].ContactIndex); + Assert.Equal(0, map[42]); + } + + private static string MakeShapeJson(string shape, string shapeParams) => + $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [ + { + "ndim": 2, "si_units": "um", + "annotations": { "model_name": "P", "manufacturer": "M" }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["{{shape}}"], + "contact_shape_params": [{{shapeParams}}] + } + ] + } + """; + + [Fact] + public void Circle_WithOnlyWidth_Throws() + { + // width alone satisfies ContactShapeParam's own constraint, but a circle needs radius. + var ex = Assert.Throws(() => + Deserialize(MakeShapeJson("circle", "{\"width\": 5.0}"))); + Assert.Contains("radius", ex.Message); + } + + [Fact] + public void Rect_MissingHeight_Throws() + { + var ex = Assert.Throws(() => + Deserialize(MakeShapeJson("rect", "{\"width\": 5.0}"))); + Assert.Contains("width and height", ex.Message); + } + + [Fact] + public void Rect_MissingWidthAndHeight_Throws() + { + var ex = Assert.Throws(() => + Deserialize(MakeShapeJson("rect", "{\"radius\": 5.0}"))); + Assert.Contains("width and height", ex.Message); + } + + [Fact] + public void Square_MissingWidth_Throws() + { + var ex = Assert.Throws(() => + Deserialize(MakeShapeJson("square", "{\"radius\": 5.0}"))); + Assert.Contains("width", ex.Message); + } + + [Fact] + public void Circle_WithRadius_DoesNotThrow() + { + var ex = Record.Exception(() => Deserialize(MakeShapeJson("circle", "{\"radius\": 5.0}"))); + Assert.Null(ex); + } + + [Fact] + public void Rect_WithWidthAndHeight_DoesNotThrow() + { + var ex = Record.Exception(() => + Deserialize(MakeShapeJson("rect", "{\"width\": 5.0, \"height\": 3.0}"))); + Assert.Null(ex); + } + + [Fact] + public void Square_WithWidth_DoesNotThrow() + { + var ex = Record.Exception(() => Deserialize(MakeShapeJson("square", "{\"width\": 5.0}"))); + Assert.Null(ex); } } } diff --git a/OpenEphys.ProbeInterface.NET.Tests/SingleProbeGroupTests.cs b/OpenEphys.ProbeInterface.NET.Tests/SingleProbeGroupTests.cs new file mode 100644 index 0000000..3d65730 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/SingleProbeGroupTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using Newtonsoft.Json; +using Xunit; + +namespace OpenEphys.ProbeInterface.NET.Tests +{ + public class SingleProbeGroupTests + { + // Minimal concrete subclass for testing. + private class TestSingleProbeGroup : SingleProbeGroup + { + [JsonConstructor] + public TestSingleProbeGroup(string specification, string version, Probe[] probes) + : base(specification, version, probes) { } + + public TestSingleProbeGroup(ProbeGroup probeGroup) + : base(probeGroup) { } + } + + private static TestSingleProbeGroup Deserialize(string json) => + JsonConvert.DeserializeObject(json) + ?? throw new JsonException("Deserialization returned null."); + + private static string MakeJson(string? deviceChannelIndices = null, string? contactIds = null) => + $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [ + { + "ndim": 2, "si_units": "um", + "annotations": { "model_name": "T", "manufacturer": "T" }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0]], + "contact_shapes": ["circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}] + {{(contactIds != null ? $", \"contact_ids\": {contactIds}" : "")}} + {{(deviceChannelIndices != null ? $", \"device_channel_indices\": {deviceChannelIndices}" : "")}} + } + ] + } + """; + + private static string MakeTwoProbeJson() => + $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [ + { + "ndim": 2, "si_units": "um", + "annotations": { "model_name": "T", "manufacturer": "T" }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}] + }, + { + "ndim": 2, "si_units": "um", + "annotations": { "model_name": "T", "manufacturer": "T" }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}] + } + ] + } + """; + + [Fact] + public void MultipleProbes_Throws() + { + Assert.Throws(() => Deserialize(MakeTwoProbeJson())); + } + + [Fact] + public void Probe_ReturnsSingleProbe() + { + var group = Deserialize(MakeJson()); + Assert.Same(group.Probes.First(), group.Probe); + } + + [Fact] + public void ChannelMap_NoChannelsAssigned_IsNull() + { + var group = Deserialize(MakeJson()); + Assert.Null(group.ChannelMap); + } + + [Fact] + public void ChannelMap_ReturnsChannelToContactIndex() + { + var group = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); + var map = group.ChannelMap; + Assert.NotNull(map); + Assert.Equal(0, map![3]); + Assert.Equal(1, map[7]); + } + + [Fact] + public void TryGetChannel_MappedContact_ReturnsTrue() + { + var group = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); + Assert.True(group.TryGetMappedChannel(0, out int ch)); + Assert.Equal(3, ch); + } + + [Fact] + public void TryGetChannel_UnmappedContact_ReturnsFalse() + { + var group = Deserialize(MakeJson(deviceChannelIndices: "[-1, 7]")); + Assert.False(group.TryGetMappedChannel(0, out int ch)); + Assert.Equal(-1, ch); + } + + [Fact] + public void TryGetChannel_NoMap_ReturnsFalse() + { + var group = Deserialize(MakeJson()); + Assert.False(group.TryGetMappedChannel(0, out int ch)); + Assert.Equal(-1, ch); + } + + [Fact] + public void ChannelMap_AfterWiring_Reflects() + { + var group = Deserialize(MakeJson()); + ChannelWiring.WireChannel(group, 0, 1, 99); + Assert.Equal(1, group.ChannelMap![99]); + } + + [Fact] + public void CopyConstructor_PreservesProbeAndMap() + { + var source = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); + var copy = new TestSingleProbeGroup(source); + Assert.Equal(source.ChannelMap!.Keys.ToList(), copy.ChannelMap!.Keys.ToList()); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET.sln b/OpenEphys.ProbeInterface.NET.sln index 67b0436..3b83fe3 100644 --- a/OpenEphys.ProbeInterface.NET.sln +++ b/OpenEphys.ProbeInterface.NET.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34511.84 +# Visual Studio Version 18 +VisualStudioVersion = 18.7.12002.237 oobstable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenEphys.ProbeInterface.NET", "OpenEphys.ProbeInterface.NET\OpenEphys.ProbeInterface.NET.csproj", "{822F3536-A4B7-4FE4-8332-A75A8458EE56}" EndProject @@ -10,42 +10,22 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Build.props = Directory.Build.props EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenEphys.ProbeInterface.NET.Tests", "OpenEphys.ProbeInterface.NET.Tests\OpenEphys.ProbeInterface.NET.Tests.csproj", "{03E29B08-D448-45E5-AE8D-7DEE7184FFA9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenEphys.ProbeInterface.NET.Tests", "OpenEphys.ProbeInterface.NET.Tests\OpenEphys.ProbeInterface.NET.Tests.csproj", "{E2869E8D-A4C4-6168-DABB-3B5465608AD8}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Debug|Any CPU.Build.0 = Debug|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Debug|x64.ActiveCfg = Debug|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Debug|x64.Build.0 = Debug|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Debug|x86.ActiveCfg = Debug|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Debug|x86.Build.0 = Debug|Any CPU {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Release|Any CPU.ActiveCfg = Release|Any CPU {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Release|Any CPU.Build.0 = Release|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Release|x64.ActiveCfg = Release|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Release|x64.Build.0 = Release|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Release|x86.ActiveCfg = Release|Any CPU - {822F3536-A4B7-4FE4-8332-A75A8458EE56}.Release|x86.Build.0 = Release|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Debug|x64.ActiveCfg = Debug|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Debug|x64.Build.0 = Debug|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Debug|x86.ActiveCfg = Debug|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Debug|x86.Build.0 = Debug|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Release|Any CPU.Build.0 = Release|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Release|x64.ActiveCfg = Release|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Release|x64.Build.0 = Release|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Release|x86.ActiveCfg = Release|Any CPU - {03E29B08-D448-45E5-AE8D-7DEE7184FFA9}.Release|x86.Build.0 = Release|Any CPU + {E2869E8D-A4C4-6168-DABB-3B5465608AD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2869E8D-A4C4-6168-DABB-3B5465608AD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2869E8D-A4C4-6168-DABB-3B5465608AD8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2869E8D-A4C4-6168-DABB-3B5465608AD8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/OpenEphys.ProbeInterface.NET/ChannelWiring.cs b/OpenEphys.ProbeInterface.NET/ChannelWiring.cs index ff29670..869d054 100644 --- a/OpenEphys.ProbeInterface.NET/ChannelWiring.cs +++ b/OpenEphys.ProbeInterface.NET/ChannelWiring.cs @@ -8,11 +8,10 @@ namespace OpenEphys.ProbeInterface.NET /// Static helper methods for wiring hardware channels to contacts in a . /// /// - /// Kept separate from because not all wiring operations are valid for - /// every hardware type. For example, Neuropixels 2.0 always maps all 384 channels to some set - /// of electrodes, so unwiring operations do not apply. Calling these methods directly makes the caller's - /// intent explicit and keeps the type hierarchy free of operations that - /// would need to be suppressed in certain subclasses. + /// Kept separate from because not all wiring operations are valid for every + /// hardware type. Calling these methods directly makes the caller's intent explicit and keeps the type hierarchy free of operations that would need to be suppressed in certain + /// subclasses. /// public static class ChannelWiring { @@ -20,20 +19,26 @@ public static class ChannelWiring /// Incrementally assigns hardware channels to contacts on the specified probe. /// /// - /// The update is incremental: contacts not in keep their - /// current channel. If the probe has no existing mapping, unspecified contacts start - /// unconnected. + /// The update is incremental: contacts not referenced by keep their + /// current channel. If the probe has no existing mapping, unreferenced contacts start unconnected. /// - /// If a channel in is already held by a different contact - /// on the same probe, then that contact loses its mapping. + /// If a channel in was already wired to a different contact on the same + /// probe, that contact loses its mapping. If a contact in already wired to + /// a different channel, that old channel is freed. /// /// /// The probe group to update. /// Zero-based index of the probe to update. - /// Contact index → channel index. Values must be >= 0 and unique within the call. + /// + /// Channel index → contact index. Keys must be >= 0; values must be within [0, contact count) and + /// unique within the call. + /// + /// + /// Thrown when is outside the range of 's probes. + /// /// - /// Thrown when a key is out of range, any value is negative, values within the call are - /// not unique, or the result would duplicate a channel already assigned on another probe. + /// Thrown when a channel key is negative, a contact value is out of range, contact values within the + /// call are not unique, or the result would duplicate a channel already assigned on another probe. /// public static void WireChannels(ProbeGroup group, int probeIndex, IDictionary assignments) { @@ -42,18 +47,18 @@ public static void WireChannels(ProbeGroup group, int probeIndex, IDictionary= n) + if (kvp.Key < 0) throw new ArgumentException( - $"Contact index {kvp.Key} is out of range [0, {n}).", nameof(assignments)); - if (kvp.Value < 0) + $"Channel value {kvp.Key} must be >= 0.", nameof(assignments)); + if (kvp.Value < 0 || kvp.Value >= n) throw new ArgumentException( - $"Channel value {kvp.Value} for contact {kvp.Key} must be >= 0.", nameof(assignments)); + $"Contact index {kvp.Value} for channel {kvp.Key} is out of range [0, {n}).", nameof(assignments)); } - var incomingChannels = assignments.Values.ToList(); - if (incomingChannels.Count != incomingChannels.Distinct().Count()) + var incomingContacts = assignments.Values.ToList(); + if (incomingContacts.Count != incomingContacts.Distinct().Count()) throw new ArgumentException( - "Channel values within a single assignment call must be unique.", nameof(assignments)); + "Contact indices within a single assignment call must be unique.", nameof(assignments)); var previousMap = probe.ChannelMap?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); @@ -63,51 +68,66 @@ public static void WireChannels(ProbeGroup group, int probeIndex, IDictionary e.Value == kvp.Value && e.Key != kvp.Key) - .Select(e => e.Key) - .ToArray(); - foreach (var k in displaced) - newMap.Remove(k); - - newMap[kvp.Key] = kvp.Value; + int channel = kvp.Key; + int contactIndex = kvp.Value; + + // Evict any old channel entry for this contact (a contact holds at most one channel). + foreach (var ch in newMap.Where(e => e.Value == contactIndex).Select(e => e.Key).ToArray()) + newMap.Remove(ch); + + // Assign: overwrites any contact previously on this channel (displacement is implicit). + newMap[channel] = contactIndex; } - probe.SetChannelMap(newMap.Count > 0 ? newMap : null); + probe.ChannelMap = newMap.Count > 0 ? newMap : null; if (!group.ValidateDeviceChannelIndices()) { - probe.SetChannelMap(previousMap); + probe.ChannelMap = previousMap; throw new ArgumentException( "Channel indices must be unique across all probes in the group.", nameof(assignments)); } } /// - /// Assigns a single hardware channel to a contact on the specified probe. - /// Displaces any other contact on the same probe that currently holds . + /// Assigns a single hardware channel to a contact on the specified probe. Displaces any other contact + /// on the same probe that currently holds , and frees any different channel + /// previously held. /// /// The probe group to update. /// Zero-based index of the probe to update. /// Zero-based index of the contact within the probe. /// Hardware channel to assign. Must be >= 0. + /// + /// Thrown when is outside the range of 's probes. + /// + /// + /// Thrown when is out of range, is negative, + /// or the assignment would duplicate a channel already assigned on another probe. + /// public static void WireChannel(ProbeGroup group, int probeIndex, int contactIndex, int channel) => - WireChannels(group, probeIndex, new Dictionary { { contactIndex, channel } }); + WireChannels(group, probeIndex, new Dictionary { { channel, contactIndex } }); /// Removes all channel mappings across every probe in the group. /// The probe group to clear. public static void UnwireChannels(ProbeGroup group) { foreach (var probe in group.Probes) - probe.SetChannelMap(null); + probe.ChannelMap = null; } - /// Removes all channel mappings on the specified probe. + /// + /// Removes all channel mappings on the specified probe. + /// /// The probe group to update. /// Zero-based index of the probe to clear. - public static void UnwireChannels(ProbeGroup group, int probeIndex) => - group.Probes.ElementAt(probeIndex).SetChannelMap(null); + /// + /// Thrown when is outside the range of 's probes. + /// + public static void UnwireChannels(ProbeGroup group, int probeIndex) + { + group.Probes.ElementAt(probeIndex).ChannelMap = null; + } /// /// Removes the channel mapping for a set of contacts on the specified probe. @@ -116,15 +136,19 @@ public static void UnwireChannels(ProbeGroup group, int probeIndex) => /// The probe group to update. /// Zero-based index of the probe to update. /// Contact indices whose mappings should be removed. + /// + /// Thrown when is outside the range of 's probes. + /// public static void UnwireChannels(ProbeGroup group, int probeIndex, IEnumerable contactIndices) { var probe = group.Probes.ElementAt(probeIndex); if (probe.ChannelMap == null) return; + var contactSet = new HashSet(contactIndices); var map = probe.ChannelMap.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - foreach (var ci in contactIndices) - map.Remove(ci); - probe.SetChannelMap(map.Count > 0 ? map : null); + foreach (var ch in map.Where(e => contactSet.Contains(e.Value)).Select(e => e.Key).ToArray()) + map.Remove(ch); + probe.ChannelMap = map.Count > 0 ? map : null; } /// @@ -134,14 +158,18 @@ public static void UnwireChannels(ProbeGroup group, int probeIndex, IEnumerable< /// The probe group to update. /// Zero-based index of the probe to update. /// Zero-based index of the contact to unwire. + /// + /// Thrown when is outside the range of 's probes. + /// public static void UnwireChannel(ProbeGroup group, int probeIndex, int contactIndex) { var probe = group.Probes.ElementAt(probeIndex); if (probe.ChannelMap == null) return; var map = probe.ChannelMap.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - map.Remove(contactIndex); - probe.SetChannelMap(map.Count > 0 ? map : null); + foreach (var ch in map.Where(e => e.Value == contactIndex).Select(e => e.Key).ToArray()) + map.Remove(ch); + probe.ChannelMap = map.Count > 0 ? map : null; } } } diff --git a/OpenEphys.ProbeInterface.NET/Contact.cs b/OpenEphys.ProbeInterface.NET/Contact.cs index ada65c9..b18f33b 100644 --- a/OpenEphys.ProbeInterface.NET/Contact.cs +++ b/OpenEphys.ProbeInterface.NET/Contact.cs @@ -7,47 +7,63 @@ namespace OpenEphys.ProbeInterface.NET /// /// Encapsulates all per-contact data for a single electrode contact on a . /// Instances are created by during construction and cannot be created - /// externally. Contact-level annotations can be read and written via , - /// , and . Channel mapping is - /// managed at the level via . + /// externally. /// public sealed class Contact { - private readonly ContactAnnotationStore store; + private readonly ContactAnnotationStore annotationStore; private readonly int index; private readonly int totalContacts; - /// Gets the x-position of the contact centre. + /// + /// Gets the x-position of the contact centre. public double PosX { get; } - /// Gets the y-position of the contact centre. + /// + /// Gets the y-position of the contact centre. + /// public double PosY { get; } - /// Gets the z-position of the contact centre, or null for 2D probes. + /// + /// Gets the z-position of the contact centre, or null for 2D probes. + /// public double? PosZ { get; } - /// Gets the shape of the contact. + /// + /// Gets the shape of the contact. + /// public ContactShape Shape { get; } - /// Gets the shape parameters for the contact. + /// + /// Gets the shape parameters for the contact. + /// public ContactShapeParam ShapeParams { get; } - /// Gets the contact ID label, or null if the source JSON omitted contact_ids. Not guaranteed to be unique across probes. + /// + /// Gets the contact ID label, or null if the source JSON omitted contact_ids. Not guaranteed to be + /// unique across probes. + /// public string? ContactId { get; } - /// Gets the shank ID this contact belongs to, or null if the source JSON omitted shank_ids. + /// + /// Gets the shank ID this contact belongs to, or null if the source JSON omitted shank_ids. + /// public string? ShankId { get; } - /// Gets the contact plane axes as a 2×ndim matrix, or null if not specified. + /// + /// Gets the contact plane axes as a 2×ndim matrix, or null if not specified. + /// public double[][]? PlaneAxes { get; } - /// Gets the probe side this contact is on (e.g. "front", "back"), or null if not specified. + /// + /// Gets the probe side this contact is on (e.g. "front", "back"), or null if not specified. + /// public string? Side { get; } /// /// Initializes a new . Called by during construction. - /// is shared across all contacts on the same probe; mutations - /// through any contact are immediately visible probe-wide. + /// is shared across all contacts on the same probe; mutations through any + /// contact are immediately visible probe-wide. /// internal Contact( double posX, double posY, double? posZ, @@ -68,17 +84,23 @@ internal Contact( Side = side; this.index = index; this.totalContacts = totalContacts; - this.store = store; + this.annotationStore = store; } /// /// Returns the annotation value for this contact for the given key, converted to - /// . Returns the default value of if the - /// key is absent or the stored value is null. + /// . Returns the default value of if the key is + /// absent or the stored value is null. /// + /// The type to convert the stored value to. + /// The annotation key to look up. + /// + /// The annotation value converted to , or the default value of + /// if the key is absent or the value is null. + /// public T? GetAnnotation(string key) { - if (store.Data == null || !store.Data.TryGetValue(key, out var arr)) + if (annotationStore.Data == null || !annotationStore.Data.TryGetValue(key, out var arr)) return default; var value = arr[index]; if (value == null) return default; @@ -87,32 +109,36 @@ internal Contact( } /// - /// Sets the annotation value for this contact for the given key. If the key does not yet - /// exist in the probe's annotation store, a new array (length = total contacts on the - /// probe) is created with all other slots initialized to null. + /// Sets the annotation value for this contact for the given key. If the key does not yet exist in the + /// probe's annotation annotationStore, a new array (length = total contacts on the probe) is created with all + /// other slots initialized to null. /// + /// The type of the annotation value. + /// The annotation key to set. + /// The annotation value to annotationStore for this contact. public void SetAnnotation(string key, T value) { - store.Data ??= new Dictionary(); - if (!store.Data.TryGetValue(key, out var arr)) + annotationStore.Data ??= new Dictionary(); + if (!annotationStore.Data.TryGetValue(key, out var arr)) { arr = new object[totalContacts]; - store.Data[key] = arr; + annotationStore.Data[key] = arr; } arr[index] = value!; } /// /// Clears the annotation value for this contact for the given key (sets the slot to null). - /// Returns true if the key was found; false if it was absent. The key itself remains in - /// the store until all contacts' values for it are null. + /// The key itself is removed from the annotationStore once all contacts' values for it are null. /// + /// The annotation key to clear. + /// True if the key was found; false if it was absent. public bool RemoveAnnotation(string key) { - if (store.Data == null || !store.Data.TryGetValue(key, out var arr)) return false; + if (annotationStore.Data == null || !annotationStore.Data.TryGetValue(key, out var arr)) return false; arr[index] = null!; if (arr.All(v => v == null)) - store.Data.Remove(key); + annotationStore.Data.Remove(key); return true; } } diff --git a/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs b/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs index df838dd..ddbad62 100644 --- a/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs +++ b/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using System; +using Newtonsoft.Json; namespace OpenEphys.ProbeInterface.NET { @@ -6,7 +7,9 @@ namespace OpenEphys.ProbeInterface.NET /// Class holding parameters used to draw the contact. /// /// - /// Fields are nullable, since not all fields are required depending on the shape selected. + /// Fields are nullable, since not all fields are required depending on the shape selected. Per the + /// probeinterface schema, at least one of or must be specified, + /// and any of the three fields that are specified must be non-negative. /// public class ContactShapeParam { @@ -39,12 +42,28 @@ public class ContactShapeParam public double? Height { get; } /// - /// Initializes a new instance of the class. - /// Used by Newtonsoft.Json during deserialization. + /// Initializes a new instance of the class. Used by Newtonsoft.Json + /// during deserialization. /// + /// The radius of the contact. Must be non-negative if specified. + /// The width of the contact. Must be non-negative if specified. + /// The height of the contact. Must be non-negative if specified. + /// + /// Thrown when , , or is + /// negative, or when neither nor is specified. + /// [JsonConstructor] internal ContactShapeParam(double? radius = null, double? width = null, double? height = null) { + if (radius.HasValue && radius.Value < 0) + throw new ArgumentException($"radius must be >= 0, but was {radius.Value}."); + if (width.HasValue && width.Value < 0) + throw new ArgumentException($"width must be >= 0, but was {width.Value}."); + if (height.HasValue && height.Value < 0) + throw new ArgumentException($"height must be >= 0, but was {height.Value}."); + if (!radius.HasValue && !width.HasValue) + throw new ArgumentException("Either radius or width must be specified."); + Radius = radius; Width = width; Height = height; diff --git a/OpenEphys.ProbeInterface.NET/OpenEphys.ProbeInterface.NET.csproj b/OpenEphys.ProbeInterface.NET/OpenEphys.ProbeInterface.NET.csproj index d677d3d..9e551c7 100644 --- a/OpenEphys.ProbeInterface.NET/OpenEphys.ProbeInterface.NET.csproj +++ b/OpenEphys.ProbeInterface.NET/OpenEphys.ProbeInterface.NET.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/OpenEphys.ProbeInterface.NET/Probe.cs b/OpenEphys.ProbeInterface.NET/Probe.cs index 401f61f..c1c1b40 100644 --- a/OpenEphys.ProbeInterface.NET/Probe.cs +++ b/OpenEphys.ProbeInterface.NET/Probe.cs @@ -1,36 +1,44 @@ +using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; using System.Xml.Serialization; -using Newtonsoft.Json; namespace OpenEphys.ProbeInterface.NET { /// - /// Represents a single probe in a . - /// The primary public API is , which exposes all per-contact data as a - /// strongly-typed collection. Channel mapping is stored in and managed - /// exclusively via . JSON serialization/deserialization preserves the - /// probeinterface parallel-array format transparently. + /// Represents a single probe in a . The primary public API is + /// , which exposes all per-contact data as a strongly-typed collection. + /// Channel mapping is managed exclusively via and exposed through + /// or . + /// JSON serialization/deserialization preserves the probeinterface parallel-array format transparently. /// public class Probe { - /// Gets the number of spatial dimensions (2 or 3). + /// + /// Gets the number of spatial dimensions (2 or 3). + /// [XmlIgnore] [JsonProperty("ndim", Required = Required.Always)] public ProbeNdim NumDimensions { get; } - /// Gets the SI unit used for contact positions. + /// + /// Gets the SI unit used for contact positions. + /// [XmlIgnore] [JsonProperty("si_units", Required = Required.Always)] public ProbeSiUnits SiUnits { get; } - /// Gets the probe-level annotations (model name, manufacturer). + /// + /// Gets the probe-level annotations (model name, manufacturer). + /// [XmlIgnore] [JsonProperty("annotations", Required = Required.Always)] public ProbeAnnotations Annotations { get; } - /// Gets the planar contour describing the physical outline of the probe, or null. + /// + /// Gets the planar contour describing the physical outline of the probe, or null. + /// [XmlIgnore] [JsonProperty("probe_planar_contour", NullValueHandling = NullValueHandling.Ignore)] public double[][]? ProbePlanarContour { get; } @@ -42,29 +50,20 @@ public class Probe [JsonIgnore] public IReadOnlyList Contacts { get; } - /// Gets the number of contacts on this probe. + /// + /// Gets the number of contacts on this probe. + /// [JsonIgnore] public int NumberOfContacts => Contacts.Count; - /// Gets the contact annotation keys defined for this probe. - [JsonIgnore] - public IEnumerable ContactAnnotationKeys => - annotationStore.Data?.Keys ?? Enumerable.Empty(); - - private Dictionary? channelMap; - /// - /// Gets the channel mapping for this probe as a read-only dictionary mapping contact index - /// to hardware channel, or null if no mapping has been assigned. Contacts absent from the - /// dictionary are not connected. Managed exclusively via . + /// Gets the annotation keys that have at least one value defined across the probe's contacts. /// [JsonIgnore] - public IReadOnlyDictionary? ChannelMap => channelMap; - - /// Replaces the channel map. Called by to keep mapping consistent across all probes. - internal void SetChannelMap(Dictionary? map) => channelMap = map; + public IEnumerable ContactAnnotationKeys => + annotationStore.Data?.Keys ?? Enumerable.Empty(); - private readonly ContactAnnotationStore annotationStore = new ContactAnnotationStore(); + private readonly ContactAnnotationStore annotationStore = new(); // Newtonsoft.Json serializes non-public [JsonProperty] members; deserialization // goes through [JsonConstructor] so these getters are never called during reads. @@ -91,10 +90,10 @@ private int[]? DeviceChannelIndicesJson { get { - if (channelMap == null) return null; - var arr = new int[NumberOfContacts]; - for (int i = 0; i < arr.Length; i++) - arr[i] = channelMap.TryGetValue(i, out var ch) ? ch : -1; + if (ChannelMap == null) return null; + var arr = Enumerable.Repeat(-1, NumberOfContacts).ToArray(); + foreach (var kvp in ChannelMap) + arr[kvp.Value] = kvp.Key; return arr; } } @@ -117,8 +116,8 @@ private int[]? DeviceChannelIndicesJson /// /// JSON constructor. Deserializes a probe from the probeinterface parallel-array format and - /// builds the collection. Throws if any - /// parallel arrays have inconsistent lengths. + /// builds the collection. Throws if the + /// probeinterface json schema is not respected. /// [JsonConstructor] internal Probe( @@ -129,8 +128,12 @@ internal Probe( double[][]? probe_planar_contour, int[]? device_channel_indices, string[]? contact_ids, string[]? shank_ids, string[]? contact_sides) { + if (ndim != ProbeNdim.Two && ndim != ProbeNdim.Three) + throw new ArgumentException($"ndim must be 2 or 3, but was {(int)ndim}."); + int n = contact_positions.Length; + // Same number of contacts for every input array if (contact_shapes.Length != n || contact_shape_params.Length != n) throw new ArgumentException( $"contact_positions ({n}), contact_shapes ({contact_shapes.Length}), and " + @@ -161,22 +164,82 @@ internal Probe( } } + // Every position must have exactly ndim coordinates. + if (contact_positions.Any(x => x.Length != (int)ndim)) + throw new ArgumentException( + $"Every contact_positions entry must have exactly {(int)ndim} elements to match ndim."); + + // Per the schema, each contact_plane_axes entry is exactly 2 axis vectors, each with length + // matching ndim. + if (contact_plane_axes != null) + { + foreach (var axes in contact_plane_axes) + { + if (axes == null) + throw new ArgumentException("contact_plane_axes entries cannot be null."); + if (axes.Length != 2) + throw new ArgumentException( + $"Every contact_plane_axes entry must contain exactly 2 axis vectors, but found {axes.Length}."); + if (axes.Any(row => row.Length != (int)ndim)) + throw new ArgumentException( + $"Every contact_plane_axes axis vector must have exactly {(int)ndim} elements to match ndim."); + } + } + + // Planar contour points must also match ndim. + if (probe_planar_contour != null && probe_planar_contour.Any(row => row.Length != (int)ndim)) + throw new ArgumentException( + $"Every probe_planar_contour entry must have exactly {(int)ndim} elements to match ndim."); + + // Each contact's shape parameters must be consistent with its shape: circles need a radius, + // rects need both width and height, squares need a width. + for (int i = 0; i < n; i++) + { + var shape = contact_shapes[i]; + var shapeParams = contact_shape_params[i]; + switch (shape) + { + case ContactShape.Circle: + if (!shapeParams.Radius.HasValue) + throw new ArgumentException( + $"contact_shape_params[{i}] must specify radius for a circle contact."); + break; + case ContactShape.Rect: + if (!shapeParams.Width.HasValue || !shapeParams.Height.HasValue) + throw new ArgumentException( + $"contact_shape_params[{i}] must specify both width and height for a rect contact."); + break; + case ContactShape.Square: + if (!shapeParams.Width.HasValue) + throw new ArgumentException( + $"contact_shape_params[{i}] must specify width for a square contact."); + break; + } + } + NumDimensions = ndim; SiUnits = si_units; Annotations = annotations; ProbePlanarContour = probe_planar_contour; annotationStore.Data = contact_annotations; - // Convert the parallel array to a dictionary, skipping -1 (not connected) entries. + // Convert the parallel array to a channel to contact dictionary, skipping -1 (not connected) entries. + // Duplicate channels are detected here because the dict would silently overwrite them otherwise. if (device_channel_indices != null) { var map = new Dictionary(); + int nonNegative = 0; for (int i = 0; i < device_channel_indices.Length; i++) { if (device_channel_indices[i] != -1) - map[i] = device_channel_indices[i]; + { + map[device_channel_indices[i]] = i; + nonNegative++; + } } - channelMap = map.Count > 0 ? map : null; + if (map.Count < nonNegative) + throw new InvalidOperationException("device_channel_indices contains duplicate channel values."); + ChannelMap = map.Count > 0 ? map : null; } Contacts = BuildContacts(n, contact_positions, contact_plane_axes, contact_shapes, @@ -189,10 +252,10 @@ internal Probe( /// through any contact are immediately visible probe-wide. /// private static Contact[] BuildContacts( - int n, double[][] positions, double[][][]? planeAxes, + int n, double[][] positions, double[][]?[]? planeAxes, ContactShape[] shapes, ContactShapeParam[] shapeParams, - string[]? contactIds, string[]? shankIds, - string[]? contactSides, ContactAnnotationStore store) + string?[]? contactIds, string?[]? shankIds, + string?[]? contactSides, ContactAnnotationStore store) { var contacts = new Contact[n]; for (int i = 0; i < n; i++) @@ -216,24 +279,91 @@ private static Contact[] BuildContacts( } /// - /// Returns a dictionary mapping each assigned hardware channel to a tuple of - /// (contact index within this probe, ), or null if no channels - /// have been assigned on this probe. + /// Deep copy constructor. Produces a fully independent probe with its own channel map, contact + /// annotation store, and contact objects. /// - public IReadOnlyDictionary? GetChannelMap() + internal Probe(Probe source) { - if (channelMap == null) return null; - var result = new Dictionary(); - foreach (var kvp in channelMap) - result[kvp.Value] = (kvp.Key, Contacts[kvp.Key]); - return result.Count > 0 ? result : null; + NumDimensions = source.NumDimensions; + SiUnits = source.SiUnits; + Annotations = new ProbeAnnotations(source.Annotations); + ProbePlanarContour = source.ProbePlanarContour?.Select(row => (double[])row.Clone()).ToArray(); + + ChannelMap = source.ChannelMap?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + var newStore = new ContactAnnotationStore(); + if (source.annotationStore.Data != null) + newStore.Data = source.annotationStore.Data.ToDictionary( + kvp => kvp.Key, + kvp => (object[])kvp.Value.Clone()); + annotationStore = newStore; + + int n = source.NumberOfContacts; + var positions = source.Contacts.Select(c => + c.PosZ.HasValue + ? new double[] { c.PosX, c.PosY, c.PosZ.Value } + : new double[] { c.PosX, c.PosY }).ToArray(); + var planeAxes = source.Contacts.All(c => c.PlaneAxes == null) ? null + : source.Contacts.Select(c => c.PlaneAxes?.Select(row => (double[])row.Clone()).ToArray()).ToArray(); + var shapes = source.Contacts.Select(c => c.Shape).ToArray(); + var shapeParams = source.Contacts.Select(c => c.ShapeParams).ToArray(); + var contactIds = source.Contacts.All(c => c.ContactId == null) ? null + : source.Contacts.Select(c => c.ContactId).ToArray(); + var shankIds = source.Contacts.All(c => c.ShankId == null) ? null + : source.Contacts.Select(c => c.ShankId).ToArray(); + var contactSides = source.Contacts.All(c => c.Side == null) ? null + : source.Contacts.Select(c => c.Side).ToArray(); + + Contacts = BuildContacts(n, positions, planeAxes, shapes, shapeParams, + contactIds, shankIds, contactSides, newStore); + } + + /// + /// Gets the channel mapping for this probe as a dictionary mapping hardware channel to contact index, + /// or null if no channels are assigned. Internal: public access is through the containing or . Managed exclusively via . + /// + [JsonIgnore] + internal IReadOnlyDictionary? ChannelMap { get; set; } + + /// + /// Gets the hardware channel assigned to the contact at . + /// + /// Zero-based index of the contact within this probe. + /// + /// When this method returns true, contains the hardware channel assigned to the contact. When this + /// method returns false, contains -1. + /// + /// True if the contact is assigned to a channel; otherwise false. + internal bool TryGetMappedChannel(int contactIndex, out int channel) + { + if (ChannelMap != null) + { + foreach (var kvp in ChannelMap) + { + if (kvp.Value == contactIndex) + { + channel = kvp.Key; + return true; + } + } + } + channel = -1; + return false; } /// /// Returns all per-contact values for the given annotation key as an array of - /// , or null if the key is absent from the probe entirely. - /// Contacts that have no value for the key yield the default of . + /// , or null if the key is absent from the probe entirely. Contacts that have + /// no value for the key yield the default of . /// + /// The type to convert each stored value to. + /// The annotation key to retrieve. + /// + /// An array of length containing each contact's value, or null if the + /// key is absent from this probe. + /// public T[]? GetContactAnnotation(string key) { if (annotationStore.Data == null || !annotationStore.Data.TryGetValue(key, out var values) || values == null) @@ -243,11 +373,17 @@ private static Contact[] BuildContacts( } /// - /// Adds or replaces a per-contact annotation for the given key. - /// must contain one element per contact. Null elements are permitted for contacts without - /// a value. Per-contact reflects the update - /// immediately. + /// Adds or replaces a per-contact annotation for the given key. must + /// contain one element per contact. Null elements are permitted for contacts without a value. + /// Per-contact reflects the update immediately. /// + /// The type of the annotation values. + /// The annotation key to set. + /// An array of length with one value per + /// contact. + /// + /// Thrown when .Length does not equal . + /// public void SetContactAnnotation(string key, T[] values) { if (values.Length != NumberOfContacts) @@ -261,8 +397,9 @@ public void SetContactAnnotation(string key, T[] values) /// /// Removes the per-contact annotation with the given key entirely. - /// Returns true if the key was found and removed. /// + /// The annotation key to remove. + /// True if the key was found and removed; false if it was absent. public bool RemoveContactAnnotation(string key) => annotationStore.Data != null && annotationStore.Data.Remove(key); } diff --git a/OpenEphys.ProbeInterface.NET/ProbeAnnotations.cs b/OpenEphys.ProbeInterface.NET/ProbeAnnotations.cs index d0ee774..093c1d7 100644 --- a/OpenEphys.ProbeInterface.NET/ProbeAnnotations.cs +++ b/OpenEphys.ProbeInterface.NET/ProbeAnnotations.cs @@ -6,25 +6,31 @@ namespace OpenEphys.ProbeInterface.NET { /// - /// Probe-level annotations. and are required - /// by the spec; any additional key-value pairs are stored in - /// and accessible via , , and - /// . + /// Probe-level annotations. and are required by the + /// spec; any additional key-value pairs are stored in and accessible + /// via , , and . /// public class ProbeAnnotations { - /// Gets the model name of the probe as defined by the manufacturer. - [JsonProperty("model_name")] + /// + /// Gets the model name of the probe as defined by the manufacturer. + /// + [JsonProperty("model_name", Required = Required.Always)] public string ModelName { get; } - /// Gets the name of the manufacturer who created the probe. - [JsonProperty("manufacturer")] + /// + /// Gets the name of the manufacturer who created the probe. + /// + [JsonProperty("manufacturer", Required = Required.Always)] public string Manufacturer { get; } [JsonExtensionData] private Dictionary? additionalProperties; - /// Gets the keys of all additional annotations present on this probe. + /// + /// Gets the keys of all additional annotations present on this probe. + /// [JsonIgnore] public IEnumerable AnnotationKeys => additionalProperties?.Keys ?? Enumerable.Empty(); @@ -39,10 +45,27 @@ internal ProbeAnnotations(string model_name, string manufacturer) Manufacturer = manufacturer; } + /// + /// Deep copy constructor. + /// + internal ProbeAnnotations(ProbeAnnotations source) + { + ModelName = source.ModelName; + Manufacturer = source.Manufacturer; + if (source.additionalProperties != null) + additionalProperties = new Dictionary(source.additionalProperties); + } + /// /// Returns an additional annotation value for the given key converted to /// , or the default value of if absent. /// + /// The type to convert the stored value to. + /// The annotation key to look up. + /// + /// The annotation value converted to , or the default value of + /// if the key is absent. + /// public T? GetAnnotation(string key) { if (additionalProperties == null || !additionalProperties.TryGetValue(key, out var token)) @@ -53,6 +76,9 @@ internal ProbeAnnotations(string model_name, string manufacturer) /// /// Adds or replaces an additional annotation for the given key. /// + /// The type of the annotation value. + /// The annotation key to set. + /// The value to store. public void SetAnnotation(string key, T value) { additionalProperties ??= new Dictionary(); @@ -61,8 +87,9 @@ public void SetAnnotation(string key, T value) /// /// Removes the additional annotation with the given key. - /// Returns true if the key was found and removed. /// + /// The annotation key to remove. + /// True if the key was found and removed; false if it was absent. public bool RemoveAnnotation(string key) => additionalProperties != null && additionalProperties.Remove(key); } diff --git a/OpenEphys.ProbeInterface.NET/ProbeGroup.cs b/OpenEphys.ProbeInterface.NET/ProbeGroup.cs index fb53257..96e735c 100644 --- a/OpenEphys.ProbeInterface.NET/ProbeGroup.cs +++ b/OpenEphys.ProbeInterface.NET/ProbeGroup.cs @@ -14,36 +14,54 @@ public class ProbeGroup { private static readonly Regex VersionPattern = new(@"^\d+\.\d+\.\d+$", RegexOptions.Compiled); - /// The probeinterface specification version implemented by this library. - public static readonly Version SupportedSpecVersion = new Version(0, 3, 2); + /// + /// The probeinterface specification version implemented by this library. + /// + public static readonly Version SupportedSpecVersion = new(0, 3, 2); - /// Gets the specification identifier. Must be "probeinterface". + /// + /// Gets the specification identifier. Must be "probeinterface". + /// [JsonProperty("specification", Required = Required.Always)] public string Specification { get; } - /// Gets the probeinterface version string (major.minor.patch). + /// + /// Gets the probeinterface version string (major.minor.patch). + /// [JsonProperty("version", Required = Required.Always)] public string Version { get; } /// - /// Gets the probes in this group. Use on each probe for - /// per-contact data and for the channel mapping. + /// Gets the probes in this group. /// [XmlIgnore] [JsonProperty("probes", Required = Required.Always)] public IEnumerable Probes { get; } - /// Gets the total number of contacts across all probes. + /// + /// Gets the number of probes in this group. + /// + [JsonIgnore] + public int NumberOfProbes => Probes.Count(); + + /// + /// Gets the total number of contacts across all probes. + /// [JsonIgnore] public int NumberOfContacts => Probes.Sum(p => p.NumberOfContacts); /// - /// Initializes a and immediately validates it. - /// Used by Newtonsoft.Json during deserialization. + /// Initializes a and immediately validates it. Used by Newtonsoft.Json + /// during deserialization. /// /// Must be "probeinterface". /// Semver string (major.minor.patch). /// One or more probes. + /// + /// Thrown when is not "probeinterface", + /// is malformed or incompatible with this library, is null or empty, or + /// channel indices are not unique across probes. + /// [JsonConstructor] protected ProbeGroup(string specification, string version, IEnumerable probes) { @@ -53,20 +71,28 @@ protected ProbeGroup(string specification, string version, IEnumerable pr Validate(); } - /// Protected copy constructor for subclasses. + /// + /// Deep copy constructor. Produces a fully independent instance: each probe, its channel map, and its + /// contact annotations are cloned. + /// + /// The source group to copy from. + /// + /// Thrown when channel indices are not unique across probes (same conditions as the primary + /// constructor). + /// protected ProbeGroup(ProbeGroup probeGroup) { Specification = probeGroup.Specification; Version = probeGroup.Version; - Probes = probeGroup.Probes; + Probes = probeGroup.Probes.Select(p => new Probe(p)).ToArray(); Validate(); } /// - /// Validates the group against the probeinterface specification. Throws - /// if the specification string, version format, probe - /// count, or channel index uniqueness are invalid. - /// Per-contact array length consistency is validated by 's constructor. + /// Validates the group against the probeinterface specification. Throws if the specification string, version format, probe count, or + /// channel index uniqueness are invalid. Per-contact array length consistency is validated by 's constructor. /// private void Validate() { @@ -92,39 +118,61 @@ private void Validate() } /// - /// Returns true if all assigned channel indices are unique across all probes. - /// Probes with no channel mapping assigned are excluded from the check. - /// Called by on construction and by after mutations. + /// Returns true if all assigned channel indices are unique across all probes. Probes with no channel + /// mapping assigned are excluded from the check. Called by on construction and + /// by after mutations. /// internal bool ValidateDeviceChannelIndices() { var active = Probes .Where(p => p.ChannelMap != null) - .SelectMany(p => p.ChannelMap!.Values) + .SelectMany(p => p.ChannelMap!.Keys) .ToList(); return active.Count == active.Distinct().Count(); } /// - /// Returns a dictionary mapping each assigned hardware channel to a tuple of - /// (probe index, contact index within that probe, ), across all probes - /// in the group, or null if no channels have been assigned anywhere. + /// Gets the channel mapping across all probes as a read-only dictionary mapping hardware channel to + /// (probe index, contact index within that probe), or null if no channels have been assigned + /// anywhere. Use and to look up the for a given entry. /// - public IReadOnlyDictionary? GetChannelMap() + [JsonIgnore] + public IReadOnlyDictionary? ChannelMap { - var result = new Dictionary(); - int probeIndex = 0; - foreach (var probe in Probes) + get { - var perProbe = probe.GetChannelMap(); - if (perProbe != null) + var result = new Dictionary(); + int probeIndex = 0; + foreach (var probe in Probes) { - foreach (var kvp in perProbe) - result[kvp.Key] = (probeIndex, kvp.Value.ContactIndex, kvp.Value.Contact); + var perProbe = probe.ChannelMap; + if (perProbe != null) + { + foreach (var kvp in perProbe) + result[kvp.Key] = (probeIndex, kvp.Value); + } + probeIndex++; } - probeIndex++; + return result.Count > 0 ? result : null; } - return result.Count > 0 ? result : null; } + + /// + /// Gets the hardware channel assigned to the contact at on the probe + /// at . + /// + /// Zero-based index of the probe within this group. + /// Zero-based index of the contact within that probe. + /// + /// When this method returns true, contains the hardware channel assigned to the contact. When this + /// method returns false, contains -1. + /// + /// True if the contact is assigned to a channel; otherwise false. + /// + /// Thrown when is outside the range of . + /// + public bool TryGetMappedChannel(int probeIndex, int contactIndex, out int channel) => + Probes.ElementAt(probeIndex).TryGetMappedChannel(contactIndex, out channel); } } diff --git a/OpenEphys.ProbeInterface.NET/SingleProbeGroup.cs b/OpenEphys.ProbeInterface.NET/SingleProbeGroup.cs new file mode 100644 index 0000000..fb54dcf --- /dev/null +++ b/OpenEphys.ProbeInterface.NET/SingleProbeGroup.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace OpenEphys.ProbeInterface.NET +{ + /// + /// Base class for probe groups that always contain exactly one probe. Enforces the single-probe invariant + /// and provides , , and + /// without requiring a probe-index argument. + /// + public abstract class SingleProbeGroup : ProbeGroup + { + /// + /// Initializes a new instance of from deserialized + /// probeinterface data. Throws if + /// does not contain exactly one probe. + /// + protected SingleProbeGroup(string specification, string version, IEnumerable probes) + : base(specification, version, probes) + { + if (Probes.Count() != 1) + throw new ArgumentException( + $"A {GetType().Name} must contain exactly one probe, but {Probes.Count()} were provided."); + } + + /// + /// Copy constructor. Throws if + /// does not contain exactly one probe. + /// + protected SingleProbeGroup(ProbeGroup probeGroup) + : base(probeGroup) + { + if (Probes.Count() != 1) + throw new ArgumentException( + $"A {GetType().Name} must contain exactly one probe, but {Probes.Count()} were provided."); + } + + /// + /// Gets the single probe in this group. + /// + [JsonIgnore] + public Probe Probe => Probes.First(); + + /// + /// Gets the channel mapping for the probe as a read-only dictionary mapping hardware channel + /// to contact index, or null if no channels are assigned. + /// + [JsonIgnore] + public new IReadOnlyDictionary? ChannelMap => Probe.ChannelMap; + + /// + /// Gets the hardware channel assigned to the contact at within the + /// the single probe in this group. + /// + /// Zero-based index of the contact within the probe. + /// + /// When this method returns true, contains the assigned hardware channel. When this method returns + /// false, contains -1. + /// + /// True if the contact is assigned to a channel; otherwise false. + public bool TryGetMappedChannel(int contactIndex, out int channel) => + Probe.TryGetMappedChannel(contactIndex, out channel); + } +} diff --git a/README.md b/README.md index 5d0b45b..8ae4e6c 100644 --- a/README.md +++ b/README.md @@ -53,17 +53,20 @@ Console.WriteLine(probe0.Annotations.ModelName); probe0.Annotations.SetAnnotation("implant_date", "2025-01-01"); string? date = probe0.Annotations.GetAnnotation("implant_date"); -// Wire hardware channels to contacts (contact index → channel number) +// Wire hardware channels to contacts (channel number → contact index) // Validates uniqueness within and across all probes in the group ChannelWiring.WireChannels(probeGroup, probeIndex: 0, new Dictionary { { 0, 3 }, { 1, 1 }, { 2, 2 }, { 3, 0 } }); -// Query the resulting channel map (channel number → probe/contact/Contact) -var map = probeGroup.GetChannelMap(); -foreach (var (channel, entry) in map) - Console.WriteLine($"Channel {channel} → probe {entry.ProbeIndex}, contact {entry.ContactIndex}"); +// Query the resulting channel map (channel number → probe index, contact index) +var map = probeGroup.ChannelMap; +if (map != null) +{ + foreach (var (channel, entry) in map) + Console.WriteLine($"Channel {channel} → probe {entry.ProbeIndex}, contact {entry.ContactIndex}"); +} // Wire a single contact, or clear the mapping when done ChannelWiring.WireChannel(probeGroup, probeIndex: 0, contactIndex: 4, channel: 7);