Merge #613 slice: MTConnect authoring-time DataType advisory + ignored-DeviceName warning
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -397,8 +397,21 @@ for anything beyond values-and-conditions.
|
||||
`DeviceName` (see [Write-back](#write-back--observation-injection-off-by-default)).
|
||||
So `MTConnectDriverOptions.DeviceName` does double duty — request scope for
|
||||
reads/browse, fallback write target for writes.
|
||||
3. **Nothing validates a raw tag's declared OPC UA `DataType` against the
|
||||
blob's coercion type.** The `/probe`-sourced inference
|
||||
|
||||
**It is no longer silent (#613).** When the instance's raw tags carry two or
|
||||
more distinct non-blank `RawTagEntry.DeviceName` values, driver init logs one
|
||||
Warning naming them and the driver-level scope they collapse into. A single
|
||||
value is not reported (it is just the `/raw` Device's own name). Real
|
||||
per-device routing remains deferred.
|
||||
3. **Authoring only *warns* when a declared coercion type cannot hold the
|
||||
inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`)
|
||||
compares the declared type with `MTConnectDataTypeInference` over the
|
||||
`/probe` metadata a browse-commit stamped (`mtCategory`/`mtType`), and shows an
|
||||
advisory line — it never blocks a save (the deploy gate does not refuse it) and
|
||||
is a no-op for hand-authored blobs with no stamped metadata. `representation`
|
||||
is not on the model, so TIME_SERIES arrays are not covered. Beyond that,
|
||||
nothing validates a raw tag's declared OPC UA `DataType` against the
|
||||
blob's coercion type. The `/probe`-sourced inference
|
||||
(`MTConnectDataTypeInference`) narrows how often an author picks a wrong
|
||||
type, but it doesn't close the gap — an authored `DataType` mismatched
|
||||
against the actual Agent observation still surfaces as `BadTypeMismatch`
|
||||
|
||||
@@ -3232,6 +3232,29 @@ public sealed class MTConnectDriver
|
||||
|
||||
// ---- v3 tag binding: RawPath ↔ dataItemId, and the definitions the index coerces against ----
|
||||
|
||||
/// <summary>
|
||||
/// Names the distinct, non-blank <see cref="RawTagEntry.DeviceName"/> values when there are two or
|
||||
/// more, else <c>null</c> (#613). The driver never routes by that field — one instance owns one
|
||||
/// Agent client — so differing values mean the author expects a split the driver silently does
|
||||
/// not perform. A single value is NOT reported: it is the <c>/raw</c> Device's own name, which
|
||||
/// routinely differs from the Agent's device name and would make the warning noise.
|
||||
/// </summary>
|
||||
/// <param name="rawTags">The authored raw tags.</param>
|
||||
/// <returns>A comma-joined, sorted list of the distinct names, or <c>null</c> when fewer than two.</returns>
|
||||
internal static string? DescribeIgnoredDeviceNames(IReadOnlyList<RawTagEntry> rawTags)
|
||||
{
|
||||
var names = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in rawTags)
|
||||
{
|
||||
if (entry is not null && !string.IsNullOrWhiteSpace(entry.DeviceName))
|
||||
{
|
||||
names.Add(entry.DeviceName.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return names.Count >= 2 ? string.Join(", ", names) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the driver's tag tables from an options document — the <c>RawPath → dataItemId</c>
|
||||
/// translation the data plane runs through, its reverse, and the definition set the
|
||||
@@ -3295,6 +3318,13 @@ public sealed class MTConnectDriver
|
||||
BuildWriteTargets(only, probeModel, options.DeviceName));
|
||||
}
|
||||
|
||||
if (DescribeIgnoredDeviceNames(options.RawTags) is { } ignoredDevices)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId}: raw tags carry differing DeviceName values ({DeviceNames}), but RawTagEntry.DeviceName is not used for routing — every tag is served from the one Agent connection scoped by the driver-level DeviceName ({ConfiguredDevice}). Author one MTConnect driver instance per Agent device if they must be kept apart.",
|
||||
_driverInstanceId, ignoredDevices, options.DeviceName ?? "<whole Agent>");
|
||||
}
|
||||
|
||||
var declared = IndexProbeDataItems(probeModel);
|
||||
var idByRawPath = new Dictionary<string, string>(options.RawTags.Count, StringComparer.Ordinal);
|
||||
var rawPathsById = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
|
||||
+4
@@ -80,6 +80,10 @@
|
||||
{
|
||||
<div class="text-danger small mt-2">@error</div>
|
||||
}
|
||||
@foreach (var warning in _m.Warnings())
|
||||
{
|
||||
<div class="text-warning small mt-2">@warning</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
|
||||
|
||||
@@ -216,6 +216,55 @@ public sealed class MTConnectTagConfigModel
|
||||
"MTConnect");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory warnings (#613) — NEVER an error and never routed through <see cref="Validate"/>: the
|
||||
/// deploy gate does not refuse these, so authoring must not either ("never stricter than the gate").
|
||||
/// One rule: the declared <see cref="DataType"/> cannot hold the type
|
||||
/// <see cref="MTConnectDataTypeInference.Infer"/> derives from the stamped
|
||||
/// <see cref="MtCategory"/>/<see cref="MtType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A no-op unless the probe metadata was stamped (a browse-commit does; hand-authored blobs do not),
|
||||
/// since there is nothing to infer from otherwise. <c>representation</c> is not on the model so it is
|
||||
/// passed as null; it can only DEMOTE an item to String (DATA_SET/TABLE), which cannot create a false
|
||||
/// warning because a numeric declaration on a String-inferred item is warned either way, and a SAMPLE
|
||||
/// inferred Float64 is only warned about for a non-numeric, non-String declaration or an integer one.
|
||||
/// TIME_SERIES arrays are not detectable without it and are not covered.
|
||||
/// </remarks>
|
||||
/// <returns>Zero or more human-readable warnings.</returns>
|
||||
public IReadOnlyList<string> Warnings()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(MtCategory)) { return []; }
|
||||
|
||||
var inferred = MTConnectDataTypeInference.Infer(MtCategory, MtType, Units).DataType;
|
||||
var declared = DataType;
|
||||
|
||||
// String can hold any observation's text, so declaring it is never lossy.
|
||||
if (declared == inferred || declared == DriverDataType.String) { return []; }
|
||||
|
||||
if (IsNumeric(inferred) && IsNumeric(declared))
|
||||
{
|
||||
// Float64 inferred (a SAMPLE): an integer type cannot hold a fractional reading. A wider-or-equal
|
||||
// numeric holds an Int64-inferred item (PartCount, Line).
|
||||
return IsIntegral(declared) && !IsIntegral(inferred)
|
||||
? [$"Data type {declared} cannot hold fractional values; the Agent's /probe metadata "
|
||||
+ $"({MtCategory}/{MtType}) infers {inferred}. Fractional readings will surface as a bad status."]
|
||||
: [];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
$"Data type {declared} does not match the type inferred from the Agent's /probe metadata "
|
||||
+ $"({MtCategory}/{MtType} -> {inferred}). Observations that do not parse as {declared} will surface "
|
||||
+ "as BadTypeMismatch/BadOutOfRange at runtime. Saving is still allowed."
|
||||
];
|
||||
}
|
||||
|
||||
private static bool IsNumeric(DriverDataType t) => t is not (DriverDataType.Boolean or DriverDataType.String
|
||||
or DriverDataType.DateTime or DriverDataType.Reference);
|
||||
|
||||
private static bool IsIntegral(DriverDataType t) => IsNumeric(t) && t is not (DriverDataType.Float32 or DriverDataType.Float64);
|
||||
|
||||
// The as-loaded coercion-type TOKEN as text (see the _rawDataType and DataTypeKeys remarks), read in
|
||||
// the RUNTIME's precedence so this model judges — and displays — the token the driver would actually
|
||||
// read. Per key, mirroring FirstNonBlank(blob.DriverDataType, blob.DataType) over a DTO whose two
|
||||
|
||||
@@ -117,6 +117,37 @@ public sealed class MTConnectRawTagBindingTests
|
||||
return (driver, agent);
|
||||
}
|
||||
|
||||
private static RawTagEntry OnDevice(string rawPath, string dataItemId, string device) =>
|
||||
Entry(rawPath, dataItemId) with { DeviceName = device };
|
||||
|
||||
private static async Task<List<string>> InitWarningsAsync(params RawTagEntry[] rawTags)
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var driver = new MTConnectDriver(Bare(), "mt1", _ => CannedAgentClient.FromFixtures(), logger);
|
||||
await driver.InitializeAsync(MergedConfig(rawTags), Ct);
|
||||
await driver.ShutdownAsync(Ct);
|
||||
|
||||
return [.. logger.Warnings];
|
||||
}
|
||||
|
||||
/// <summary>#613: differing RawTagEntry.DeviceName values are ignored for routing, so init says so.</summary>
|
||||
[Fact]
|
||||
public async Task Differing_raw_device_names_log_an_ignored_routing_warning()
|
||||
{
|
||||
// Positive control: a single /raw Device is the ordinary case and must stay silent.
|
||||
(await InitWarningsAsync(
|
||||
OnDevice(PositionRawPath, PositionDataItemId, "cncA"),
|
||||
OnDevice(ExecutionRawPath, ExecutionDataItemId, "cncA")))
|
||||
.ShouldNotContain(w => w.Contains("RawTagEntry.DeviceName"));
|
||||
|
||||
var warnings = await InitWarningsAsync(
|
||||
OnDevice(PositionRawPath, PositionDataItemId, "cncA"),
|
||||
OnDevice(ExecutionRawPath, ExecutionDataItemId, "cncB"));
|
||||
|
||||
var w = warnings.Where(x => x.Contains("RawTagEntry.DeviceName")).ShouldHaveSingleItem();
|
||||
w.ShouldContain("cncA, cncB");
|
||||
}
|
||||
|
||||
// ---- the production data plane: subscribe by RawPath, publish by RawPath ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -295,4 +295,37 @@ public sealed class MTConnectTagConfigModelTests
|
||||
[InlineData("""{"fullName":"x","driverDataType":8,"dataType":"Int32"}""")]
|
||||
public void Validate_rejects_a_driverDataType_written_as_an_unquoted_number(string json)
|
||||
=> MTConnectTagConfigModel.FromJson(json).Validate().ShouldNotBeNull().ShouldContain("8");
|
||||
|
||||
private static MTConnectTagConfigModel Stamped(string category, string type, string dataType)
|
||||
=> MTConnectTagConfigModel.FromJson(
|
||||
$"{{\"fullName\":\"x\",\"dataType\":\"{dataType}\",\"mtCategory\":\"{category}\",\"mtType\":\"{type}\"}}");
|
||||
|
||||
[Theory]
|
||||
[InlineData("SAMPLE", "POSITION", "Boolean")] // numeric inferred, non-numeric declared
|
||||
[InlineData("SAMPLE", "POSITION", "Int32")] // fractional readings cannot fit
|
||||
[InlineData("EVENT", "EXECUTION", "Float64")] // text inferred, numeric declared
|
||||
[InlineData("CONDITION", "TEMPERATURE", "Int32")]
|
||||
public void Warnings_flags_a_declared_type_that_cannot_hold_the_inferred_one_without_blocking(
|
||||
string category, string type, string dataType)
|
||||
{
|
||||
var m = Stamped(category, type, dataType);
|
||||
|
||||
m.Warnings().ShouldHaveSingleItem().ShouldContain(dataType);
|
||||
m.Validate().ShouldBeNull(); // advisory: never blocks
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SAMPLE", "POSITION", "Float64")]
|
||||
[InlineData("SAMPLE", "POSITION", "String")] // text holds anything
|
||||
[InlineData("EVENT", "EXECUTION", "String")]
|
||||
[InlineData("EVENT", "PART_COUNT", "Int64")]
|
||||
[InlineData("EVENT", "PART_COUNT", "Float64")]
|
||||
public void Warnings_is_quiet_when_the_declared_type_holds_the_inferred_one(
|
||||
string category, string type, string dataType)
|
||||
=> Stamped(category, type, dataType).Warnings().ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Warnings_is_a_noop_without_probe_metadata()
|
||||
=> MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Boolean\"}")
|
||||
.Warnings().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user