Skip to content
Draft
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
4 changes: 2 additions & 2 deletions engines/ARDIREC.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schema": 2,
"repository": "masarray/ardirec",
"ref": "main",
"commit": "cfcd65adf853b6707ee54a89dd30328db8827f0b",
"commit": "b611a6c6ab9262d0e1cc50fdf6fc76910d90d298",
"bridge": {
"abi": 1,
"relativeLibrary": "Tools/ArdIrec/ardirec_bridge.dll"
Expand All @@ -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."
}
181 changes: 177 additions & 4 deletions tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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);
Expand All @@ -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<BridgeCapabilitiesDelegate>(library, "ardirec_bridge_capabilities")();
var required = CapCursorMeasurement | CapChannelSemantics | CapValueRepresentation | CapStatusState | CapDigitalEdgeSnap;
Assert.Equal(required, capabilities & required);

var open = Export<RecordOpenDelegate>(library, "ardirec_record_open_utf8");
var close = Export<RecordCloseDelegate>(library, "ardirec_record_close");
var getSemantics = Export<RecordGetAnalogSemanticsDelegate>(library, "ardirec_record_get_analog_semantics");
var getMeasurement = Export<RecordGetCursorMeasurementDelegate>(library, "ardirec_record_get_cursor_measurement");
var getStatusState = Export<RecordGetStatusStateDelegate>(library, "ardirec_record_get_status_state");
var findEdge = Export<RecordFindNearestStatusEdgeDelegate>(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<NativeAnalogSemanticsInfo>());
Assert.Equal(72, Marshal.SizeOf<NativeCursorMeasurementInfo>());
Assert.Equal(12, Marshal.SizeOf<NativeStatusStateInfo>());
Assert.Equal(48, Marshal.SizeOf<NativeStatusEdgeInfo>());
}

[Fact]
public void P1CNativeAnalysisStructLayout_MatchesCAbiOnWindowsX64()
{
Expand All @@ -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<T>(IntPtr library, string name) where T : Delegate
=> Marshal.GetDelegateForFunctionPointer<T>(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;
}
}
Loading