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);