diff --git a/engines/ARDIREC.lock.json b/engines/ARDIREC.lock.json index 07540730e..c6f05b430 100644 --- a/engines/ARDIREC.lock.json +++ b/engines/ARDIREC.lock.json @@ -2,7 +2,7 @@ "schema": 2, "repository": "masarray/ardirec", "ref": "main", - "commit": "cfcd65adf853b6707ee54a89dd30328db8827f0b", + "commit": "b611a6c6ab9262d0e1cc50fdf6fc76910d90d298", "bridge": { "abi": 1, "relativeLibrary": "Tools/ArdIrec/ardirec_bridge.dll" @@ -15,5 +15,5 @@ "relativeExecutable": "Tools/ArdIrec/ardirec.exe", "launchArgument": "--arsas-open" }, - "purpose": "Pinned ArdIrec P1C engine for ARSAS COMTRADE integration. The native bridge is the preferred in-process path and exposes waveform, digital, phasor and harmonic analysis; the Qt runtime remains a compatibility fallback while WPF analysis parity is completed. Change this commit only through a deliberate engine upgrade." + "purpose": "Temporary stacked P1D.2A integration pin. The ref field remains main to preserve the ARSAS immutable-lock policy, while the exact commit intentionally points to the unmerged ArdIrec P1D.2A head built on the latest open locus UX engine. It adds ABI-v1 capability probing, channel semantics, authoritative Primary/Secondary scaling, cursor instantaneous/true-RMS measurements, normal-state-aware digital state and native digital-edge snapping. Do not merge this temporary ARSAS pin; replace it with the merged ArdIrec main SHA before landing P1D.2B." } diff --git a/tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs b/tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs index 11ed892b2..f0b8d18eb 100644 --- a/tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs +++ b/tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs @@ -5,6 +5,12 @@ namespace ARSAS.Tests; public sealed class ArdIrecNativeBridgeIntegrationTests { + private const ulong CapCursorMeasurement = 1UL << 0; + private const ulong CapChannelSemantics = 1UL << 1; + private const ulong CapValueRepresentation = 1UL << 2; + private const ulong CapStatusState = 1UL << 3; + private const ulong CapDigitalEdgeSnap = 1UL << 4; + [Fact] public void OpensConfiguredFixtureThroughManagedBridge_WhenConfigured() { @@ -44,10 +50,10 @@ public void OpensConfiguredFixtureThroughManagedBridge_WhenConfigured() Assert.Equal(readCount, timestamps.Length); Assert.True(timestamps[^1] >= timestamps[0]); - // This small binary fixture may not contain a complete power-frequency cycle, but - // invoking both P1C exports here proves the managed delegate/struct boundary against - // the exact DLL that will be packaged. Numeric analysis accuracy is covered by the - // ArdIrec bridge smoke using its 1 kHz / 50 Hz distance_p1 fixture. + // These compact fixtures may not contain a complete power-frequency cycle, but + // invoking both P1C exports proves the managed delegate/struct boundary against the + // exact DLL that will be packaged. Numeric analysis accuracy is covered by ArdIrec's + // 1 kHz / 50 Hz bridge smoke fixture. var referenceFrame = record.Info.FrameCount - 1; var phasor = record.ReadPhasor(0, referenceFrame); Assert.True(phasor.WindowStartFrame <= phasor.WindowEndExclusive); @@ -65,6 +71,102 @@ public void OpensConfiguredFixtureThroughManagedBridge_WhenConfigured() } } + [Fact] + public void P1D2AInvestigationExports_MatchManagedAbi_WhenConfigured() + { + var bridgePath = Environment.GetEnvironmentVariable("ARSAS_ARDIREC_BRIDGE_PATH"); + var cfgPath = Environment.GetEnvironmentVariable("ARSAS_NATIVE_COMTRADE_TEST_CFG"); + if (string.IsNullOrWhiteSpace(bridgePath) || string.IsNullOrWhiteSpace(cfgPath)) + return; + + var library = NativeLibrary.Load(bridgePath); + try + { + var capabilities = Export(library, "ardirec_bridge_capabilities")(); + var required = CapCursorMeasurement | CapChannelSemantics | CapValueRepresentation | CapStatusState | CapDigitalEdgeSnap; + Assert.Equal(required, capabilities & required); + + var open = Export(library, "ardirec_record_open_utf8"); + var close = Export(library, "ardirec_record_close"); + var getSemantics = Export(library, "ardirec_record_get_analog_semantics"); + var getMeasurement = Export(library, "ardirec_record_get_cursor_measurement"); + var getStatusState = Export(library, "ardirec_record_get_status_state"); + var findEdge = Export(library, "ardirec_record_find_nearest_status_edge"); + + var path = Marshal.StringToCoTaskMemUTF8(Path.GetFullPath(cfgPath)); + var error = Marshal.AllocHGlobal(512); + try + { + for (var i = 0; i < 512; ++i) Marshal.WriteByte(error, i, 0); + var result = open(path, out var handle, error, 512); + Assert.True(result == 0 && handle != IntPtr.Zero, + $"P1D.2A bridge open failed ({result}): {Marshal.PtrToStringUTF8(error)}"); + + try + { + var semantics = new NativeAnalogSemanticsInfo(); + Assert.Equal(0, getSemantics(handle, 0, ref semantics)); + Assert.Equal(2, semantics.Role); // Current + Assert.Equal(1, semantics.PhaseRole); // L1 + Assert.Equal(2, semantics.RecordedRepresentation); // Primary + Assert.Equal(1, semantics.HasValidTransformerRatio); + Assert.Equal(1.0, semantics.ScaleToPrimary, 12); + Assert.Equal(0.0005, semantics.ScaleToSecondary, 12); + + var measurement = new NativeCursorMeasurementInfo(); + Assert.Equal(0, getMeasurement(handle, 0, 1, 1, ref measurement)); // Secondary + Assert.Equal(1, measurement.Valid); + Assert.Equal((ulong)1, measurement.ReferenceFrame); + Assert.True(double.IsFinite(measurement.Instantaneous)); + Assert.True(double.IsFinite(measurement.Rms)); + Assert.True(measurement.WindowEndExclusive > measurement.WindowStartFrame); + Assert.True(measurement.WindowSampleCount > 0); + + // The installer and workflow lanes deliberately use different compact + // COMTRADE fixtures. Assert the engine contract rather than assuming a + // particular transition is located at frame 1. + var state = new NativeStatusStateInfo(); + Assert.Equal(0, getStatusState(handle, 0, 1, ref state)); + Assert.Contains(state.RawState, new[] { 0, 1 }); + Assert.Contains(state.NormalState, new[] { 0, 1 }); + Assert.Equal(state.RawState != state.NormalState ? 1 : 0, state.IsActive); + + var edge = new NativeStatusEdgeInfo(); + Assert.Equal(0, findEdge(handle, 0, 0.010, ref edge)); + Assert.Equal(1, edge.Valid); + Assert.NotEqual(edge.BeforeState, edge.AfterState); + Assert.Contains(edge.BeforeState, new[] { 0, 1 }); + Assert.Contains(edge.AfterState, new[] { 0, 1 }); + Assert.Contains(edge.NormalState, new[] { 0, 1 }); + Assert.Equal(edge.AfterState != edge.NormalState ? 1 : 0, edge.BecameActive); + Assert.True(edge.DistanceSeconds >= 0.0 && double.IsFinite(edge.DistanceSeconds)); + } + finally + { + close(handle); + } + } + finally + { + Marshal.FreeCoTaskMem(path); + Marshal.FreeHGlobal(error); + } + } + finally + { + NativeLibrary.Free(library); + } + } + + [Fact] + public void P1D2ANativeInvestigationStructLayout_MatchesCAbiOnWindowsX64() + { + Assert.Equal(32, Marshal.SizeOf()); + Assert.Equal(72, Marshal.SizeOf()); + Assert.Equal(12, Marshal.SizeOf()); + Assert.Equal(48, Marshal.SizeOf()); + } + [Fact] public void P1CNativeAnalysisStructLayout_MatchesCAbiOnWindowsX64() { @@ -79,4 +181,75 @@ public void DecodesUtf8FixedBuffersWithoutUsingWindowsAnsiCodePage() var bytes = System.Text.Encoding.UTF8.GetBytes("Gardu 日本 üñîçødé\0unused"); Assert.Equal("Gardu 日本 üñîçødé", ArdIrecNativeBridge.DecodeUtf8(bytes)); } + + private static T Export(IntPtr library, string name) where T : Delegate + => Marshal.GetDelegateForFunctionPointer(NativeLibrary.GetExport(library, name)); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate ulong BridgeCapabilitiesDelegate(); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int RecordOpenDelegate(IntPtr cfgPath, out IntPtr handle, IntPtr error, nuint errorCapacity); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void RecordCloseDelegate(IntPtr handle); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int RecordGetAnalogSemanticsDelegate(IntPtr handle, uint channel, ref NativeAnalogSemanticsInfo info); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int RecordGetCursorMeasurementDelegate(IntPtr handle, uint channel, ulong referenceFrame, int representation, ref NativeCursorMeasurementInfo info); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int RecordGetStatusStateDelegate(IntPtr handle, uint channel, ulong referenceFrame, ref NativeStatusStateInfo info); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int RecordFindNearestStatusEdgeDelegate(IntPtr handle, ulong referenceFrame, double maxDistanceSeconds, ref NativeStatusEdgeInfo info); + + [StructLayout(LayoutKind.Sequential)] + private struct NativeAnalogSemanticsInfo + { + internal int Role; + internal int PhaseRole; + internal int RecordedRepresentation; + internal int HasValidTransformerRatio; + internal double ScaleToSecondary; + internal double ScaleToPrimary; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeCursorMeasurementInfo + { + internal int Valid; + internal ulong ReferenceFrame; + internal uint RawTimestamp; + internal double TimeSeconds; + internal double Instantaneous; + internal double Rms; + internal ulong WindowStartFrame; + internal ulong WindowEndExclusive; + internal uint WindowSampleCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeStatusStateInfo + { + internal int RawState; + internal int NormalState; + internal int IsActive; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeStatusEdgeInfo + { + internal int Valid; + internal uint ChannelIndex; + internal ulong FrameIndex; + internal uint RawTimestamp; + internal int BeforeState; + internal int AfterState; + internal int NormalState; + internal int BecameActive; + internal double DistanceSeconds; + } }