Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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 }}
NUGET_APIKEY: ${{ secrets.NUGET_APIKEY }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ ASALocalRun/

# MSBuild Binary and Structured Log
*.binlog
*.trx

# NVidia Nsight GPU debugger configuration file
*.nvuser
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<Authors>Open Ephys</Authors>
<Copyright>Copyright © Open Ephys and Contributors 2024</Copyright>
<Copyright>Copyright © Open Ephys</Copyright>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<IncludeSymbols>true</IncludeSymbols>
Expand All @@ -12,7 +12,7 @@
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<UseArtifactsOutput>true</UseArtifactsOutput>
<PackageIcon>icon.png</PackageIcon>
<VersionPrefix>0.3.0</VersionPrefix>
<VersionPrefix>0.4.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
<LangVersion>10.0</LangVersion>
<Features>strict</Features>
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
263 changes: 263 additions & 0 deletions OpenEphys.ProbeInterface.NET.Tests/ContactAnnotationsTests.cs
Original file line number Diff line number Diff line change
@@ -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<ProbeGroup>(Json)!;
var areas = group.Probes.First().GetContactAnnotation<string>("brain_area");
Assert.Equal(new[] { "CA1", "CA1", "DG" }, areas);
}

[Fact]
public void NumericAnnotation_TypedAccessor_Double()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var impedances = group.Probes.First().GetContactAnnotation<double>("impedance");
Assert.Equal(new[] { 125.3, 98.7, 110.1 }, impedances);
}

[Fact]
public void NumericAnnotation_TypedAccessor_NullableDouble()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var impedances = group.Probes.First().GetContactAnnotation<double?>("impedance");
Assert.Equal(new double?[] { 125.3, 98.7, 110.1 }, impedances);
}

[Fact]
public void NumericAnnotation_TypedAccessor_Float()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var impedances = group.Probes.First().GetContactAnnotation<float>("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<ProbeGroup>(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<double>(), 3);
}

[Fact]
public void ArbitraryKeys_RoundTrip()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var serialized = JsonConvert.SerializeObject(group);
var roundTrip = JsonConvert.DeserializeObject<ProbeGroup>(serialized)!;
var labels = roundTrip.Probes.First().GetContactAnnotation<string>("custom_label");
Assert.Equal(new[] { "a", "b", "c" }, labels);
}

[Fact]
public void MissingKey_ReturnsNull()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
Assert.Null(group.Probes.First().GetContactAnnotation<string>("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<ProbeGroup>(jsonNoAnnotations)!;
Assert.Empty(group.Probes.First().ContactAnnotationKeys);
Assert.Null(group.Probes.First().GetContactAnnotation<string>("brain_area"));
}

[Fact]
public void SetContactAnnotation_String_CanBeReadBack()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var probe = group.Probes.First();
probe.SetContactAnnotation("region", new[] { "CA3", "CA3", "CA1" });
Assert.Equal(new[] { "CA3", "CA3", "CA1" }, probe.GetContactAnnotation<string>("region"));
}

[Fact]
public void SetContactAnnotation_Numeric_RoundTrips()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(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<ProbeGroup>(serialized)!;
var gain = reloaded.Probes.First().GetContactAnnotation<double>("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<ProbeGroup>(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<string>("label"));
}

[Fact]
public void SetContactAnnotation_WrongLength_Throws()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var probe = group.Probes.First(); // 3 contacts
Assert.Throws<System.ArgumentException>(() =>
probe.SetContactAnnotation("bad", new[] { "only_two", "values" }));
}

[Fact]
public void RemoveContactAnnotation_RemovesKey()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var probe = group.Probes.First();
Assert.True(probe.RemoveContactAnnotation("brain_area"));
Assert.Null(probe.GetContactAnnotation<string>("brain_area"));
}

[Fact]
public void RemoveContactAnnotation_MissingKey_ReturnsFalse()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
Assert.False(group.Probes.First().RemoveContactAnnotation("nonexistent"));
}

[Fact]
public void PerContact_SetAnnotation_CanBeReadBack()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var probe = group.Probes.First();
var contact = probe.Contacts[1];
contact.SetAnnotation("gain", 3.5);
Assert.Equal(3.5, contact.GetAnnotation<double>("gain"));
// Visible at probe level too
var all = probe.GetContactAnnotation<double>("gain");
Assert.NotNull(all);
Assert.Equal(3.5, all![1]);
}

[Fact]
public void PerContact_RemoveAnnotation_ClearsSlot()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var probe = group.Probes.First();
var contact = probe.Contacts[0];
Assert.True(contact.RemoveAnnotation("brain_area"));
Assert.Null(contact.GetAnnotation<string>("brain_area"));
// Key still present (other contacts still have values); probe-level array has null at [0]
var all = probe.GetContactAnnotation<string>("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<ProbeGroup>(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<string>("brain_area"));
}

[Fact]
public void PerContact_SetAnnotation_NewKey_OtherContactsGetDefault()
{
var group = JsonConvert.DeserializeObject<ProbeGroup>(Json)!;
var probe = group.Probes.First();
probe.Contacts[0].SetAnnotation("new_key", "only_first");
var all = probe.GetContactAnnotation<string>("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<ProbeGroup>(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<string>());
Assert.Equal(JTokenType.Null, arr[1]!.Type);
Assert.Equal(JTokenType.Null, arr[2]!.Type);
}
}
}
33 changes: 33 additions & 0 deletions OpenEphys.ProbeInterface.NET.Tests/FixtureHelper.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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();
}
}
}
20 changes: 20 additions & 0 deletions OpenEphys.ProbeInterface.NET.Tests/Fixtures/minimal_probe.json
Original file line number Diff line number Diff line change
@@ -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]
}
]
}
Loading
Loading