From 8202508ec62010b1ff91aae898f1fe699469dbc7 Mon Sep 17 00:00:00 2001 From: jonnew Date: Fri, 9 Aug 2024 17:15:03 -0400 Subject: [PATCH] Initial commit - I would also like to be able to control the other aspects of the hardware somehow. I could modify the command string based on changes in porperties but that is probably a correct way to do this. --- .bonsai/Bonsai.config | 76 +++++++++++++++ .bonsai/NuGet.config | 11 +++ .bonsai/Setup.cmd | 1 + .bonsai/Setup.ps1 | 19 ++++ .editorconfig | 27 ++++++ .github/workflows/build.yml | 91 ++++++++++++++++++ .gitignore | 6 ++ Directory.Build.props | 29 ++++++ LICENSE | 19 ++++ NuGet.config | 8 ++ OpenEphys.Commutator.sln | 34 +++++++ OpenEphys.Commutator/Commutator.cs | 86 +++++++++++++++++ .../OpenEphys.Commutator.csproj | 17 ++++ .../Properties/AssemblyInfo.cs | 7 ++ .../Properties/launchSettings.json | 10 ++ README.md | 4 + build/Version.props | 20 ++++ icon.png | Bin 0 -> 12462 bytes 18 files changed, 465 insertions(+) create mode 100644 .bonsai/Bonsai.config create mode 100644 .bonsai/NuGet.config create mode 100644 .bonsai/Setup.cmd create mode 100644 .bonsai/Setup.ps1 create mode 100644 .editorconfig create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 LICENSE create mode 100644 NuGet.config create mode 100644 OpenEphys.Commutator.sln create mode 100644 OpenEphys.Commutator/Commutator.cs create mode 100644 OpenEphys.Commutator/OpenEphys.Commutator.csproj create mode 100644 OpenEphys.Commutator/Properties/AssemblyInfo.cs create mode 100644 OpenEphys.Commutator/Properties/launchSettings.json create mode 100644 README.md create mode 100644 build/Version.props create mode 100644 icon.png diff --git a/.bonsai/Bonsai.config b/.bonsai/Bonsai.config new file mode 100644 index 0000000..ab4ecd0 --- /dev/null +++ b/.bonsai/Bonsai.config @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.bonsai/NuGet.config b/.bonsai/NuGet.config new file mode 100644 index 0000000..43ff030 --- /dev/null +++ b/.bonsai/NuGet.config @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/.bonsai/Setup.cmd b/.bonsai/Setup.cmd new file mode 100644 index 0000000..0dbbaef --- /dev/null +++ b/.bonsai/Setup.cmd @@ -0,0 +1 @@ +powershell -ExecutionPolicy Bypass -File ./Setup.ps1 \ No newline at end of file diff --git a/.bonsai/Setup.ps1 b/.bonsai/Setup.ps1 new file mode 100644 index 0000000..3cc17e7 --- /dev/null +++ b/.bonsai/Setup.ps1 @@ -0,0 +1,19 @@ +if (!(Test-Path "./Bonsai.exe")) { + $release = "https://github.com/bonsai-rx/bonsai/releases/latest/download/Bonsai.zip" + $configPath = "./Bonsai.config" + if (Test-Path $configPath) { + [xml]$config = Get-Content $configPath + $bootstrapper = $config.PackageConfiguration.Packages.Package.where{$_.id -eq 'Bonsai'} + if ($bootstrapper) { + $version = $bootstrapper.version + $release = "https://github.com/bonsai-rx/bonsai/releases/download/$version/Bonsai.zip" + } + } + Invoke-WebRequest $release -OutFile "temp.zip" + Move-Item -Path "NuGet.config" "temp.config" + Expand-Archive "temp.zip" -DestinationPath "." -Force + Move-Item -Path "temp.config" "NuGet.config" -Force + Remove-Item -Path "temp.zip" + Remove-Item -Path "Bonsai32.exe" +} +& .\Bonsai.exe --no-editor \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..514c50b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs +############################### +# Core EditorConfig Options # +############################### +# All files +[*] +indent_style = space + +# XML project files +[*.{csproj,vcxproj,vcxproj.filters,proj,projitems,shproj,bonsai}] +indent_size = 2 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# Code files +[*.{cs,csx}] +indent_size = 4 +insert_final_newline = true +charset = utf-8-bom +############################### +# .NET Coding Conventions # +############################### +[*.{cs}] +# Organize usings +dotnet_sort_system_directives_first = true \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..04f1da9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,91 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + release: + types: [published] +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_GENERATE_ASPNET_CERTIFICATE: false + ContinuousIntegrationBuild: true + CiRunNumber: ${{ github.run_number }} + CiRunPushSuffix: ${{ github.ref_name }}-ci${{ github.run_number }} + CiRunPullSuffix: pull-${{ github.event.number }}-ci${{ github.run_number }} +jobs: + setup: + runs-on: ubuntu-latest + outputs: + build-suffix: ${{ steps.setup-build.outputs.build-suffix }} + steps: + - name: Setup Build + id: setup-build + run: echo "build-suffix=${{ github.event_name == 'push' && env.CiRunPushSuffix || github.event_name == 'pull_request' && env.CiRunPullSuffix || null }}" >> "$GITHUB_OUTPUT" + + build: + needs: [setup] + strategy: + fail-fast: false + matrix: + configuration: [debug, release] + os: [ubuntu-latest, windows-latest] + include: + - os: windows-latest + configuration: release + collect-packages: true + runs-on: ${{ matrix.os }} + env: + CiBuildVersionSuffix: ${{ needs.setup.outputs.build-suffix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration ${{ matrix.configuration }} + + - name: Pack + id: pack + if: matrix.collect-packages + run: dotnet pack --no-build --configuration ${{ matrix.configuration }} + + - name: Collect packages + uses: actions/upload-artifact@v4 + if: matrix.collect-packages && steps.pack.outcome == 'success' && always() + with: + name: Packages + if-no-files-found: error + path: artifacts/package/${{matrix.configuration}}/** + + publish-github: + runs-on: ubuntu-latest + permissions: + packages: write + needs: [build] + if: github.event_name == 'push' || github.event_name == 'release' + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: Packages + path: Packages + + - name: Push to GitHub Packages + run: dotnet nuget push "Packages/*.nupkg" --skip-duplicate --no-symbols --api-key ${{secrets.GITHUB_TOKEN}} --source https://nuget.pkg.github.com/${{github.repository_owner}} + env: + # This is a workaround for https://github.com/NuGet/Home/issues/9775 + DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER: 0 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bd1fb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.vs/ +/artifacts/ +.bonsai/Packages/ +.bonsai/*.exe +.bonsai/*.exe.settings +.bonsai/*.exe.WebView2/ \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6f1b56b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,29 @@ + + + + Open Ephys + Copyright © Open Ephys and Contributors 2024 + https://open-ephys.github.io/commutator-docs/ + true + snupkg + true + https://github.com/open-ephys/bonsai-commutator + git + README.md + LICENSE + true + icon.png + 0.1.0 + + 9.0 + strict + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91e5007 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..3a59663 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/OpenEphys.Commutator.sln b/OpenEphys.Commutator.sln new file mode 100644 index 0000000..243d912 --- /dev/null +++ b/OpenEphys.Commutator.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32825.248 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenEphys.Commutator", "OpenEphys.Commutator\OpenEphys.Commutator.csproj", "{353B1EBC-F8EB-4D99-8331-9FF15EC17F38}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F8644FAC-94E5-4E73-B809-925ABABE35B1}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {353B1EBC-F8EB-4D99-8331-9FF15EC17F38}.Debug|x64.ActiveCfg = Debug|x64 + {353B1EBC-F8EB-4D99-8331-9FF15EC17F38}.Debug|x64.Build.0 = Debug|x64 + {353B1EBC-F8EB-4D99-8331-9FF15EC17F38}.Release|x64.ActiveCfg = Release|x64 + {353B1EBC-F8EB-4D99-8331-9FF15EC17F38}.Release|x64.Build.0 = Release|x64 + {149E86EC-B865-463D-81A8-8290CA7F8871}.Debug|x64.ActiveCfg = Debug|x64 + {149E86EC-B865-463D-81A8-8290CA7F8871}.Debug|x64.Build.0 = Debug|x64 + {149E86EC-B865-463D-81A8-8290CA7F8871}.Release|x64.ActiveCfg = Release|x64 + {149E86EC-B865-463D-81A8-8290CA7F8871}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {86554706-612A-4283-B0DC-5477B01D58B6} + EndGlobalSection +EndGlobal diff --git a/OpenEphys.Commutator/Commutator.cs b/OpenEphys.Commutator/Commutator.cs new file mode 100644 index 0000000..084548d --- /dev/null +++ b/OpenEphys.Commutator/Commutator.cs @@ -0,0 +1,86 @@ +using System; +using System.ComponentModel; +using System.Numerics; +using System.Reactive.Linq; +using Bonsai; +using Bonsai.IO.Ports; +using Bonsai.Reactive; + +namespace OpenEphys.Commutator +{ + /// + /// Controls an Open Ephys commutator using orientation measurements. + /// + [Description("Turns an Open Ephys commutator by the amount specified.")] + public class Commutator : Combinator + { + /// + /// Gets or sets the name of the serial port that the commutator is plugged into. + /// + [TypeConverter("Bonsai.IO.Ports.PortNameConverter, Bonsai.System")] + [Description("The name of the serial port that the commutator is plugged into..")] + public string PortName { get; set; } + + /// + /// Gets or sets the direction vector specifying the axis around which to calculate rotations + /// + /// + /// This direction should be parallel to the major axis of the tether in order to compensate for twisting. + /// + [TypeConverter(typeof(NumericRecordConverter))] + [Description("The direction vector specifying the axis around which to calculate rotations.")] + public Vector3 RotationAxis { get; set; } = Vector3.UnitZ; + + /// + /// Sends commands to an Open Ephys commutator using orientation measurements information. + /// + /// Sequence of orientation measurements. + /// Sequence of commands sent to the commutator. + public override IObservable Process(IObservable source) + { + double? last = null; + var gate = new SampleInterval() { Interval = new(0, 0, 0, 0, 100) }; + var writer = new SerialWriteLine() { PortName = PortName }; + + var quaternionToCommand = gate.Process(source).Select(orientation => + { + var direction = RotationAxis; + var rotationAxis = new Vector3(orientation.X, orientation.Y, orientation.Z); + var dotProduct = Vector3.Dot(rotationAxis, direction); + var projection = dotProduct / Vector3.Dot(direction, direction) * direction; + var twist = new Quaternion(projection, orientation.W); + twist = Quaternion.Normalize(twist); + if (dotProduct < 0) // account for angle-axis flipping + { + twist = -twist; + } + + var angle = 2 * Math.Acos(twist.W); + + var a1 = angle + 2 * Math.PI; + var a2 = angle - 2 * Math.PI; + var pos = new double[] { angle - (last ?? angle), a1 - (last ?? angle), a2 - (last ?? angle) }; + last = angle; + var index = 0; + var currMax = Math.PI; + for (int count = 0; count < pos.Length; count++) + { + if (Math.Abs(pos[count]) < currMax) + { + currMax = pos[count]; + index = count; + } + + } + + var turns = pos[index] / (2 * Math.PI); + turns = double.IsNaN(turns) || double.IsInfinity(turns) ? 0 : turns; + return $"{{turn: {turns}}}"; + }); + + + return writer.Process(quaternionToCommand); + + } + } +} diff --git a/OpenEphys.Commutator/OpenEphys.Commutator.csproj b/OpenEphys.Commutator/OpenEphys.Commutator.csproj new file mode 100644 index 0000000..eefdd00 --- /dev/null +++ b/OpenEphys.Commutator/OpenEphys.Commutator.csproj @@ -0,0 +1,17 @@ + + + + OpenEphys.Onix1 + Bonsai library containing interfaces for data acquisition and control of ONIX devices. + Bonsai Rx Open Ephys Onix + net472 + true + x64 + + + + + + + + diff --git a/OpenEphys.Commutator/Properties/AssemblyInfo.cs b/OpenEphys.Commutator/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..935b5ec --- /dev/null +++ b/OpenEphys.Commutator/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using Bonsai; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: XmlNamespacePrefix("clr-namespace:OpenEphys.Commutator", "commutator")] +[assembly: WorkflowNamespaceIcon("")] diff --git a/OpenEphys.Commutator/Properties/launchSettings.json b/OpenEphys.Commutator/Properties/launchSettings.json new file mode 100644 index 0000000..e8640d6 --- /dev/null +++ b/OpenEphys.Commutator/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Bonsai": { + "commandName": "Executable", + "executablePath": "$(SolutionDir).bonsai/Bonsai.exe", + "commandLineArgs": "--lib:$(TargetDir).", + "nativeDebugging": true + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..061da48 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# Bonsai library for Open Ephys Commmuators +[Bonsai](https://bonsai-rx.org/) library for controlling +[Open Ephys commuators](https://open-ephys.org/commutator-info). + diff --git a/build/Version.props b/build/Version.props new file mode 100644 index 0000000..d7d1fa2 --- /dev/null +++ b/build/Version.props @@ -0,0 +1,20 @@ + + + + 0 + + dev$(DevVersion) + <_FileVersionRevision>$([MSBuild]::Add(60000, $(DevVersion))) + + + + $(CiBuildVersionSuffix) + <_FileVersionRevision>0 + <_FileVersionRevision Condition="'$(CiBuildVersionSuffix)' != '' and '$(CiRunNumber)' != ''">$(CiRunNumber) + + + + + $(WarningsAsErrors);CS7035 + + \ No newline at end of file diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5c02da4a0b9221f501c4795f6702a3593af391b5 GIT binary patch literal 12462 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 N6l7GT>m*G>{}(0G