diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 20cbd1c..bec3799 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,16 +1,31 @@ name: Build .NET and Publish to Nuget -# This workflow will run when: 1) any commit is pushed to main, 2) any pull request is opened that will merge to main, and 3) whenever a new release is published. +# This workflow will run when: 1) any commit is pushed to main, 2) any pull request is opened that +# will merge to main, and 3) whenever a new release is published. on: push: - branches: [main] # 1) Generates a package on Github that is a pre-release package, and is typically named X.Y.Z-main-ci000, where X/Y/Z are the semantic version numbers, and ci000 is incremented for each action that is run, guaranteeing a unique package name + branches: [main] # 1) Generates a pre-release package named X.Y.Z-main-ci000 pull_request: - branches: [main] # 2) Does not generate a package, but does check that the semantic version number is increasing, and that the package builds correctly in all matrix configurations (Ubuntu / Windows and Release / Debug) + branches: [main] # 2) Checks version, build, and tests . Does not generate a package release: - types: [published] # 3) Generates a package that is a full release package (X.Y.Z) that is published to Github and NuGet automatically + types: [published] # 3) Generates a full release package (X.Y.Z) published to GitHub and NuGet jobs: + test: + name: Run Tests + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + - name: Run tests + run: dotnet test --configuration Release + build_and_publish: + needs: test uses: open-ephys/github-actions/.github/workflows/build_dotnet_publish_nuget.yml@main secrets: - NUGET_APIKEY: ${{ secrets.NUGET_APIKEY }} \ No newline at end of file + NUGET_APIKEY: ${{ secrets.NUGET_APIKEY }} diff --git a/.gitignore b/.gitignore index 8a30d25..6333bec 100644 --- a/.gitignore +++ b/.gitignore @@ -351,6 +351,7 @@ ASALocalRun/ # MSBuild Binary and Structured Log *.binlog +*.trx # NVidia Nsight GPU debugger configuration file *.nvuser diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9dc7b40 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "OpenEphys.ProbeInterface.NET.Tests/probeinterface_library"] + path = OpenEphys.ProbeInterface.NET.Tests/probeinterface_library + url = https://github.com/SpikeInterface/probeinterface_library diff --git a/Directory.Build.props b/Directory.Build.props index aa65b9e..d8ffc35 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ Open Ephys - Copyright © Open Ephys and Contributors 2024 + Copyright © Open Ephys true snupkg true @@ -12,7 +12,7 @@ LICENSE true icon.png - 0.3.0 + 0.4.0 10.0 strict diff --git a/LICENSE b/LICENSE index 91e5007..00bdd25 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024 Open Ephys and Contributors +Copyright (c) Open Ephys and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/OpenEphys.ProbeInterface.NET.Tests/ContactAnnotationsTests.cs b/OpenEphys.ProbeInterface.NET.Tests/ContactAnnotationsTests.cs new file mode 100644 index 0000000..8545880 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/ContactAnnotationsTests.cs @@ -0,0 +1,263 @@ +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace OpenEphys.ProbeInterface.NET.Tests +{ + public class ContactAnnotationsTests + { + private readonly string Json = $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { "model_name": "TestProbe", "manufacturer": "TestMfg" }, + "contact_annotations": { + "brain_area": ["CA1", "CA1", "DG"], + "custom_label": ["a", "b", "c"], + "impedance": [125.3, 98.7, 110.1] + }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0], [16.0, 10.0]], + "contact_shapes": ["circle", "circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}, {"radius": 5.0}], + "contact_ids": ["0", "1", "2"], + "shank_ids": ["", "", ""], + "device_channel_indices": [0, 1, 2] + } + ] + } + """; + + [Fact] + public void StringAnnotation_TypedAccessor() + { + var group = JsonConvert.DeserializeObject(Json)!; + var areas = group.Probes.First().GetContactAnnotation("brain_area"); + Assert.Equal(new[] { "CA1", "CA1", "DG" }, areas); + } + + [Fact] + public void NumericAnnotation_TypedAccessor_Double() + { + var group = JsonConvert.DeserializeObject(Json)!; + var impedances = group.Probes.First().GetContactAnnotation("impedance"); + Assert.Equal(new[] { 125.3, 98.7, 110.1 }, impedances); + } + + [Fact] + public void NumericAnnotation_TypedAccessor_NullableDouble() + { + var group = JsonConvert.DeserializeObject(Json)!; + var impedances = group.Probes.First().GetContactAnnotation("impedance"); + Assert.Equal(new double?[] { 125.3, 98.7, 110.1 }, impedances); + } + + [Fact] + public void NumericAnnotation_TypedAccessor_Float() + { + var group = JsonConvert.DeserializeObject(Json)!; + var impedances = group.Probes.First().GetContactAnnotation("impedance"); + Assert.Equal(3, impedances!.Length); + Assert.Equal(125.3, (double)impedances[0], precision: 1); + } + + [Fact] + public void NumericAnnotation_RoundTrip_PreservesNumbers() + { + var group = JsonConvert.DeserializeObject(Json)!; + var serialized = JsonConvert.SerializeObject(group); + var parsed = JToken.Parse(serialized); + var impedanceToken = parsed["probes"]![0]!["contact_annotations"]!["impedance"]!; + // Values must serialize as JSON numbers, not strings + Assert.Equal(JTokenType.Float, impedanceToken[0]!.Type); + Assert.Equal(125.3, impedanceToken[0]!.Value(), 3); + } + + [Fact] + public void ArbitraryKeys_RoundTrip() + { + var group = JsonConvert.DeserializeObject(Json)!; + var serialized = JsonConvert.SerializeObject(group); + var roundTrip = JsonConvert.DeserializeObject(serialized)!; + var labels = roundTrip.Probes.First().GetContactAnnotation("custom_label"); + Assert.Equal(new[] { "a", "b", "c" }, labels); + } + + [Fact] + public void MissingKey_ReturnsNull() + { + var group = JsonConvert.DeserializeObject(Json)!; + Assert.Null(group.Probes.First().GetContactAnnotation("nonexistent")); + } + + [Fact] + public void NoContactAnnotations_DoesNotThrow() + { + string jsonNoAnnotations = $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { "model_name": "TestProbe", "manufacturer": "TestMfg" }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}], + "contact_ids": ["0"], + "shank_ids": [""], + "device_channel_indices": [0] + } + ] + } + """; + var group = JsonConvert.DeserializeObject(jsonNoAnnotations)!; + Assert.Empty(group.Probes.First().ContactAnnotationKeys); + Assert.Null(group.Probes.First().GetContactAnnotation("brain_area")); + } + + [Fact] + public void SetContactAnnotation_String_CanBeReadBack() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + probe.SetContactAnnotation("region", new[] { "CA3", "CA3", "CA1" }); + Assert.Equal(new[] { "CA3", "CA3", "CA1" }, probe.GetContactAnnotation("region")); + } + + [Fact] + public void SetContactAnnotation_Numeric_RoundTrips() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + probe.SetContactAnnotation("gain", new double[] { 1.0, 2.5, 3.0 }); + var serialized = JsonConvert.SerializeObject(group); + var reloaded = JsonConvert.DeserializeObject(serialized)!; + var gain = reloaded.Probes.First().GetContactAnnotation("gain"); + Assert.Equal(new[] { 1.0, 2.5, 3.0 }, gain); + } + + [Fact] + public void SetContactAnnotation_InitializesStoreWhenEmpty() + { + string bare = $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [{ + "ndim": 2, "si_units": "um", + "annotations": { "model_name": "P", "manufacturer": "M" }, + "contact_positions": [[0.0,0.0],[0.0,20.0]], + "contact_shapes": ["circle","circle"], + "contact_shape_params": [{"radius":5.0},{"radius":5.0}], + "contact_ids": ["0","1"], "shank_ids": ["",""], + "device_channel_indices": [0,1] + }] + } + """; + var probe = JsonConvert.DeserializeObject(bare)!.Probes.First(); + Assert.Empty(probe.ContactAnnotationKeys); + probe.SetContactAnnotation("label", new[] { "a", "b" }); + Assert.Contains("label", probe.ContactAnnotationKeys); + Assert.Equal(new[] { "a", "b" }, probe.GetContactAnnotation("label")); + } + + [Fact] + public void SetContactAnnotation_WrongLength_Throws() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); // 3 contacts + Assert.Throws(() => + probe.SetContactAnnotation("bad", new[] { "only_two", "values" })); + } + + [Fact] + public void RemoveContactAnnotation_RemovesKey() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + Assert.True(probe.RemoveContactAnnotation("brain_area")); + Assert.Null(probe.GetContactAnnotation("brain_area")); + } + + [Fact] + public void RemoveContactAnnotation_MissingKey_ReturnsFalse() + { + var group = JsonConvert.DeserializeObject(Json)!; + Assert.False(group.Probes.First().RemoveContactAnnotation("nonexistent")); + } + + [Fact] + public void PerContact_SetAnnotation_CanBeReadBack() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + var contact = probe.Contacts[1]; + contact.SetAnnotation("gain", 3.5); + Assert.Equal(3.5, contact.GetAnnotation("gain")); + // Visible at probe level too + var all = probe.GetContactAnnotation("gain"); + Assert.NotNull(all); + Assert.Equal(3.5, all![1]); + } + + [Fact] + public void PerContact_RemoveAnnotation_ClearsSlot() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + var contact = probe.Contacts[0]; + Assert.True(contact.RemoveAnnotation("brain_area")); + Assert.Null(contact.GetAnnotation("brain_area")); + // Key still present (other contacts still have values); probe-level array has null at [0] + var all = probe.GetContactAnnotation("brain_area"); + Assert.NotNull(all); + Assert.Null(all![0]); + Assert.Equal("CA1", all[1]); + } + + [Fact] + public void PerContact_RemoveAnnotation_AllNull_RemovesKeyFromStore() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + // Remove "brain_area" from every contact — key should disappear from the store + foreach (var contact in probe.Contacts) + contact.RemoveAnnotation("brain_area"); + Assert.DoesNotContain("brain_area", probe.ContactAnnotationKeys); + Assert.Null(probe.GetContactAnnotation("brain_area")); + } + + [Fact] + public void PerContact_SetAnnotation_NewKey_OtherContactsGetDefault() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + probe.Contacts[0].SetAnnotation("new_key", "only_first"); + var all = probe.GetContactAnnotation("new_key"); + Assert.NotNull(all); + Assert.Equal("only_first", all![0]); + Assert.Null(all[1]); + Assert.Null(all[2]); + } + + [Fact] + public void PerContact_SetAnnotation_PartialAnnotation_RoundTrips() + { + var group = JsonConvert.DeserializeObject(Json)!; + var probe = group.Probes.First(); + probe.Contacts[0].SetAnnotation("partial", "present"); + var serialized = JsonConvert.SerializeObject(group); + var parsed = JToken.Parse(serialized); + var arr = parsed["probes"]![0]!["contact_annotations"]!["partial"]!; + Assert.Equal("present", arr[0]!.Value()); + Assert.Equal(JTokenType.Null, arr[1]!.Type); + Assert.Equal(JTokenType.Null, arr[2]!.Type); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/FixtureHelper.cs b/OpenEphys.ProbeInterface.NET.Tests/FixtureHelper.cs new file mode 100644 index 0000000..c5a3cf4 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/FixtureHelper.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace OpenEphys.ProbeInterface.NET.Tests +{ + internal static class FixtureHelper + { + private static readonly Assembly Assembly = typeof(FixtureHelper).Assembly; + + public static string LoadFixture(string fileName) + { + var resourceName = $"OpenEphys.ProbeInterface.NET.Tests.Fixtures.{fileName}"; + using var stream = Assembly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + public static IEnumerable GetProbeLibraryResourceNames() => + Assembly.GetManifestResourceNames() + .Where(n => n.Contains(".probeinterface_library.") && n.EndsWith(".json")); + + public static string LoadProbeLibraryFile(string resourceName) + { + using var stream = Assembly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/Fixtures/minimal_probe.json b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/minimal_probe.json new file mode 100644 index 0000000..b2b8c0b --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/minimal_probe.json @@ -0,0 +1,20 @@ +{ + "specification": "probeinterface", + "version": "0.3.2", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "TestProbe", + "manufacturer": "TestManufacturer" + }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0], [16.0, 10.0]], + "contact_shapes": ["circle", "circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}, {"radius": 5.0}], + "contact_ids": ["0", "1", "2"], + "shank_ids": ["", "", ""], + "device_channel_indices": [0, 1, 2] + } + ] +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/Fixtures/multi_probe_group.json b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/multi_probe_group.json new file mode 100644 index 0000000..75071dd --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/multi_probe_group.json @@ -0,0 +1,34 @@ +{ + "specification": "probeinterface", + "version": "0.3.2", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "TestProbeA", + "manufacturer": "TestManufacturer" + }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0]], + "contact_shapes": ["circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}], + "contact_ids": ["0", "1"], + "shank_ids": ["", ""], + "device_channel_indices": [0, 1] + }, + { + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "TestProbeB", + "manufacturer": "TestManufacturer" + }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0], [16.0, 10.0]], + "contact_shapes": ["rect", "rect", "rect"], + "contact_shape_params": [{"width": 10.0, "height": 5.0}, {"width": 10.0, "height": 5.0}, {"width": 10.0, "height": 5.0}], + "contact_ids": ["0", "1", "2"], + "shank_ids": ["", "", ""], + "device_channel_indices": [2, 3, 4] + } + ] +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe.json.schema b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe.json.schema new file mode 100644 index 0000000..ff8e2d2 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe.json.schema @@ -0,0 +1,129 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "Validates the probeinterface specification version 0.3.2. See https://probeinterface.readthedocs.io", + "type": "object", + "properties": { + "specification": { + "type": "string", + "value": "probeinterface" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "probes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ndim": { + "type": "integer", + "enum": [2, 3] + }, + "si_units": { + "type": "string", + "enum": ["mm","um"] + }, + "annotations": { + "type": "object", + "properties": { + "model_name": { "type": "string" }, + "manufacturer": { "type": "string" } + }, + "required": ["model_name", "manufacturer"], + "additionalProperties": true + }, + "contact_annotations": { + "type": "object", + "additionalProperties": true + }, + "contact_positions": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + } + }, + "contact_plane_axes": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 3 + }, + "minItems": 2, + "maxItems": 2 + } + }, + "contact_shapes": { + "type": "array", + "items": { "type": "string", "enum": ["circle", "rect", "square"] } + }, + "contact_shape_params": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { "radius": { "type": "number", "minimum": 0 } }, + "required": ["radius"] + }, + { + "type": "object", + "properties": { + "width": { "type": "number", "minimum": 0 }, + "height": { "type": "number", "minimum": 0 } + }, + "required": ["width"] + } + ] + } + }, + "probe_planar_contour": { + "type": "array", + "items": { + "type": "array", + "items": { "type": ["integer", "number"] }, + "minItems": 2, + "maxItems": 3 + } + }, + "contact_ids": { + "type": "array", + "items": { "type": "string" } + }, + "shank_ids": { + "type": "array", + "items": { "type": "string" } + }, + "contact_sides": { + "type": "array", + "items": { "type": "string" } + }, + "device_channel_indices": { + "type": "array", + "items": { "type": "integer" } + } + }, + "required": [ + "ndim", + "si_units", + "annotations", + "contact_positions", + "contact_shapes", + "contact_shape_params" + ], + "additionalProperties": false + } + } + }, + "required": ["specification", "version", "probes"], + "additionalProperties": false +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_3d.json b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_3d.json new file mode 100644 index 0000000..c143588 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_3d.json @@ -0,0 +1,20 @@ +{ + "specification": "probeinterface", + "version": "0.3.2", + "probes": [ + { + "ndim": 3, + "si_units": "um", + "annotations": { + "model_name": "TestProbe3D", + "manufacturer": "TestManufacturer" + }, + "contact_positions": [[0.0, 0.0, 0.0], [0.0, 20.0, 5.0], [16.0, 10.0, 10.0]], + "contact_shapes": ["circle", "circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}, {"radius": 5.0}], + "contact_ids": ["0", "1", "2"], + "shank_ids": ["", "", ""], + "device_channel_indices": [0, 1, 2] + } + ] +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_contact_annotations.json b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_contact_annotations.json new file mode 100644 index 0000000..7fd7459 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_contact_annotations.json @@ -0,0 +1,25 @@ +{ + "specification": "probeinterface", + "version": "0.3.2", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "TestProbe", + "manufacturer": "TestManufacturer" + }, + "contact_annotations": { + "brain_area": ["CA1", "CA1", "DG"], + "channel_names": ["ch0", "ch1", "ch2"], + "impedance": [125.3, 98.7, 110.1] + }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0], [16.0, 10.0]], + "contact_shapes": ["circle", "circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}, {"radius": 5.0}], + "contact_ids": ["0", "1", "2"], + "shank_ids": ["", "", ""], + "device_channel_indices": [0, 1, 2] + } + ] +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_contact_sides.json b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_contact_sides.json new file mode 100644 index 0000000..dc75192 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_contact_sides.json @@ -0,0 +1,21 @@ +{ + "specification": "probeinterface", + "version": "0.3.2", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "TestProbe", + "manufacturer": "TestManufacturer" + }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0], [16.0, 10.0]], + "contact_shapes": ["circle", "circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}, {"radius": 5.0}], + "contact_sides": ["front", "front", "back"], + "contact_ids": ["0", "1", "2"], + "shank_ids": ["", "", ""], + "device_channel_indices": [0, 1, 2] + } + ] +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_nonnumeric_ids.json b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_nonnumeric_ids.json new file mode 100644 index 0000000..0d48c2b --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/Fixtures/probe_with_nonnumeric_ids.json @@ -0,0 +1,20 @@ +{ + "specification": "probeinterface", + "version": "0.3.2", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "TestProbe", + "manufacturer": "TestManufacturer" + }, + "contact_positions": [[0.0, 0.0], [0.0, 20.0], [16.0, 10.0]], + "contact_shapes": ["circle", "circle", "circle"], + "contact_shape_params": [{"radius": 5.0}, {"radius": 5.0}, {"radius": 5.0}], + "contact_ids": ["e0", "e1", "e2"], + "shank_ids": ["", "", ""], + "device_channel_indices": [0, 1, 2] + } + ] +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/OpenEphys.ProbeInterface.NET.Tests.csproj b/OpenEphys.ProbeInterface.NET.Tests/OpenEphys.ProbeInterface.NET.Tests.csproj new file mode 100644 index 0000000..db7957d --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/OpenEphys.ProbeInterface.NET.Tests.csproj @@ -0,0 +1,30 @@ + + + net472 + enable + false + 11.0 + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + diff --git a/OpenEphys.ProbeInterface.NET.Tests/ProbeAnnotationsTests.cs b/OpenEphys.ProbeInterface.NET.Tests/ProbeAnnotationsTests.cs new file mode 100644 index 0000000..ce0b4c4 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/ProbeAnnotationsTests.cs @@ -0,0 +1,123 @@ +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace OpenEphys.ProbeInterface.NET.Tests +{ + public class ProbeAnnotationsTests + { + private static string Json => $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { "model_name": "Neuropixels 1.0", "manufacturer": "IMEC" }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}], + "contact_ids": ["0"], + "shank_ids": [""], + "device_channel_indices": [0] + } + ] + } + """; + + [Fact] + public void ModelName_DeserializesFromModelNameKey() + { + var group = JsonConvert.DeserializeObject(Json)!; + Assert.Equal("Neuropixels 1.0", group.Probes.First().Annotations.ModelName); + Assert.Equal("IMEC", group.Probes.First().Annotations.Manufacturer); + } + + [Fact] + public void ModelName_SerializesToModelNameKey() + { + var group = JsonConvert.DeserializeObject(Json)!; + var serialized = JsonConvert.SerializeObject(group); + var roundTrip = JsonConvert.DeserializeObject(serialized)!; + Assert.Equal("Neuropixels 1.0", roundTrip.Probes.First().Annotations.ModelName); + } + + private static string JsonWithExtraAnnotations => $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "Neuropixels 1.0", + "manufacturer": "IMEC", + "custom_note": "implanted 2025-01-01", + "depth_um": 3840 + }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}], + "contact_ids": ["0"], + "shank_ids": [""] + } + ] + } + """; + + [Fact] + public void ExtraAnnotationKeys_AreDeserializedIntoAdditionalProperties() + { + var group = JsonConvert.DeserializeObject(JsonWithExtraAnnotations)!; + var ann = group.Probes.First().Annotations; + Assert.Contains("custom_note", ann.AnnotationKeys); + Assert.Contains("depth_um", ann.AnnotationKeys); + } + + [Fact] + public void ExtraAnnotationKeys_TypedAccessor_String() + { + var group = JsonConvert.DeserializeObject(JsonWithExtraAnnotations)!; + Assert.Equal("implanted 2025-01-01", + group.Probes.First().Annotations.GetAnnotation("custom_note")); + } + + [Fact] + public void ExtraAnnotationKeys_TypedAccessor_Numeric() + { + var group = JsonConvert.DeserializeObject(JsonWithExtraAnnotations)!; + Assert.Equal(3840, group.Probes.First().Annotations.GetAnnotation("depth_um")); + } + + [Fact] + public void ExtraAnnotationKeys_RoundTrip() + { + var group = JsonConvert.DeserializeObject(JsonWithExtraAnnotations)!; + var serialized = JsonConvert.SerializeObject(group); + var roundTrip = JsonConvert.DeserializeObject(serialized)!; + Assert.Equal("implanted 2025-01-01", + roundTrip.Probes.First().Annotations.GetAnnotation("custom_note")); + Assert.Equal(3840, roundTrip.Probes.First().Annotations.GetAnnotation("depth_um")); + } + + [Fact] + public void ExtraAnnotationKeys_AbsentKey_ReturnsDefault() + { + var group = JsonConvert.DeserializeObject(JsonWithExtraAnnotations)!; + Assert.Null(group.Probes.First().Annotations.GetAnnotation("nonexistent")); + } + + [Fact] + public void KnownKeys_NotDuplicatedInAdditionalProperties() + { + var group = JsonConvert.DeserializeObject(JsonWithExtraAnnotations)!; + var ann = group.Probes.First().Annotations; + // model_name and manufacturer are handled by named properties, not extension data + Assert.DoesNotContain("model_name", ann.AnnotationKeys); + Assert.DoesNotContain("manufacturer", ann.AnnotationKeys); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/ProbeGroupValidationTests.cs b/OpenEphys.ProbeInterface.NET.Tests/ProbeGroupValidationTests.cs new file mode 100644 index 0000000..f3ad4be --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/ProbeGroupValidationTests.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Xunit; + +namespace OpenEphys.ProbeInterface.NET.Tests +{ + public class ProbeGroupValidationTests + { + private static ProbeGroup Deserialize(string json) => + JsonConvert.DeserializeObject(json) + ?? throw new JsonException("Deserialization returned null."); + + private static string MakeTwoProbeJson( + string? probe0ChannelIndices = null, string? probe0ContactIds = null, + string? probe1ChannelIndices = null, string? probe1ContactIds = null) => + $$""" + { + "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": ["circle"], + "contact_shape_params": [{"radius": 5.0}] + {{(probe0ContactIds != null ? $", \"contact_ids\": {probe0ContactIds}" : "")}} + {{(probe0ChannelIndices != null ? $", \"device_channel_indices\": {probe0ChannelIndices}" : "")}} + }, + { + "ndim": 2, "si_units": "um", + "annotations": { "model_name": "P", "manufacturer": "M" }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}] + {{(probe1ContactIds != null ? $", \"contact_ids\": {probe1ContactIds}" : "")}} + {{(probe1ChannelIndices != null ? $", \"device_channel_indices\": {probe1ChannelIndices}" : "")}} + } + ] + } + """; + + private static string MakeJson( + string specification = "probeinterface", + string? version = null, + string modelName = "TestProbe", + string manufacturer = "TestMfg", + string? contactIds = null, + string? deviceChannelIndices = null) => + $$""" + { + "specification": "{{specification}}", + "version": "{{version ?? ProbeGroup.SupportedSpecVersion.ToString()}}", + "probes": [ + { + "ndim": 2, + "si_units": "um", + "annotations": { "model_name": "{{modelName}}", "manufacturer": "{{manufacturer}}" }, + "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}" : "")}} + } + ] + } + """; + + [Fact] + public void WrongSpecification_Throws() + { + var ex = Assert.Throws(() => + Deserialize(MakeJson(specification: "other"))); + Assert.Contains("probeinterface", ex.Message); + } + + [Theory] + [InlineData("0.2")] + [InlineData("1")] + [InlineData("abc")] + [InlineData("")] + public void MalformedVersion_Throws(string version) + { + Assert.Throws(() => + Deserialize(MakeJson(version: version))); + } + + public static TheoryData CompatibleVersions => new TheoryData + { + $"{ProbeGroup.SupportedSpecVersion.Major}.{ProbeGroup.SupportedSpecVersion.Minor}.0", + ProbeGroup.SupportedSpecVersion.ToString(), + $"{ProbeGroup.SupportedSpecVersion.Major}.{ProbeGroup.SupportedSpecVersion.Minor}.99", + }; + + public static TheoryData IncompatibleVersions => new TheoryData + { + $"{ProbeGroup.SupportedSpecVersion.Major}.{ProbeGroup.SupportedSpecVersion.Minor - 1}.0", + $"{ProbeGroup.SupportedSpecVersion.Major}.{ProbeGroup.SupportedSpecVersion.Minor + 1}.0", + $"{ProbeGroup.SupportedSpecVersion.Major + 1}.0.0", + }; + + [Theory] + [MemberData(nameof(CompatibleVersions))] + public void CompatibleMinorVersion_DoesNotThrow(string version) + { + var ex = Record.Exception(() => Deserialize(MakeJson(version: version))); + Assert.Null(ex); + } + + [Theory] + [MemberData(nameof(IncompatibleVersions))] + public void IncompatibleMinorVersion_Throws(string version) + { + var ex = Assert.Throws(() => + Deserialize(MakeJson(version: version))); + Assert.Contains($"0.{ProbeGroup.SupportedSpecVersion.Minor}.x", ex.Message); + } + + [Fact] + public void MissingContactPlaneAxes_DoesNotThrow() + { + var ex = Record.Exception(() => Deserialize(MakeJson())); + Assert.Null(ex); + } + + [Fact] + public void NonNumericContactIds_ArePreserved() + { + var group = Deserialize(MakeJson(contactIds: "[\"e0\", \"e1\"]", deviceChannelIndices: "[0, 1]")); + Assert.Equal("e0", group.Probes.First().Contacts[0].ContactId); + Assert.Equal("e1", group.Probes.First().Contacts[1].ContactId); + } + + [Fact] + public void MissingDeviceChannelIndices_ChannelMapIsNull() + { + var group = Deserialize(MakeJson()); + Assert.Null(group.Probes.First().ChannelMap); + } + + [Fact] + 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]); + } + + [Fact] + public void AllMinusOne_ChannelMapIsNull() + { + // All -1 entries → no connected contacts → ChannelMap is null (not set) + var group = Deserialize(MakeJson(deviceChannelIndices: "[-1, -1]")); + Assert.Null(group.Probes.First().ChannelMap); + } + + [Fact] + public void DuplicateDeviceChannelIndices_Throws() + { + Assert.Throws(() => + Deserialize(MakeJson(deviceChannelIndices: "[5, 5]"))); + } + + [Fact] + public void WireChannels_FirstCall_AssignsSpecifiedContacts() + { + var group = Deserialize(MakeJson()); + ChannelWiring.WireChannels(group,0, new Dictionary { { 0, 3 } }); + var map = group.Probes.First().ChannelMap; + Assert.NotNull(map); + Assert.Equal(3, map![0]); + Assert.False(map.ContainsKey(1)); // contact 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 } }); + 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 + } + + [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 } }); + var map = group.Probes.First().ChannelMap; + Assert.False(map!.ContainsKey(0)); // displaced + Assert.Equal(5, map[1]); + } + + [Fact] + public void WireChannels_OutOfRangeContactIndex_Throws() + { + var group = Deserialize(MakeJson()); + Assert.Throws(() => + ChannelWiring.WireChannels(group,0, new Dictionary { { 5, 0 } })); + } + + [Fact] + public void WireChannels_NegativeChannelValue_Throws() + { + var group = Deserialize(MakeJson()); + Assert.Throws(() => + ChannelWiring.WireChannels(group,0, new Dictionary { { 0, -1 } })); + } + + [Fact] + public void WireChannels_DuplicateChannelWithinCall_Throws() + { + var group = Deserialize(MakeJson()); + Assert.Throws(() => + ChannelWiring.WireChannels(group,0, new Dictionary { { 0, 5 }, { 1, 5 } })); + } + + [Fact] + 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 } })); + // Probe 1 map must be rolled back to null + Assert.Null(group.Probes.ElementAt(1).ChannelMap); + } + + [Fact] + public void WireChannel_AssignsSingleContact() + { + var group = Deserialize(MakeJson()); + ChannelWiring.WireChannel(group,0, 1, 42); + Assert.Equal(42, group.Probes.First().ChannelMap![1]); + } + + [Fact] + public void UnwireChannel_RemovesEntry() + { + var group = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); + ChannelWiring.UnwireChannel(group,0, 0); + var map = group.Probes.First().ChannelMap; + Assert.NotNull(map); + Assert.False(map!.ContainsKey(0)); + Assert.Equal(7, map[1]); + } + + [Fact] + public void UnwireChannel_LastEntry_SetsMapToNull() + { + var group = Deserialize(MakeJson(deviceChannelIndices: "[3, -1]")); + ChannelWiring.UnwireChannel(group,0, 0); // only contact 0 had an entry + Assert.Null(group.Probes.First().ChannelMap); + } + + [Fact] + public void UnwireChannel_MissingMap_DoesNotThrow() + { + var group = Deserialize(MakeJson()); + var ex = Record.Exception(() => ChannelWiring.UnwireChannel(group,0, 0)); + Assert.Null(ex); + } + + [Fact] + public void UnwireChannels_RemovesMultipleContacts() + { + var group = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); + ChannelWiring.UnwireChannels(group,0, new[] { 0, 1 }); + Assert.Null(group.Probes.First().ChannelMap); + } + + [Fact] + public void UnwireChannels_Probe_ClearsAllOnThatProbe() + { + var group = Deserialize(MakeJson(deviceChannelIndices: "[3, 7]")); + ChannelWiring.UnwireChannels(group,0); + Assert.Null(group.Probes.First().ChannelMap); + } + + [Fact] + public void UnwireChannels_Probe_DoesNotThrowWhenAlreadyEmpty() + { + var group = Deserialize(MakeJson()); // no channel indices + var ex = Record.Exception(() => ChannelWiring.UnwireChannels(group,0)); + Assert.Null(ex); + } + + [Fact] + public void UnwireChannels_All_ClearsEveryProbe() + { + var group = Deserialize(MakeTwoProbeJson(probe0ChannelIndices: "[10]", probe1ChannelIndices: "[20]")); + ChannelWiring.UnwireChannels(group); + Assert.Null(group.Probes.ElementAt(0).ChannelMap); + Assert.Null(group.Probes.ElementAt(1).ChannelMap); + } + + [Fact] + public void GetChannelMap_NoChannelsAssigned_ReturnsNull() + { + var group = Deserialize(MakeJson()); + Assert.Null(group.GetChannelMap()); + } + + [Fact] + public void GetChannelMap_ReturnsChannelToContactMapping() + { + var group = Deserialize(MakeJson(contactIds: "[\"e0\", \"e1\"]", deviceChannelIndices: "[3, 7]")); + var map = group.GetChannelMap(); + Assert.NotNull(map); + Assert.Equal("e0", map![3].Contact.ContactId); + Assert.Equal("e1", map[7].Contact.ContactId); + } + + [Fact] + public void GetChannelMap_ContactPropertiesAreAccessible() + { + 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); + } + + [Fact] + public void GetChannelMap_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); + } + + [Fact] + public void GetChannelMap_MultiProbe_CombinesBothProbes() + { + 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); + } + + [Fact] + public void GetChannelMap_AfterWireChannel_ReflectsUpdate() + { + var group = Deserialize(MakeJson()); + ChannelWiring.WireChannel(group,0, 0, 42); + var map = group.GetChannelMap()!; + Assert.Single(map); + Assert.True(map.ContainsKey(42)); + Assert.Equal(0, map[42].ProbeIndex); + Assert.Equal(0, map[42].ContactIndex); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/RoundTripTests.cs b/OpenEphys.ProbeInterface.NET.Tests/RoundTripTests.cs new file mode 100644 index 0000000..c241290 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/RoundTripTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace OpenEphys.ProbeInterface.NET.Tests +{ + /// + /// Verifies that each JSON fixture can be deserialized into a and + /// re-serialized to JSON that is structurally equivalent to the original. + /// + public class RoundTripTests + { + private static ProbeGroup Deserialize(string json) => + JsonConvert.DeserializeObject(json) + ?? throw new JsonException("Deserialization returned null."); + + private static bool JsonEquivalent(string a, string b) => + TokensEqual(JToken.Parse(a), JToken.Parse(b)); + + private static bool TokensEqual(JToken a, JToken b) + { + if (a.Type == b.Type) + { + if (a is JArray arrA && b is JArray arrB) + { + if (arrA.Count != arrB.Count) return false; + for (int i = 0; i < arrA.Count; i++) + if (!TokensEqual(arrA[i], arrB[i])) return false; + return true; + } + if (a is JObject objA && b is JObject objB) + { + if (objA.Count != objB.Count) return false; + foreach (var prop in objA.Properties()) + { + var bProp = objB.Property(prop.Name); + if (bProp == null || !TokensEqual(prop.Value, bProp.Value)) return false; + } + return true; + } + return JToken.DeepEquals(a, b); + } + // Integer vs float: compare by numeric value. + if ((a.Type == JTokenType.Integer || a.Type == JTokenType.Float) && + (b.Type == JTokenType.Integer || b.Type == JTokenType.Float)) + return a.Value() == b.Value(); + return false; + } + + [Fact] + public void MinimalProbe_RoundTrips() + { + var json = FixtureHelper.LoadFixture("minimal_probe.json"); + var group = Deserialize(json); + var roundTripped = JsonConvert.SerializeObject(group); + Assert.True(JsonEquivalent(json, roundTripped)); + } + + [Fact] + public void ProbeWithContactAnnotations_RoundTrips() + { + var json = FixtureHelper.LoadFixture("probe_with_contact_annotations.json"); + var group = Deserialize(json); + Assert.NotNull(group.Probes.First().GetContactAnnotation("brain_area")); + Assert.NotNull(group.Probes.First().GetContactAnnotation("impedance")); + Assert.NotNull(group.Probes.First().GetContactAnnotation("channel_names")); + Assert.Equal(new[] { "CA1", "CA1", "DG" }, group.Probes.First().GetContactAnnotation("brain_area")); + var roundTripped = JsonConvert.SerializeObject(group); + Assert.True(JsonEquivalent(json, roundTripped)); + } + + [Fact] + public void ProbeWithContactSides_RoundTrips() + { + var json = FixtureHelper.LoadFixture("probe_with_contact_sides.json"); + var group = Deserialize(json); + Assert.Equal(new[] { "front", "front", "back" }, group.Probes.First().Contacts.Select(c => c.Side).ToArray()); + var roundTripped = JsonConvert.SerializeObject(group); + Assert.True(JsonEquivalent(json, roundTripped)); + } + + [Fact] + public void ProbeWithNonNumericIds_RoundTrips() + { + var json = FixtureHelper.LoadFixture("probe_with_nonnumeric_ids.json"); + var group = Deserialize(json); + Assert.Equal("e0", group.Probes.First().Contacts[0].ContactId); + var roundTripped = JsonConvert.SerializeObject(group); + Assert.True(JsonEquivalent(json, roundTripped)); + } + + [Fact] + public void MultiProbeGroup_RoundTrips() + { + var json = FixtureHelper.LoadFixture("multi_probe_group.json"); + var group = Deserialize(json); + Assert.Equal(2, group.Probes.Count()); + var roundTripped = JsonConvert.SerializeObject(group); + Assert.True(JsonEquivalent(json, roundTripped)); + } + + [Fact] + public void Probe3D_RoundTrips() + { + var json = FixtureHelper.LoadFixture("probe_3d.json"); + var group = Deserialize(json); + var contact = group.Probes.First().Contacts[1]; + Assert.Equal(20.0, contact.PosY); + Assert.Equal(5.0, contact.PosZ); + var roundTripped = JsonConvert.SerializeObject(group); + Assert.True(JsonEquivalent(json, roundTripped)); + } + + // Real-world probe library + + public static IEnumerable ProbeLibraryFiles => + FixtureHelper.GetProbeLibraryResourceNames() + .Where(n => { + var json = FixtureHelper.LoadProbeLibraryFile(n); + var sv = ProbeGroup.SupportedSpecVersion; + return json.Contains($"\"{sv.Major}.{sv.Minor}."); + }) + .Select(n => new object[] { n }); + + [Theory] + [MemberData(nameof(ProbeLibraryFiles))] + public void ProbeLibraryFile_RoundTrips(string resourceName) + { + var json = FixtureHelper.LoadProbeLibraryFile(resourceName); + var group = Deserialize(json); + Assert.True(group.NumberOfContacts > 0); + Assert.True(JsonEquivalent(json, JsonConvert.SerializeObject(group))); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/SchemaValidationTests.cs b/OpenEphys.ProbeInterface.NET.Tests/SchemaValidationTests.cs new file mode 100644 index 0000000..f8dd4a4 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/SchemaValidationTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NJsonSchema; +using NJsonSchema.Validation; +using Xunit; + +namespace OpenEphys.ProbeInterface.NET.Tests +{ + /// + /// Validates JSON against the bundled probeinterface JSON schema (probe.json.schema). + /// Covers both raw fixture files and the output of our serialization pipeline. + /// + public class SchemaValidationTests + { + private static readonly JsonSchema Schema = + JsonSchema.FromJsonAsync(FixtureHelper.LoadFixture("probe.json.schema")) + .GetAwaiter().GetResult(); + + private static ICollection Validate(string json) => Schema.Validate(json); + + public static IEnumerable AllFixtures => + new[] + { + new object[] { "minimal_probe.json" }, + new object[] { "probe_with_contact_annotations.json" }, + new object[] { "probe_with_contact_sides.json" }, + new object[] { "probe_with_nonnumeric_ids.json" }, + new object[] { "multi_probe_group.json" }, + new object[] { "probe_3d.json" }, + }; + + [Theory] + [MemberData(nameof(AllFixtures))] + public void FixtureJson_PassesSchemaValidation(string fileName) + { + var errors = Validate(FixtureHelper.LoadFixture(fileName)); + Assert.Empty(errors); + } + + [Theory] + [MemberData(nameof(AllFixtures))] + public void SerializedProbeGroup_PassesSchemaValidation(string fileName) + { + var json = FixtureHelper.LoadFixture(fileName); + var group = JsonConvert.DeserializeObject(json)!; + var errors = Validate(JsonConvert.SerializeObject(group)); + Assert.Empty(errors); + } + + [Fact] + public void ProbeAnnotations_WithExtraKeys_PassesSchemaValidation() + { + string json = $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [{ + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "Neuropixels 1.0", + "manufacturer": "IMEC", + "custom_note": "implanted 2025-01-01", + "depth_um": 3840 + }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}], + "contact_ids": ["0"], + "shank_ids": [""] + }] + } + """; + var errors = Validate(json); + Assert.Empty(errors); + } + + public static IEnumerable ProbeLibraryFiles => + FixtureHelper.GetProbeLibraryResourceNames() + .Where(n => { + var json = FixtureHelper.LoadProbeLibraryFile(n); + var sv = ProbeGroup.SupportedSpecVersion; + return json.Contains($"\"{sv.Major}.{sv.Minor}."); + }) + .Select(n => new object[] { n }); + + [Theory] + [MemberData(nameof(ProbeLibraryFiles))] + public void ProbeLibraryFile_SerializedOutputPassesSchema(string resourceName) + { + var json = FixtureHelper.LoadProbeLibraryFile(resourceName); + var group = JsonConvert.DeserializeObject(json)!; + var errors = Validate(JsonConvert.SerializeObject(group)); + Assert.Empty(errors); + } + + [Fact] + public void ProbeAnnotations_WithExtraKeys_SerializedOutputPassesSchema() + { + string json = $$""" + { + "specification": "probeinterface", + "version": "{{ProbeGroup.SupportedSpecVersion}}", + "probes": [{ + "ndim": 2, + "si_units": "um", + "annotations": { + "model_name": "Neuropixels 1.0", + "manufacturer": "IMEC", + "custom_note": "implanted 2025-01-01", + "depth_um": 3840 + }, + "contact_positions": [[0.0, 0.0]], + "contact_shapes": ["circle"], + "contact_shape_params": [{"radius": 5.0}], + "contact_ids": ["0"], + "shank_ids": [""] + }] + } + """; + var group = JsonConvert.DeserializeObject(json)!; + var errors = Validate(JsonConvert.SerializeObject(group)); + Assert.Empty(errors); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET.Tests/generate_fixtures.py b/OpenEphys.ProbeInterface.NET.Tests/generate_fixtures.py new file mode 100644 index 0000000..37bae41 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/generate_fixtures.py @@ -0,0 +1,130 @@ +""" +Regenerate JSON fixture files from the Python probeinterface library. + +Usage: + pip install probeinterface + python generate_fixtures.py + +Outputs files to OpenEphys.ProbeInterface.NET.Tests/Fixtures/. +The generated files are the authoritative reference for round-trip testing. +""" + +import json +from pathlib import Path + +try: + from probeinterface import Probe, ProbeGroup + from probeinterface.io import write_probeinterface +except ImportError: + raise SystemExit("probeinterface is not installed. Run: pip install probeinterface") + +OUT = Path(__file__).parent / "OpenEphys.ProbeInterface.NET.Tests" / "Fixtures" +OUT.mkdir(parents=True, exist_ok=True) + + +def write(name: str, group: ProbeGroup) -> None: + path = OUT / name + write_probeinterface(path, group) + # Pretty-print for readability + with open(path) as f: + data = json.load(f) + with open(path, "w") as f: + json.dump(data, f, indent=2) + print(f"Written: {path}") + + +# --- minimal_probe.json --- +p = Probe(ndim=2, si_units="um") +p.set_contacts( + positions=[[0, 0], [0, 20], [16, 10]], + shapes="circle", + shape_params={"radius": 5}, +) +p.set_device_channel_indices([0, 1, 2]) +p.annotate(model_name="TestProbe", manufacturer="TestManufacturer") +g = ProbeGroup() +g.add_probe(p) +write("minimal_probe.json", g) + +# --- probe_with_contact_annotations.json --- +p = Probe(ndim=2, si_units="um") +p.set_contacts( + positions=[[0, 0], [0, 20], [16, 10]], + shapes="circle", + shape_params={"radius": 5}, +) +p.set_device_channel_indices([0, 1, 2]) +p.annotate(model_name="TestProbe", manufacturer="TestManufacturer") +p.set_contact_annotations(brain_area=["CA1", "CA1", "DG"], channel_names=["ch0", "ch1", "ch2"], + impedance=[125.3, 98.7, 110.1]) +g = ProbeGroup() +g.add_probe(p) +write("probe_with_contact_annotations.json", g) + +# --- probe_with_contact_sides.json --- +p = Probe(ndim=2, si_units="um") +p.set_contacts( + positions=[[0, 0], [0, 20], [16, 10]], + shapes="circle", + shape_params={"radius": 5}, + contact_ids=["0", "1", "2"], +) +p.set_device_channel_indices([0, 1, 2]) +p.annotate(model_name="TestProbe", manufacturer="TestManufacturer") +p.contact_sides = ["front", "front", "back"] +g = ProbeGroup() +g.add_probe(p) +write("probe_with_contact_sides.json", g) + +# --- probe_with_nonnumeric_ids.json --- +p = Probe(ndim=2, si_units="um") +p.set_contacts( + positions=[[0, 0], [0, 20], [16, 10]], + shapes="circle", + shape_params={"radius": 5}, + contact_ids=["e0", "e1", "e2"], +) +p.set_device_channel_indices([0, 1, 2]) +p.annotate(model_name="TestProbe", manufacturer="TestManufacturer") +g = ProbeGroup() +g.add_probe(p) +write("probe_with_nonnumeric_ids.json", g) + +# --- multi_probe_group.json --- +p1 = Probe(ndim=2, si_units="um") +p1.set_contacts( + positions=[[0, 0], [0, 20]], + shapes="circle", + shape_params={"radius": 5}, +) +p1.set_device_channel_indices([0, 1]) +p1.annotate(model_name="TestProbeA", manufacturer="TestManufacturer") + +p2 = Probe(ndim=2, si_units="um") +p2.set_contacts( + positions=[[0, 0], [0, 20], [16, 10]], + shapes="rect", + shape_params={"width": 10, "height": 5}, +) +p2.set_device_channel_indices([2, 3, 4]) +p2.annotate(model_name="TestProbeB", manufacturer="TestManufacturer") + +g = ProbeGroup() +g.add_probe(p1) +g.add_probe(p2) +write("multi_probe_group.json", g) + +# --- probe_3d.json --- +p = Probe(ndim=3, si_units="um") +p.set_contacts( + positions=[[0, 0, 0], [0, 20, 5], [16, 10, 10]], + shapes="circle", + shape_params={"radius": 5}, +) +p.set_device_channel_indices([0, 1, 2]) +p.annotate(model_name="TestProbe3D", manufacturer="TestManufacturer") +g = ProbeGroup() +g.add_probe(p) +write("probe_3d.json", g) + +print("\nAll fixtures written. Verify them against the JSON schema before committing.") diff --git a/OpenEphys.ProbeInterface.NET.Tests/probeinterface_library b/OpenEphys.ProbeInterface.NET.Tests/probeinterface_library new file mode 160000 index 0000000..275ae28 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET.Tests/probeinterface_library @@ -0,0 +1 @@ +Subproject commit 275ae2887301741605c2df8f885e3983e9a1c52b diff --git a/OpenEphys.ProbeInterface.NET.sln b/OpenEphys.ProbeInterface.NET.sln index 6a525bb..67b0436 100644 --- a/OpenEphys.ProbeInterface.NET.sln +++ b/OpenEphys.ProbeInterface.NET.sln @@ -10,16 +10,42 @@ 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}" +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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/OpenEphys.ProbeInterface.NET/ChannelWiring.cs b/OpenEphys.ProbeInterface.NET/ChannelWiring.cs new file mode 100644 index 0000000..ff29670 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET/ChannelWiring.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +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. + /// + 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. + /// + /// If a channel in is already held by a different contact + /// on the same probe, then that contact loses its mapping. + /// + /// + /// 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. + /// + /// 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. + /// + public static void WireChannels(ProbeGroup group, int probeIndex, IDictionary assignments) + { + var probe = group.Probes.ElementAt(probeIndex); + int n = probe.NumberOfContacts; + + foreach (var kvp in assignments) + { + if (kvp.Key < 0 || kvp.Key >= n) + throw new ArgumentException( + $"Contact index {kvp.Key} is out of range [0, {n}).", nameof(assignments)); + if (kvp.Value < 0) + throw new ArgumentException( + $"Channel value {kvp.Value} for contact {kvp.Key} must be >= 0.", nameof(assignments)); + } + + var incomingChannels = assignments.Values.ToList(); + if (incomingChannels.Count != incomingChannels.Distinct().Count()) + throw new ArgumentException( + "Channel values within a single assignment call must be unique.", nameof(assignments)); + + var previousMap = probe.ChannelMap?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + var newMap = probe.ChannelMap != null + ? probe.ChannelMap.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + : new Dictionary(); + + foreach (var kvp in assignments) + { + // Displace any other contact that currently holds this channel. + var displaced = newMap + .Where(e => 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; + } + + probe.SetChannelMap(newMap.Count > 0 ? newMap : null); + + if (!group.ValidateDeviceChannelIndices()) + { + probe.SetChannelMap(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 . + /// + /// 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. + public static void WireChannel(ProbeGroup group, int probeIndex, int contactIndex, int channel) => + WireChannels(group, probeIndex, new Dictionary { { contactIndex, channel } }); + + /// 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); + } + + /// 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); + + /// + /// Removes the channel mapping for a set of contacts on the specified probe. + /// Contacts with no existing mapping are silently skipped. + /// + /// The probe group to update. + /// Zero-based index of the probe to update. + /// Contact indices whose mappings should be removed. + public static void UnwireChannels(ProbeGroup group, int probeIndex, IEnumerable contactIndices) + { + var probe = group.Probes.ElementAt(probeIndex); + if (probe.ChannelMap == null) return; + + 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); + } + + /// + /// Removes the channel mapping for a single contact on the specified probe. + /// Has no effect if the contact has no mapping. + /// + /// The probe group to update. + /// Zero-based index of the probe to update. + /// Zero-based index of the contact to unwire. + 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); + } + } +} diff --git a/OpenEphys.ProbeInterface.NET/Contact.cs b/OpenEphys.ProbeInterface.NET/Contact.cs index b25f9bc..ada65c9 100644 --- a/OpenEphys.ProbeInterface.NET/Contact.cs +++ b/OpenEphys.ProbeInterface.NET/Contact.cs @@ -1,72 +1,119 @@ -namespace OpenEphys.ProbeInterface.NET +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenEphys.ProbeInterface.NET { /// - /// Struct that extends the Probeinterface specification by encapsulating all values for a single contact. + /// 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 . /// - public readonly struct Contact + public sealed class Contact { - /// - /// Gets the x-position of the contact. - /// - public float PosX { get; } + private readonly ContactAnnotationStore store; + private readonly int index; + private readonly int totalContacts; - /// - /// Gets the y-position of the contact. - /// - public float PosY { get; } + /// Gets the x-position of the contact centre. + public double PosX { get; } - /// - /// Gets the of the contact. - /// + /// 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. + public double? PosZ { get; } + + /// Gets the shape of the contact. public ContactShape Shape { get; } - /// - /// Gets the 's of the contact. - /// + /// Gets the shape parameters for the contact. public ContactShapeParam ShapeParams { get; } - /// - /// Gets the device ID of the contact. - /// - public int DeviceId { get; } + /// 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. + public string? ShankId { get; } + + /// 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. + public string? Side { get; } /// - /// Gets the contact ID of the contact. + /// 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. /// - public string ContactId { get; } + internal Contact( + double posX, double posY, double? posZ, + ContactShape shape, ContactShapeParam shapeParams, + string? contactId, string? shankId, + double[][]? planeAxes, string? side, + int index, int totalContacts, + ContactAnnotationStore store) + { + PosX = posX; + PosY = posY; + PosZ = posZ; + Shape = shape; + ShapeParams = shapeParams; + ContactId = contactId; + ShankId = shankId; + PlaneAxes = planeAxes; + Side = side; + this.index = index; + this.totalContacts = totalContacts; + this.store = store; + } /// - /// Gets the shank ID of the contact. + /// 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. /// - public string ShankId { get; } + public T? GetAnnotation(string key) + { + if (store.Data == null || !store.Data.TryGetValue(key, out var arr)) + return default; + var value = arr[index]; + if (value == null) return default; + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + return (T)Convert.ChangeType(value, targetType); + } /// - /// Gets the index of the contact within the object. + /// 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. /// - public int Index { get; } + public void SetAnnotation(string key, T value) + { + store.Data ??= new Dictionary(); + if (!store.Data.TryGetValue(key, out var arr)) + { + arr = new object[totalContacts]; + store.Data[key] = arr; + } + arr[index] = value!; + } /// - /// Initializes a new instance of the struct. + /// 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. /// - /// Center value of the contact on the X-axis. - /// Center value of the contact on the Y-axis. - /// The of the contact. - /// 's relevant to the of the contact. - /// The device channel index () of this contact. - /// The contact ID () of this contact. - /// The shank ID () of this contact. - /// The index of the contact within the context of the . - public Contact(float posX, float posY, ContactShape shape, ContactShapeParam shapeParam, - int deviceId, string contactId, string shankId, int index) + public bool RemoveAnnotation(string key) { - PosX = posX; - PosY = posY; - Shape = shape; - ShapeParams = shapeParam; - DeviceId = deviceId; - ContactId = contactId; - ShankId = shankId; - Index = index; + if (store.Data == null || !store.Data.TryGetValue(key, out var arr)) return false; + arr[index] = null!; + if (arr.All(v => v == null)) + store.Data.Remove(key); + return true; } } } diff --git a/OpenEphys.ProbeInterface.NET/ContactAnnotationStore.cs b/OpenEphys.ProbeInterface.NET/ContactAnnotationStore.cs new file mode 100644 index 0000000..fefa433 --- /dev/null +++ b/OpenEphys.ProbeInterface.NET/ContactAnnotationStore.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace OpenEphys.ProbeInterface.NET +{ + /// + /// Shared backing store for per-contact annotations within a single . + /// One instance is created per probe and passed to every so that + /// mutations made through any contact are immediately visible at the probe level (and + /// vice-versa) without requiring a back-reference from contact to probe. + /// + internal sealed class ContactAnnotationStore + { + internal Dictionary? Data; + } +} diff --git a/OpenEphys.ProbeInterface.NET/ContactAnnotations.cs b/OpenEphys.ProbeInterface.NET/ContactAnnotations.cs deleted file mode 100644 index 1d621a4..0000000 --- a/OpenEphys.ProbeInterface.NET/ContactAnnotations.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Newtonsoft.Json; - -namespace OpenEphys.ProbeInterface.NET -{ - /// - /// Class holding all of the annotations for each contact. - /// - public class ContactAnnotations - { - /// - /// Gets the array of strings holding annotations for each contact. Not all indices must have annotations. - /// - [JsonProperty("annotations")] - public string[] Annotations { get; protected set; } - - /// - /// Initializes a new instance of the class. - /// - /// Array of strings containing annotations for each contact. Size of the array should match the number of contacts, but they can be empty strings. - [JsonConstructor] - public ContactAnnotations(string[] annotations) - { - Annotations = annotations; - } - } -} diff --git a/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs b/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs index e07cbc7..df838dd 100644 --- a/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs +++ b/OpenEphys.ProbeInterface.NET/ContactShapeParam.cs @@ -16,8 +16,8 @@ public class ContactShapeParam /// /// This is only used to draw contacts. Field can be null. /// - [JsonProperty("radius")] - public float? Radius { get; protected set; } + [JsonProperty("radius", NullValueHandling = NullValueHandling.Ignore)] + public double? Radius { get; } /// /// Gets the width of the contact. @@ -26,8 +26,8 @@ public class ContactShapeParam /// This is used to draw or contacts. /// Field can be null. /// - [JsonProperty("width")] - public float? Width { get; protected set; } + [JsonProperty("width", NullValueHandling = NullValueHandling.Ignore)] + public double? Width { get; } /// /// Gets the height of the contact. @@ -35,32 +35,19 @@ public class ContactShapeParam /// /// This is only used to draw contacts. Field can be null. /// - [JsonProperty("height")] - public float? Height { get; protected set; } + [JsonProperty("height", NullValueHandling = NullValueHandling.Ignore)] + public double? Height { get; } /// /// Initializes a new instance of the class. + /// Used by Newtonsoft.Json during deserialization. /// - /// Radius. Can be null. - /// Width. Can be null. - /// Height. Can be null. [JsonConstructor] - public ContactShapeParam(float? radius = null, float? width = null, float? height = null) + internal ContactShapeParam(double? radius = null, double? width = null, double? height = null) { Radius = radius; Width = width; Height = height; } - - /// - /// Copy constructor given an existing object. - /// - /// Existing object to be copied. - protected ContactShapeParam(ContactShapeParam shape) - { - Radius = shape.Radius; - Width = shape.Width; - Height = shape.Height; - } } } diff --git a/OpenEphys.ProbeInterface.NET/Probe.cs b/OpenEphys.ProbeInterface.NET/Probe.cs index d18decb..401f61f 100644 --- a/OpenEphys.ProbeInterface.NET/Probe.cs +++ b/OpenEphys.ProbeInterface.NET/Probe.cs @@ -1,330 +1,269 @@ -using System.Xml.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Serialization; using Newtonsoft.Json; namespace OpenEphys.ProbeInterface.NET { /// - /// Class that implements the Probe Interface specification for a Probe. + /// 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. /// public class Probe { - /// - /// Gets the to use while plotting the . - /// + /// Gets the number of spatial dimensions (2 or 3). [XmlIgnore] [JsonProperty("ndim", Required = Required.Always)] - public ProbeNdim NumDimensions { get; protected set; } + public ProbeNdim NumDimensions { get; } - /// - /// Gets the to use while plotting the . - /// + /// Gets the SI unit used for contact positions. [XmlIgnore] [JsonProperty("si_units", Required = Required.Always)] - public ProbeSiUnits SiUnits { get; protected set; } + public ProbeSiUnits SiUnits { get; } - /// - /// Gets the for the . - /// - /// - /// Used to specify the name of the probe, and the manufacturer. - /// + /// Gets the probe-level annotations (model name, manufacturer). [XmlIgnore] [JsonProperty("annotations", Required = Required.Always)] - public ProbeAnnotations Annotations { get; protected set; } + public ProbeAnnotations Annotations { get; } - /// - /// Gets the for the . - /// - /// - /// This field can be used for noting things like where it physically is within a specimen, or if it - /// is no longer functioning correctly. - /// + /// Gets the planar contour describing the physical outline of the probe, or null. [XmlIgnore] - [JsonProperty("contact_annotations")] - public ContactAnnotations ContactAnnotations { get; protected set; } + [JsonProperty("probe_planar_contour", NullValueHandling = NullValueHandling.Ignore)] + public double[][]? ProbePlanarContour { get; } /// - /// Gets the positions, specifically the center point of every contact. + /// Gets the contacts on this probe. Each carries all per-contact data: + /// position, shape, shank, plane axes, side, and annotations. /// - /// - /// This is a two-dimensional array of floats; the first index is the index of the contact, and - /// the second index is the X and Y value, respectively. - /// - [XmlIgnore] - [JsonProperty("contact_positions", Required = Required.Always)] - public float[][] ContactPositions { get; protected set; } - - /// - /// Gets the plane axes for the contacts. - /// - [XmlIgnore] - [JsonProperty("contact_plane_axes")] - public float[][][] ContactPlaneAxes { get; protected set; } + [JsonIgnore] + public IReadOnlyList Contacts { get; } - /// - /// Gets the for each contact. - /// - [XmlIgnore] - [JsonProperty("contact_shapes", Required = Required.Always)] - public ContactShape[] ContactShapes { get; protected set; } + /// Gets the number of contacts on this probe. + [JsonIgnore] + public int NumberOfContacts => Contacts.Count; - /// - /// Gets the parameters of the shape for each contact. - /// - /// - /// Depending on which - /// is selected, not all parameters are needed; for instance, only uses - /// , while just uses - /// . - /// - [XmlIgnore] - [JsonProperty("contact_shape_params", Required = Required.Always)] - public ContactShapeParam[] ContactShapeParams { get; protected set; } + /// Gets the contact annotation keys defined for this probe. + [JsonIgnore] + public IEnumerable ContactAnnotationKeys => + annotationStore.Data?.Keys ?? Enumerable.Empty(); - /// - /// Gets the outline of the probe that represents the physical shape. - /// - [XmlIgnore] - [JsonProperty("probe_planar_contour")] - public float[][] ProbePlanarContour { get; protected set; } + private Dictionary? channelMap; /// - /// Gets the indices of each channel defining their recording channel number. Must be unique, except for contacts - /// that are set to -1 if they disabled. + /// 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 . /// - [XmlIgnore] - [JsonProperty("device_channel_indices")] - public int[] DeviceChannelIndices { get; internal set; } + [JsonIgnore] + public IReadOnlyDictionary? ChannelMap => channelMap; - /// - /// Gets the contact IDs for each channel. These do not have to be unique. - /// - [XmlIgnore] - [JsonProperty("contact_ids")] - public string[] ContactIds { get; internal set; } + /// Replaces the channel map. Called by to keep mapping consistent across all probes. + internal void SetChannelMap(Dictionary? map) => channelMap = map; - /// - /// Gets the shank that each contact belongs to. - /// - [XmlIgnore] - [JsonProperty("shank_ids")] - public string[] ShankIds { get; internal set; } + private readonly ContactAnnotationStore annotationStore = new ContactAnnotationStore(); - /// - /// Public constructor, defined as the default Json constructor. - /// - /// Number of dimensions to use while plotting the contacts [ or ]. - /// Real-world units to use while plotting the contacts [ or ]. - /// Annotations for the probe. - /// Annotations for each contact as an array of strings. - /// Center position of each contact in a two-dimensional array of floats. For more info, see . - /// Plane axes of each contact in a three-dimensional array of floats. For more info, see . - /// Array of shapes for each contact. - /// Array of shape parameters for the each contact. - /// Two-dimensional array of floats (X and Y positions) defining a closed shape for a probe contour. - /// Array of integers containing the device channel indices for each contact. For more info, see . - /// Array of strings containing the contact ID for each contact. For more info, see . - /// Array of strings containing the shank ID for each contact. For more info, see . - [JsonConstructor] - public Probe(ProbeNdim ndim, ProbeSiUnits si_units, ProbeAnnotations annotations, ContactAnnotations contact_annotations, - float[][] contact_positions, float[][][] contact_plane_axes, ContactShape[] contact_shapes, - ContactShapeParam[] contact_shape_params, float[][] probe_planar_contour, int[] device_channel_indices, - string[] contact_ids, string[] shank_ids) - { - NumDimensions = ndim; - SiUnits = si_units; - Annotations = annotations; - ContactAnnotations = contact_annotations; - ContactPositions = contact_positions; - ContactPlaneAxes = contact_plane_axes; - ContactShapes = contact_shapes; - ContactShapeParams = contact_shape_params; - ProbePlanarContour = probe_planar_contour; - DeviceChannelIndices = device_channel_indices; - ContactIds = contact_ids; - ShankIds = shank_ids; - } + // Newtonsoft.Json serializes non-public [JsonProperty] members; deserialization + // goes through [JsonConstructor] so these getters are never called during reads. - /// - /// Copy constructor given an existing object. - /// - /// Existing object to be copied. - public Probe(Probe probe) - { - NumDimensions = probe.NumDimensions; - SiUnits = probe.SiUnits; - Annotations = probe.Annotations; - ContactAnnotations = probe.ContactAnnotations; - ContactPositions = probe.ContactPositions; - ContactPlaneAxes = probe.ContactPlaneAxes; - ContactShapes = probe.ContactShapes; - ContactShapeParams = probe.ContactShapeParams; - ProbePlanarContour = probe.ProbePlanarContour; - DeviceChannelIndices = probe.DeviceChannelIndices; - ContactIds = probe.ContactIds; - ShankIds = probe.ShankIds; - } + [JsonProperty("contact_positions", Required = Required.Always)] + private double[][] ContactPositionsJson => Contacts.Select(c => + c.PosZ.HasValue + ? new double[] { c.PosX, c.PosY, c.PosZ.Value } + : new double[] { c.PosX, c.PosY }).ToArray(); - /// - /// Returns default array that contains the given number of channels and the corresponding shape. - /// - /// Number of contacts in a single . - /// The to apply to each contact. - /// array. - public static ContactShape[] DefaultContactShapes(int numberOfContacts, ContactShape contactShape) - { - ContactShape[] contactShapes = new ContactShape[numberOfContacts]; + [JsonProperty("contact_plane_axes", NullValueHandling = NullValueHandling.Ignore)] + private double[][][]? ContactPlaneAxesJson => + Contacts.All(c => c.PlaneAxes == null) ? null + : Contacts.Select(c => c.PlaneAxes ?? new double[][] { new double[] { 1, 0 }, new double[] { 0, 1 } }).ToArray(); - for (int i = 0; i < numberOfContacts; i++) - { - contactShapes[i] = contactShape; - } + [JsonProperty("contact_shapes", Required = Required.Always)] + private ContactShape[] ContactShapesJson => Contacts.Select(c => c.Shape).ToArray(); - return contactShapes; - } + [JsonProperty("contact_shape_params", Required = Required.Always)] + private ContactShapeParam[] ContactShapeParamsJson => Contacts.Select(c => c.ShapeParams).ToArray(); - /// - /// Returns a default contactPlaneAxes array, with each contact given the same axis; { { 1, 0 }, { 0, 1 } } - /// - /// - /// See Probeinterface documentation for more info. - /// - /// Number of contacts in a single . - /// Three-dimensional array of s. - public static float[][][] DefaultContactPlaneAxes(int numberOfContacts) + [JsonProperty("device_channel_indices", NullValueHandling = NullValueHandling.Ignore)] + private int[]? DeviceChannelIndicesJson { - float[][][] contactPlaneAxes = new float[numberOfContacts][][]; - - for (int i = 0; i < numberOfContacts; i++) + get { - contactPlaneAxes[i] = new float[2][] { new float[2] { 1.0f, 0.0f }, new float[2] { 0.0f, 1.0f } }; + 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; + return arr; } - - return contactPlaneAxes; } - /// - /// Returns an array of s for a . - /// - /// Number of contacts in a single . - /// Radius of the contact, in units of . - /// array. - public static ContactShapeParam[] DefaultCircleParams(int numberOfContacts, float radius) - { - ContactShapeParam[] contactShapeParams = new ContactShapeParam[numberOfContacts]; + [JsonProperty("contact_ids", NullValueHandling = NullValueHandling.Ignore)] + private string?[]? ContactIdsJson => Contacts.All(c => c.ContactId == null) ? null + : Contacts.Select(c => c.ContactId).ToArray(); - for (int i = 0; i < numberOfContacts; i++) - { - contactShapeParams[i] = new ContactShapeParam(radius: radius); - } + [JsonProperty("shank_ids", NullValueHandling = NullValueHandling.Ignore)] + private string?[]? ShankIdsJson => Contacts.All(c => c.ShankId == null) ? null + : Contacts.Select(c => c.ShankId).ToArray(); - return contactShapeParams; - } + [JsonProperty("contact_sides", NullValueHandling = NullValueHandling.Ignore)] + private string[]? ContactSidesJson => + Contacts.All(c => c.Side == null) ? null + : Contacts.Select(c => c.Side ?? "").ToArray(); + + [JsonProperty("contact_annotations", NullValueHandling = NullValueHandling.Ignore)] + private Dictionary? ContactAnnotationsJson => annotationStore.Data; /// - /// Returns an array of s for a . + /// JSON constructor. Deserializes a probe from the probeinterface parallel-array format and + /// builds the collection. Throws if any + /// parallel arrays have inconsistent lengths. /// - /// Number of contacts in a single . - /// Width of the contact, in units of . - /// array. - public static ContactShapeParam[] DefaultSquareParams(int numberOfContacts, float width) + [JsonConstructor] + internal Probe( + ProbeNdim ndim, ProbeSiUnits si_units, ProbeAnnotations annotations, + Dictionary? contact_annotations, + double[][] contact_positions, double[][][]? contact_plane_axes, + ContactShape[] contact_shapes, ContactShapeParam[] contact_shape_params, + double[][]? probe_planar_contour, int[]? device_channel_indices, + string[]? contact_ids, string[]? shank_ids, string[]? contact_sides) { - ContactShapeParam[] contactShapeParams = new ContactShapeParam[numberOfContacts]; - - for (int i = 0; i < numberOfContacts; i++) + int n = contact_positions.Length; + + if (contact_shapes.Length != n || contact_shape_params.Length != n) + throw new ArgumentException( + $"contact_positions ({n}), contact_shapes ({contact_shapes.Length}), and " + + $"contact_shape_params ({contact_shape_params.Length}) must all have the same length."); + + if (contact_plane_axes != null && contact_plane_axes.Length != n) + throw new ArgumentException( + $"contact_plane_axes length ({contact_plane_axes.Length}) must match contact count ({n})."); + if (contact_ids != null && contact_ids.Length != n) + throw new ArgumentException( + $"contact_ids length ({contact_ids.Length}) must match contact count ({n})."); + if (shank_ids != null && shank_ids.Length != n) + throw new ArgumentException( + $"shank_ids length ({shank_ids.Length}) must match contact count ({n})."); + if (device_channel_indices != null && device_channel_indices.Length != n) + throw new ArgumentException( + $"device_channel_indices length ({device_channel_indices.Length}) must match contact count ({n})."); + if (contact_sides != null && contact_sides.Length != n) + throw new ArgumentException( + $"contact_sides length ({contact_sides.Length}) must match contact count ({n})."); + if (contact_annotations != null) { - contactShapeParams[i] = new ContactShapeParam(width: width); + foreach (var kvp in contact_annotations) + { + if (kvp.Value.Length != n) + throw new ArgumentException( + $"contact_annotations[\"{kvp.Key}\"] length ({kvp.Value.Length}) must match contact count ({n})."); + } } - return contactShapeParams; - } - - /// - /// Returns an array of s for a . - /// - /// Number of contacts in a single . - /// Width of the contact, in units of . - /// Height of the contact, in units of . - /// array. - public static ContactShapeParam[] DefaultRectParams(int numberOfContacts, float width, float height) - { - ContactShapeParam[] contactShapeParams = new ContactShapeParam[numberOfContacts]; + NumDimensions = ndim; + SiUnits = si_units; + Annotations = annotations; + ProbePlanarContour = probe_planar_contour; + annotationStore.Data = contact_annotations; - for (int i = 0; i < numberOfContacts; i++) + // Convert the parallel array to a dictionary, skipping -1 (not connected) entries. + if (device_channel_indices != null) { - contactShapeParams[i] = new ContactShapeParam(width: width, height: height); + var map = new Dictionary(); + for (int i = 0; i < device_channel_indices.Length; i++) + { + if (device_channel_indices[i] != -1) + map[i] = device_channel_indices[i]; + } + channelMap = map.Count > 0 ? map : null; } - return contactShapeParams; + Contacts = BuildContacts(n, contact_positions, contact_plane_axes, contact_shapes, + contact_shape_params, contact_ids, shank_ids, contact_sides, annotationStore); } /// - /// Returns a default array of sequential . + /// Constructs the array from the probeinterface parallel arrays. + /// All contacts receive a reference to the same so mutations made + /// through any contact are immediately visible probe-wide. /// - /// Number of contacts in a single . - /// The first value of the . - /// A serially increasing array of . - public static int[] DefaultDeviceChannelIndices(int numberOfContacts, int offset) + private static Contact[] BuildContacts( + int n, double[][] positions, double[][][]? planeAxes, + ContactShape[] shapes, ContactShapeParam[] shapeParams, + string[]? contactIds, string[]? shankIds, + string[]? contactSides, ContactAnnotationStore store) { - int[] deviceChannelIndices = new int[numberOfContacts]; - - for (int i = 0; i < numberOfContacts; i++) + var contacts = new Contact[n]; + for (int i = 0; i < n; i++) { - deviceChannelIndices[i] = i + offset; + double? posZ = positions[i].Length >= 3 ? positions[i][2] : (double?)null; + contacts[i] = new Contact( + posX: positions[i][0], + posY: positions[i][1], + posZ: posZ, + shape: shapes[i], + shapeParams: shapeParams[i], + contactId: contactIds?[i], + shankId: shankIds?[i], + planeAxes: planeAxes?[i], + side: contactSides?[i], + index: i, + totalContacts: n, + store: store); } - - return deviceChannelIndices; + return contacts; } /// - /// Returns a sequential array of . + /// 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. /// - /// Number of contacts in a single . - /// Array of strings defining the . - public static string[] DefaultContactIds(int numberOfContacts) + public IReadOnlyDictionary? GetChannelMap() { - string[] contactIds = new string[numberOfContacts]; - - for (int i = 0; i < numberOfContacts; i++) - { - contactIds[i] = i.ToString(); - } - - return contactIds; + 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; } /// - /// Returns an array of empty strings as the default shank ID. + /// 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 . /// - /// Number of contacts in a single . - /// Array of empty strings. - public static string[] DefaultShankIds(int numberOfContacts) + public T[]? GetContactAnnotation(string key) { - string[] contactIds = new string[numberOfContacts]; - - for (int i = 0; i < numberOfContacts; i++) - { - contactIds[i] = ""; - } - - return contactIds; + if (annotationStore.Data == null || !annotationStore.Data.TryGetValue(key, out var values) || values == null) + return null; + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + return Array.ConvertAll(values, v => v == null ? default! : (T)Convert.ChangeType(v, targetType)); } /// - /// Returns a object. + /// 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. /// - /// Relative index of the contact in this . - /// . - public Contact GetContact(int index) + public void SetContactAnnotation(string key, T[] values) { - return new Contact(ContactPositions[index][0], ContactPositions[index][1], ContactShapes[index], ContactShapeParams[index], - DeviceChannelIndices[index], ContactIds[index], ShankIds[index], index); + if (values.Length != NumberOfContacts) + throw new ArgumentException( + $"Annotation array length ({values.Length}) must match the number of contacts ({NumberOfContacts}).", + nameof(values)); + + annotationStore.Data ??= new Dictionary(); + annotationStore.Data[key] = Array.ConvertAll(values, v => (object)v!); } /// - /// Gets the number of contacts within this . + /// Removes the per-contact annotation with the given key entirely. + /// Returns true if the key was found and removed. /// - [JsonIgnore] - public int NumberOfContacts => ContactPositions.Length; + 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 72f9011..d0ee774 100644 --- a/OpenEphys.ProbeInterface.NET/ProbeAnnotations.cs +++ b/OpenEphys.ProbeInterface.NET/ProbeAnnotations.cs @@ -1,44 +1,69 @@ -using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace OpenEphys.ProbeInterface.NET { /// - /// Class holding the annotations. + /// 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 name of the probe as defined by the manufacturer, or a descriptive name such as the neurological target. - /// - [JsonProperty("name")] - public string Name { get; protected set; } + /// Gets the model name of the probe as defined by the manufacturer. + [JsonProperty("model_name")] + public string ModelName { get; } - /// - /// Gets the name of the manufacturer who created the probe. - /// + /// Gets the name of the manufacturer who created the probe. [JsonProperty("manufacturer")] - public string Manufacturer { get; protected set; } + public string Manufacturer { get; } + + [JsonExtensionData] + private Dictionary? additionalProperties; + + /// Gets the keys of all additional annotations present on this probe. + [JsonIgnore] + public IEnumerable AnnotationKeys => + additionalProperties?.Keys ?? Enumerable.Empty(); /// - /// Initializes a new instance of the class. + /// Used by Newtonsoft.Json during deserialization. /// - /// String defining the name of the probe. - /// String defining the manufacturer of the probe. [JsonConstructor] - public ProbeAnnotations(string name, string manufacturer) + internal ProbeAnnotations(string model_name, string manufacturer) { - Name = name; + ModelName = model_name; Manufacturer = manufacturer; } /// - /// Copy constructor that copies data from an existing object. + /// Returns an additional annotation value for the given key converted to + /// , or the default value of if absent. + /// + public T? GetAnnotation(string key) + { + if (additionalProperties == null || !additionalProperties.TryGetValue(key, out var token)) + return default; + return token.ToObject(); + } + + /// + /// Adds or replaces an additional annotation for the given key. /// - /// Existing object, containing a and a . - protected ProbeAnnotations(ProbeAnnotations probeAnnotations) + public void SetAnnotation(string key, T value) { - Name = probeAnnotations.Name; - Manufacturer = probeAnnotations.Manufacturer; + additionalProperties ??= new Dictionary(); + additionalProperties[key] = JToken.FromObject(value!); } + + /// + /// Removes the additional annotation with the given key. + /// Returns true if the key was found and removed. + /// + 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 b5955d0..fb53257 100644 --- a/OpenEphys.ProbeInterface.NET/ProbeGroup.cs +++ b/OpenEphys.ProbeInterface.NET/ProbeGroup.cs @@ -1,328 +1,130 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Xml.Serialization; using Newtonsoft.Json; -using System; namespace OpenEphys.ProbeInterface.NET { /// - /// Abstract class that implements the Probeinterface specification in C# for .NET. + /// Implements the probeinterface specification in C# for .NET. /// - public abstract class ProbeGroup + public class ProbeGroup { - /// - /// Gets the string defining the specification of the file. - /// - /// - /// For Probeinterface files, this value is expected to be "probeinterface". - /// + 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); + + /// Gets the specification identifier. Must be "probeinterface". [JsonProperty("specification", Required = Required.Always)] - public string Specification { get; protected set; } + public string Specification { get; } - /// - /// Gets the string defining which version of Probeinterface was used. - /// + /// Gets the probeinterface version string (major.minor.patch). [JsonProperty("version", Required = Required.Always)] - public string Version { get; protected set; } + public string Version { get; } /// - /// Gets an IEnumerable of probes that are present. + /// Gets the probes in this group. Use on each probe for + /// per-contact data and for the channel mapping. /// - /// - /// Each probe can contain multiple shanks, and each probe has a unique - /// contour that defines the physical representation of the probe. Contacts have several representations - /// for their channel number, specifically (a string that is not guaranteed to be unique) and - /// (guaranteed to be unique across all probes). 's can also be set to -1 - /// to indicate that the channel was not connected or recorded from. - /// [XmlIgnore] [JsonProperty("probes", Required = Required.Always)] - public IEnumerable Probes { get; protected set; } + public IEnumerable Probes { get; } + + /// Gets the total number of contacts across all probes. + [JsonIgnore] + public int NumberOfContacts => Probes.Sum(p => p.NumberOfContacts); /// - /// Initializes a new instance of the class. + /// Initializes a and immediately validates it. + /// Used by Newtonsoft.Json during deserialization. /// - /// String defining the parameter. - /// String defining the parameter. - /// IEnumerable of objects. - public ProbeGroup(string specification, string version, IEnumerable probes) + /// Must be "probeinterface". + /// Semver string (major.minor.patch). + /// One or more probes. + [JsonConstructor] + protected ProbeGroup(string specification, string version, IEnumerable probes) { Specification = specification; Version = version; Probes = probes; - Validate(); } - /// - /// Copy constructor that takes in an existing object and copies the individual fields. - /// - /// - /// After copying the relevant fields, the is validated to ensure that it is compliant - /// with the Probeinterface specification. See for more details on what is checked. - /// - /// Existing object. + /// Protected copy constructor for subclasses. protected ProbeGroup(ProbeGroup probeGroup) { Specification = probeGroup.Specification; Version = probeGroup.Version; Probes = probeGroup.Probes; - Validate(); } /// - /// Gets the number of contacts across all objects. + /// 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. /// - [JsonIgnore] - public int NumberOfContacts => Probes.Aggregate(0, (total, next) => total + next.NumberOfContacts); - - /// - /// Returns the 's of all contacts in all probes. - /// - /// - /// Note that these are not guaranteed to be unique values across probes. - /// - /// List of strings containing all contact IDs. - public IEnumerable GetContactIds() + private void Validate() { - List contactIds = new(); - - foreach (var probe in Probes) - { - contactIds.AddRange(probe.ContactIds.ToList()); - } + if (Specification != "probeinterface") + throw new InvalidOperationException( + $"Specification must be \"probeinterface\" but was \"{Specification}\"."); - return contactIds; - } + if (string.IsNullOrEmpty(Version) || !VersionPattern.IsMatch(Version)) + throw new InvalidOperationException( + $"Version \"{Version}\" does not match the required pattern major.minor.patch (e.g. \"0.3.2\")."); - /// - /// Returns all objects in the . - /// - /// - public List GetContacts() - { - List contacts = new(); + var v = new Version(Version); + if (v.Major != SupportedSpecVersion.Major || v.Minor != SupportedSpecVersion.Minor) + throw new InvalidOperationException( + $"Version \"{Version}\" is not compatible with this library, which implements " + + $"probeinterface {SupportedSpecVersion.Major}.{SupportedSpecVersion.Minor}.x."); - foreach (var p in Probes) - { - for (int i = 0; i < p.NumberOfContacts; i++) - { - contacts.Add(p.GetContact(i)); - } - } + if (Probes == null || !Probes.Any()) + throw new InvalidOperationException("At least one probe must be defined."); - return contacts; + if (!ValidateDeviceChannelIndices()) + throw new InvalidOperationException("Device channel indices are not unique across all probes."); } /// - /// Returns all 's in the . + /// 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. /// - /// - /// Device channel indices are guaranteed to be unique, unless they are -1. Multiple contacts can be - /// set to -1 to indicate they are not recorded from. - /// - /// - public IEnumerable GetDeviceChannelIndices() + internal bool ValidateDeviceChannelIndices() { - List deviceChannelIndices = new(); - - foreach (var probe in Probes) - { - deviceChannelIndices.AddRange(probe.DeviceChannelIndices.ToList()); - } - - return deviceChannelIndices; + var active = Probes + .Where(p => p.ChannelMap != null) + .SelectMany(p => p.ChannelMap!.Values) + .ToList(); + return active.Count == active.Distinct().Count(); } /// - /// Validate that the correctly implements the Probeinterface specification. + /// 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. /// - /// - /// Check that all necessary fields are populated (, - /// , ). - /// Check that there is at least one defined. - /// Check that all variables in each have the same length. - /// Check if are present, and generate default values - /// based on the index if there are no values defined. - /// Check if are zero-indexed, and convert to - /// zero-indexed if possible. - /// Check if are defined, and initialize empty strings - /// if they are not defined. - /// Check if are defined, and initialize default - /// values (using the value as the new ). - /// Check that all are unique across all 's, - /// unless the value is -1; multiple contacts can be set to -1. - /// - public void Validate() - { - if (string.IsNullOrEmpty(Specification)) - { - throw new InvalidOperationException("Specification string must be defined."); - } - - if (string.IsNullOrEmpty(Version)) - { - throw new InvalidOperationException("Version string must be defined."); - } - - if (Probes == null || Probes.Count() == 0) - { - throw new InvalidOperationException("No probes are listed, probes must be added during construction"); - } - - ValidateVariableLength(); - - SetDefaultContactIdsIfMissing(); - ForceContactIdsToZeroIndexed(); - SetEmptyShankIdsIfMissing(); - SetDefaultDeviceChannelIndicesIfMissing(); - - if (!ValidateDeviceChannelIndices()) - { - throw new Exception("Device channel indices are not unique across all probes."); - } - } - - private void ValidateVariableLength() + public IReadOnlyDictionary? GetChannelMap() { - for (int i = 0; i < Probes.Count(); i++) - { - if (Probes.ElementAt(i).NumberOfContacts != Probes.ElementAt(i).ContactPositions.Count() || - Probes.ElementAt(i).NumberOfContacts != Probes.ElementAt(i).ContactPlaneAxes.Count() || - Probes.ElementAt(i).NumberOfContacts != Probes.ElementAt(i).ContactShapeParams.Count() || - Probes.ElementAt(i).NumberOfContacts != Probes.ElementAt(i).ContactShapes.Count()) - { - throw new InvalidOperationException($"Required contact parameters are not the same length in probe {i}. " + - "Check positions / plane axes / shapes / shape parameters for lengths."); - } - - if (Probes.ElementAt(i).ContactIds != null && - Probes.ElementAt(i).ContactIds.Count() != Probes.ElementAt(i).NumberOfContacts) - { - throw new InvalidOperationException($"Contact IDs does not have the correct number of channels for probe {i}"); - } - - if (Probes.ElementAt(i).ShankIds != null && - Probes.ElementAt(i).ShankIds.Count() != Probes.ElementAt(i).NumberOfContacts) - { - throw new InvalidOperationException($"Shank IDs does not have the correct number of channels for probe {i}"); - } - - if (Probes.ElementAt(i).DeviceChannelIndices != null && - Probes.ElementAt(i).DeviceChannelIndices.Count() != Probes.ElementAt(i).NumberOfContacts) - { - throw new InvalidOperationException($"Device Channel Indices does not have the correct number of channels for probe {i}"); - } - } - } - - private void SetDefaultContactIdsIfMissing() - { - for (int i = 0; i < Probes.Count(); i++) - { - if (Probes.ElementAt(i).ContactIds == null) - { - Probes.ElementAt(i).ContactIds = Probe.DefaultContactIds(Probes.ElementAt(i).NumberOfContacts); - } - } - } - - private void ForceContactIdsToZeroIndexed() - { - var contactIds = GetContactIds(); - var numericIds = contactIds.Select(c => { return int.Parse(c); }) - .ToList(); - - var min = numericIds.Min(); - var max = numericIds.Max(); - - if (min == 1 && max == NumberOfContacts && numericIds.Count == numericIds.Distinct().Count()) - { - for (int i = 0; i < Probes.Count(); i++) - { - var probe = Probes.ElementAt(i); - var newContactIds = probe.ContactIds.Select(c => { return (int.Parse(c) - 1).ToString(); }); - - for (int j = 0; j < probe.NumberOfContacts; j++) - { - probe.ContactIds.SetValue(newContactIds.ElementAt(j), j); - } - } - } - } - - private void SetEmptyShankIdsIfMissing() - { - for (int i = 0; i < Probes.Count(); i++) - { - if (Probes.ElementAt(i).ShankIds == null) - { - Probes.ElementAt(i).ShankIds = Probe.DefaultShankIds(Probes.ElementAt(i).NumberOfContacts); - } - } - } - - private void SetDefaultDeviceChannelIndicesIfMissing() - { - for (int i = 0; i < Probes.Count(); i++) + var result = new Dictionary(); + int probeIndex = 0; + foreach (var probe in Probes) { - if (Probes.ElementAt(i).DeviceChannelIndices == null) + var perProbe = probe.GetChannelMap(); + if (perProbe != null) { - Probes.ElementAt(i).DeviceChannelIndices = new int[Probes.ElementAt(i).NumberOfContacts]; - - for (int j = 0; j < Probes.ElementAt(i).NumberOfContacts; j++) - { - if (int.TryParse(Probes.ElementAt(i).ContactIds[j], out int result)) - { - Probes.ElementAt(i).DeviceChannelIndices[j] = result; - } - } + foreach (var kvp in perProbe) + result[kvp.Key] = (probeIndex, kvp.Value.ContactIndex, kvp.Value.Contact); } + probeIndex++; } - } - - /// - /// Validate the uniqueness of all 's across all 's. - /// - /// - /// All indices that are greater than or equal to 0 must be unique, - /// but there can be as many values equal to -1 as there are contacts. A value of -1 indicates that this contact is - /// not being recorded. - /// - /// True if all values not equal to -1 are unique, False if there are duplicates. - public bool ValidateDeviceChannelIndices() - { - var activeChannels = GetDeviceChannelIndices().Where(index => index != -1); - return activeChannels.Count() == activeChannels.Distinct().Count(); - } - - /// - /// Update the at the given probe index. - /// - /// - /// Device channel indices can be updated as contacts are being enabled or disabled. This is done on a - /// per-probe basis, where the incoming array of indices must be the same size as the original probe, - /// and must follow the standard for uniqueness found in . - /// - /// Zero-based index of the probe to update. - /// Array of . - /// - public void UpdateDeviceChannelIndices(int probeIndex, int[] deviceChannelIndices) - { - if (Probes.ElementAt(probeIndex).DeviceChannelIndices.Length != deviceChannelIndices.Length) - { - throw new ArgumentException($"Incoming device channel indices have {deviceChannelIndices.Length} contacts, " + - $"but the existing probe {probeIndex} has {Probes.ElementAt(probeIndex).DeviceChannelIndices.Length} contacts"); - } - - Probes.ElementAt(probeIndex).DeviceChannelIndices = deviceChannelIndices; - - if (!ValidateDeviceChannelIndices()) - { - throw new ArgumentException("Device channel indices are not valid. Ensure that all values are either -1 or are unique."); - } + return result.Count > 0 ? result : null; } } } diff --git a/OpenEphys.ProbeInterface.NET/ProbeNdim.cs b/OpenEphys.ProbeInterface.NET/ProbeNdim.cs index b1da56e..afee773 100644 --- a/OpenEphys.ProbeInterface.NET/ProbeNdim.cs +++ b/OpenEphys.ProbeInterface.NET/ProbeNdim.cs @@ -1,6 +1,4 @@ -using System.Runtime.Serialization; - -namespace OpenEphys.ProbeInterface.NET +namespace OpenEphys.ProbeInterface.NET { /// /// Number of dimensions to use while plotting a . @@ -10,13 +8,11 @@ public enum ProbeNdim /// /// Two-dimensions. /// - [EnumMember(Value = "2")] Two = 2, /// /// Three-dimensions. /// - [EnumMember(Value = "3")] Three = 3, } } diff --git a/README.md b/README.md index 885af55..5d0b45b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,76 @@ # OpenEphys.ProbeInterface.NET -C# .NET API based on [Probe Interface](https://probeinterface.readthedocs.io/en/main/index.html) specifications for parsing channel configurations. + +A C# .NET library for reading and writing neural probe configurations in the +[probeinterface](https://probeinterface.readthedocs.io) JSON format. +Probeinterface is an open standard describing probe geometry, contact positions, +and channel mappings, and is widely used by Open Ephys, SpikeInterface, and +related tools. + +## What it does + +- **Deserializes** probeinterface JSON files into strongly-typed .NET objects +- **Serializes** probe configurations back to compliant JSON +- **Validates** files against the specification on load (correct version format, + required fields, unique channel indices, array length consistency) +- **Supports** all probe properties: contact positions (2D and 3D), shapes + (`circle`, `rect`, `square`), plane axes, planar contour, shank IDs, contact + sides, and free-form per-contact annotations with typed access + +## Quick start + +```csharp +using Newtonsoft.Json; +using OpenEphys.ProbeInterface.NET; + +// Load a probeinterface JSON file +var json = File.ReadAllText("my_probe.json"); +var probeGroup = JsonConvert.DeserializeObject(json); + +// Iterate contacts on each probe +foreach (var probe in probeGroup.Probes) +{ + foreach (var contact in probe.Contacts) + Console.WriteLine($"Contact {contact.ContactId}: ({contact.PosX}, {contact.PosY})"); +} + +// Access per-contact annotations at the contact level +var probe0 = probeGroup.Probes.First(); +var contact0 = probe0.Contacts[0]; +double? impedance = contact0.GetAnnotation("impedance"); +string? region = contact0.GetAnnotation("brain_area"); + +// Set or remove an annotation on a single contact +contact0.SetAnnotation("brain_area", "CA1"); +contact0.RemoveAnnotation("brain_area"); + +// Bulk access across all contacts on a probe (returns one element per contact) +double[]? impedances = probe0.GetContactAnnotation("impedance"); +probe0.SetContactAnnotation("brain_area", new[] { "CA1", "CA3", "DG" }); +probe0.RemoveContactAnnotation("brain_area"); + +// Access probe-level annotations (model, manufacturer, any custom fields) +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) +// 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}"); + +// Wire a single contact, or clear the mapping when done +ChannelWiring.WireChannel(probeGroup, probeIndex: 0, contactIndex: 4, channel: 7); +ChannelWiring.UnwireChannel(probeGroup, probeIndex: 0, contactIndex: 4); +ChannelWiring.UnwireChannels(probeGroup, probeIndex: 0); // clear one probe +ChannelWiring.UnwireChannels(probeGroup); // clear all probes + +// Serialize back to JSON +File.WriteAllText("output.json", JsonConvert.SerializeObject(probeGroup, Formatting.Indented)); +```