Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using AasCore.Aas3_0;
using FluentAAS.Builder;
using FluentAAS.Templates.TechnicalData;
using Shouldly;

namespace FluentAAS.Templates.Tests.TechnicalData;

public class TechnicalDataBuilderTests
{
[Fact]
public void BuildTechnicalData_ShouldFail_WhenMandatoryParametersAreMissing()
{
var envBuilder = AasBuilder.Create();
var shellBuilder = envBuilder.AddShell("urn:aas:example:shell:technical-data", "DriveShell");

var sut = shellBuilder.AddTechnicalData("urn:aas:example:submodel:technical-data")
.WithMotorPerformance(motor => motor
.WithRatedVoltage(400m)
.WithRatedCurrent(82.5m));

var ex = Record.Exception(() => sut.BuildTechnicalData());

ex.ShouldBeOfType<InvalidOperationException>();
ex.Message.ShouldContain("MotorPerformance.RatedPower");
ex.Message.ShouldContain("MotorPerformance.RatedSpeed");
ex.Message.ShouldContain("BearingCharacteristics.InnerDiameter");
}

[Fact]
public void BuildTechnicalData_ShouldFail_WhenUnitIsInvalid()
{
var envBuilder = AasBuilder.Create();
var shellBuilder = envBuilder.AddShell("urn:aas:example:shell:technical-data", "DriveShell");

var ex = Record.Exception(() => shellBuilder.AddTechnicalData("urn:aas:example:submodel:technical-data")
.WithMotorPerformance(motor => motor.WithRatedVoltage(400m, "mm")));

ex.ShouldBeOfType<InvalidOperationException>();
ex.Message.ShouldContain("Unit 'mm' is invalid for 'RatedVoltage'");
}

[Fact]
public void BuildTechnicalData_ShouldFail_WhenSemanticIdDoesNotMatchEclassCatalogEntry()
{
var envBuilder = AasBuilder.Create();
var shellBuilder = envBuilder.AddShell("urn:aas:example:shell:technical-data", "DriveShell");

var ex = Record.Exception(() => shellBuilder.AddTechnicalData("urn:aas:example:submodel:technical-data")
.WithMotorPerformance(motor => motor.WithRatedVoltage(400m, "V", TechnicalDataSemantics.RatedCurrent)));

ex.ShouldBeOfType<InvalidOperationException>();
ex.Message.ShouldContain("Semantic ID mismatch for 'RatedVoltage'");
}

[Fact]
public void BuildTechnicalData_ShouldCreateStructuredGroups_WithEclassSemanticIds()
{
var envBuilder = AasBuilder.Create();
var shellBuilder = envBuilder.AddShell("urn:aas:example:shell:technical-data", "DriveShell");

shellBuilder.AddTechnicalData("urn:aas:example:submodel:technical-data")
.WithMotorPerformance(motor => motor
.WithRatedVoltage(400m, "V", TechnicalDataSemantics.RatedVoltage)
.WithRatedCurrent(82.5m, "A", TechnicalDataSemantics.RatedCurrent)
.WithRatedPower(45m, "kW", TechnicalDataSemantics.RatedPower)
.WithRatedSpeed(1470m, "1/min", TechnicalDataSemantics.RatedSpeed))
.WithBearingCharacteristics(bearing => bearing
.WithInnerDiameter(50m, "mm", TechnicalDataSemantics.InnerDiameter)
.WithOuterDiameter(90m, "mm", TechnicalDataSemantics.OuterDiameter)
.WithWidth(20m, "mm", TechnicalDataSemantics.Width)
.WithLimitingSpeed(6500m, "1/min", TechnicalDataSemantics.LimitingSpeed))
.BuildTechnicalData();

var env = envBuilder.Build();

var submodel = env.Submodels!.Single(sm => sm.Id == "urn:aas:example:submodel:technical-data");
submodel.SemanticId!.Keys.Single().Value.ShouldBe(TechnicalDataSemantics.SubmodelTechnicalData);

var motorGroup = submodel.SubmodelElements!
.OfType<SubmodelElementCollection>()
.Single(x => x.IdShort == TechnicalDataIdentifiers.MotorPerformance);

var ratedVoltage = motorGroup.Value!
.OfType<Property>()
.Single(x => x.IdShort == TechnicalDataIdentifiers.RatedVoltage);

ratedVoltage.Value.ShouldBe("400");
ratedVoltage.Category.ShouldBe("V");
ratedVoltage.SemanticId!.Keys.Single().Value.ShouldBe(TechnicalDataSemantics.RatedVoltage);
Comment on lines +83 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how Category is typically used in the AAS 3.0 library and existing codebase
rg -n -C3 'Category\s*=' --type=cs

Repository: Freezor/FluentAAS

Length of output: 3003


🌐 Web query:

AAS 3.0 Property Category field usage and IEC 61360 unit storage

💡 Result:

In AAS 3.0, the 'category' field is defined in the Referable abstract class, inherited by elements like Property. It provides meta-information about the element's class, affecting expected attributes and constraints (e.g., distinguishing measurement values). It is optional (string type, default null) and deprecated since V3.0, though still present for backward compatibility. Legacy values include PARAMETER, CONSTANT, VALUE for data elements like Properties, influencing validation (e.g., in ConceptDescription constraints referencing category, marked deprecated). IEC 61360 units for quantitative properties (e.g., dataType INTEGER_MEASURE, REAL_MEASURE) are stored in ConceptDescriptions via the DataSpecificationIec61360 template, using 'unit' (string, primary unit text) or 'unitId' (Reference to global unit identifier, e.g., IRDI). These are embedded in the semanticId-referenced ConceptDescription of the Property, not directly in the Property.value. Constraint AASc-3a-009 requires unit/unitId for measure types. The Property.valueType maps IEC data types to XSD (e.g., xs:integer for INTEGER_MEASURE).

Citations:


Remove Category field usage for unit storage; use ConceptDescription's DataSpecificationIec61360 instead.

The test asserts ratedVoltage.Category.ShouldBe("V"), but in AAS 3.0, the Category field is deprecated and meant for element classification (e.g., PARAMETER, CONSTANT, VALUE). Units should be stored in the ConceptDescription's DataSpecificationIec61360 template via the unit field, not in the Property's Category field. This applies to the builder implementation as well (TechnicalDataBuilder.cs:95).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@FluentAAS/FluentAAS.Templates.Tests/TechnicalData/TechnicalDataBuilderTests.cs`
around lines 83 - 89, The test currently asserts the deprecated
Property.Category ("ratedVoltage.Category.ShouldBe(\"V\")"); update the test and
builder so the unit is stored on the ConceptDescription's
DataSpecificationIec61360 instead: remove the Category assertion in
FluentAAS.Templates.Tests.TechnicalData.TechnicalDataBuilderTests (the
ratedVoltage variable usage) and add an assertion that the ConceptDescription
created by TechnicalDataBuilder contains a DataSpecificationIec61360 with unit
== "V"; update TechnicalDataBuilder (the code that currently sets
Property.Category around the TechnicalDataBuilder.Create... logic) to stop
populating Property.Category and instead populate the
ConceptDescription.DataSpecificationIec61360.unit field with the unit string.

}

[Fact]
public void CompositionExample_ShouldBuildEnvironment()
{
var env = TechnicalDataCompositionExample.BuildMotorAndBearingExampleEnvironment();

env.AssetAdministrationShells.ShouldNotBeNull();
env.AssetAdministrationShells!.Count.ShouldBe(1);
env.Submodels.ShouldNotBeNull();
env.Submodels!.Count.ShouldBe(1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
249 changes: 249 additions & 0 deletions FluentAAS/FluentAAS.Templates/TechnicalData/TechnicalDataBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
using FluentAAS.Builder;
using FluentAAS.Builder.SubModel;

namespace FluentAAS.Templates.TechnicalData;

public sealed class TechnicalDataBuilder
{
private readonly IShellBuilder _shellBuilder;
private string _id;
private string _idShort;

private readonly Dictionary<string, GroupAssignment> _groups = new(StringComparer.Ordinal);

internal TechnicalDataBuilder(IShellBuilder shellBuilder, string id, string idShort)
{
_shellBuilder = shellBuilder ?? throw new ArgumentNullException(nameof(shellBuilder));

if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Submodel id must not be empty.", nameof(id));
}

if (string.IsNullOrWhiteSpace(idShort))
{
throw new ArgumentException("Submodel idShort must not be empty.", nameof(idShort));
}

_id = id;
_idShort = idShort;
}

public TechnicalDataBuilder WithIds(string id, string idShort)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Submodel id must not be empty.", nameof(id));
}

if (string.IsNullOrWhiteSpace(idShort))
{
throw new ArgumentException("Submodel idShort must not be empty.", nameof(idShort));
}

_id = id;
_idShort = idShort;
return this;
}

public TechnicalDataBuilder WithMotorPerformance(Action<MotorPerformanceGroupBuilder> configure)
{
ArgumentNullException.ThrowIfNull(configure);

var groupBuilder = new MotorPerformanceGroupBuilder(this);
configure(groupBuilder);

return this;
}

public TechnicalDataBuilder WithBearingCharacteristics(Action<BearingCharacteristicsGroupBuilder> configure)
{
ArgumentNullException.ThrowIfNull(configure);

var groupBuilder = new BearingCharacteristicsGroupBuilder(this);
configure(groupBuilder);

return this;
}

public IShellBuilder BuildTechnicalData()
{
ValidateRequiredParameters();

var submodelBuilder = new SubmodelBuilder(_shellBuilder, _id, _idShort)
.WithSemanticId(ReferenceFactory.GlobalConceptDescription(TechnicalDataSemantics.SubmodelTechnicalData));

foreach (var group in _groups.Values.OrderBy(x => x.IdShort, StringComparer.Ordinal))
{
var collection = new SubmodelElementCollection
{
IdShort = group.IdShort,
Value = [],
SemanticId = ReferenceFactory.GlobalSemanticReference($"{TechnicalDataSemantics.SubmodelTechnicalData}/{group.IdShort}")
};

foreach (var assignment in group.Assignments.Values.OrderBy(x => x.Definition.IdShort, StringComparer.Ordinal))
{
ValidateEclassCatalogMapping(assignment.Definition.EclassIrdi);

var property = new Property(assignment.Definition.ValueType)
{
IdShort = assignment.Definition.IdShort,
Value = assignment.Value,
ValueId = ReferenceFactory.GlobalSemanticReference(assignment.Definition.EclassIrdi),
SemanticId = ReferenceFactory.GlobalConceptDescription(assignment.Definition.EclassIrdi),
Category = assignment.Unit
};
Comment on lines +121 to +128

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

IDTA Technical Data submodel template property unit storage AAS 3.0

💡 Result:

The IDTA Technical Data submodel template is the "Generic Frame for Technical Data for Industrial Equipment in Manufacturing" (IDTA number 02003, version 2.0), published by the Industrial Digital Twin Association (IDTA) for use with Asset Administration Shell (AAS) 3.0. It provides an interoperable structure for technical properties of assets, using semantic dictionaries like ECLASS and IEC CDD. Properties in this submodel are defined as AAS Property elements with semanticIds referencing external concept descriptions (e.g., IRDIs from ECLASS). For quantitative properties (e.g., dataType INTEGER_MEASURE, REAL_MEASURE), the unit is specified via the IEC 61360 data specification template (IDTA-01003-a), which includes attributes: - unit: string for unit in text (e.g., "m³" for storage volume). - unitId: Reference to a unit concept (e.g., IRDI for cubic meter). "Storage" likely refers to a property like storage capacity (e.g., ECLASS property for volume), where the value has a unit such as cubic meters (m³). Example structure: - Property idShort: "storageCapacity" - semanticId: IRDI (e.g., 0173-1#02-BAF188#007 for gross volume) - value: e.g., "10" - hasDataSpecification: DataSpecificationIec61360 with unit="m³", unitId=reference to unit concept. This follows AAS 3.0 metamodel (IDTA-01001), where units are part of embedded data specs for interoperability. Official spec downloadable from IDTA site; GitHub repo at admin-shell-io/submodel-templates for JSON/UML/AASX files.

Citations:


🏁 Script executed:

# First, find the Property class definition
find . -type f -name "*.cs" | head -20

Repository: Freezor/FluentAAS

Length of output: 1180


🏁 Script executed:

# Search for Property class definition
rg "class Property" -A 10 --max-count 5

Repository: Freezor/FluentAAS

Length of output: 43


🏁 Script executed:

# Look for IEC 61360 or DataSpecification references related to units
rg -i "iec.?61360|dataspecification|unit" --max-count 20

Repository: Freezor/FluentAAS

Length of output: 10802


🏁 Script executed:

# Check if there are comments explaining Category usage in this file
cat -n FluentAAS/FluentAAS.Templates/TechnicalData/TechnicalDataBuilder.cs | head -100

Repository: Freezor/FluentAAS

Length of output: 4192


🏁 Script executed:

# Search for other Category assignments to see if this is a pattern
rg "Category\s*=" -B 2 -A 2 --max-count 10

Repository: Freezor/FluentAAS

Length of output: 2220


Store unit information using IEC 61360 data specification instead of the Category field.

The Category field per AAS 3.0 should contain classification values (e.g., PARAMETER, CONSTANT, VARIABLE), not measurement units. IDTA Technical Data (IDTA-02003 v2.0) specifies units must be stored via DataSpecificationIec61360 with unit (string) and unitId (Reference) attributes. The current implementation on lines 89–96 violates this requirement by assigning the unit string directly to Category. This breaks IDTA compliance and causes misinterpretation of property semantics by AAS consumers.

Consider:

  • Adding DataSpecificationIec61360 (or embedded data specification) to the Property with proper unit and unitId fields
  • Removing Category = assignment.Unit or setting it to an appropriate classification value if needed
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@FluentAAS/FluentAAS.Templates/TechnicalData/TechnicalDataBuilder.cs` around
lines 89 - 96, The Property construction incorrectly stores measurement units in
Category; update TechnicalDataBuilder to remove Category = assignment.Unit and
instead attach a DataSpecificationIec61360 to the created Property (use the
Property instance created in TechnicalDataBuilder.cs) populating the unit
(assignment.Unit) and unitId (use your ReferenceFactory to create a global unit
Reference) and keep SemanticId/ValueId as-is; if a classification is required
set Category to an appropriate AAS classification token (e.g., "PARAMETER")
rather than the unit.


collection.Value!.Add(property);
}

submodelBuilder.AddElement(collection);
}

var submodel = submodelBuilder.BuildSubmodel();
_shellBuilder.AddSubmodelReference(submodel);

return _shellBuilder;
}

private void ValidateRequiredParameters()
{
var missing = new List<string>();

foreach (var groupIdShort in new[] { TechnicalDataIdentifiers.MotorPerformance, TechnicalDataIdentifiers.BearingCharacteristics })
{
if (!_groups.TryGetValue(groupIdShort, out var group))
{
missing.AddRange(TechnicalDataEclassCatalog.RequiredInGroup(groupIdShort).Select(x => $"{groupIdShort}.{x.IdShort}"));
continue;
}

var required = TechnicalDataEclassCatalog.RequiredInGroup(groupIdShort);
missing.AddRange(required.Where(x => !group.Assignments.ContainsKey(x.IdShort)).Select(x => $"{groupIdShort}.{x.IdShort}"));
}

if (missing.Count == 0)
{
return;
}

throw new InvalidOperationException("Technical data is incomplete. Missing mandatory parameters: " + string.Join(", ", missing));
}

private static void ValidateEclassCatalogMapping(string eclassIrdi)
{
if (!TechnicalDataEclassCatalog.IsKnownEclassReference(eclassIrdi))
{
throw new InvalidOperationException($"The ECLASS semantic ID '{eclassIrdi}' is not available in the integrated ECLASS catalog map.");
}
}

internal void UpsertDecimalProperty(string groupIdShort, string idShort, decimal value, string unit, string? semanticIdOverride)
{
var definition = TechnicalDataEclassCatalog.ForIdShort(idShort);

if (!string.Equals(definition.GroupIdShort, groupIdShort, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Property '{idShort}' is not allowed in group '{groupIdShort}'.");
}

if (definition.ValueType != DataTypeDefXsd.Decimal)
{
throw new InvalidOperationException($"Property '{idShort}' does not accept decimal values according to IEC 61360 type constraints.");
}

if (!definition.AllowedUnits.Contains(unit, StringComparer.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Unit '{unit}' is invalid for '{idShort}'. Allowed IEC 61360 units: {string.Join(", ", definition.AllowedUnits)}.");
}

if (!string.IsNullOrWhiteSpace(semanticIdOverride) && !string.Equals(semanticIdOverride, definition.EclassIrdi, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Semantic ID mismatch for '{idShort}'. Expected '{definition.EclassIrdi}' but got '{semanticIdOverride}'.");
}

var group = GetOrCreateGroup(groupIdShort);
group.Assignments[idShort] = new PropertyAssignment(definition, value.ToString(System.Globalization.CultureInfo.InvariantCulture), unit);
}

private GroupAssignment GetOrCreateGroup(string groupIdShort)
{
if (_groups.TryGetValue(groupIdShort, out var existing))
{
return existing;
}

var created = new GroupAssignment(groupIdShort);
_groups[groupIdShort] = created;
return created;
}

private sealed class GroupAssignment(string idShort)
{
public string IdShort { get; } = idShort;

public Dictionary<string, PropertyAssignment> Assignments { get; } = new(StringComparer.Ordinal);
}

private sealed record PropertyAssignment(
TechnicalDataEclassCatalog.PropertyDefinition Definition,
string Value,
string Unit);

public sealed class MotorPerformanceGroupBuilder(TechnicalDataBuilder parent)
{
public MotorPerformanceGroupBuilder WithRatedVoltage(decimal value, string unit = "V", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.MotorPerformance, TechnicalDataIdentifiers.RatedVoltage, value, unit, semanticId);
return this;
}

public MotorPerformanceGroupBuilder WithRatedCurrent(decimal value, string unit = "A", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.MotorPerformance, TechnicalDataIdentifiers.RatedCurrent, value, unit, semanticId);
return this;
}

public MotorPerformanceGroupBuilder WithRatedPower(decimal value, string unit = "kW", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.MotorPerformance, TechnicalDataIdentifiers.RatedPower, value, unit, semanticId);
return this;
}

public MotorPerformanceGroupBuilder WithRatedSpeed(decimal value, string unit = "1/min", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.MotorPerformance, TechnicalDataIdentifiers.RatedSpeed, value, unit, semanticId);
return this;
}
}

public sealed class BearingCharacteristicsGroupBuilder(TechnicalDataBuilder parent)
{
public BearingCharacteristicsGroupBuilder WithInnerDiameter(decimal value, string unit = "mm", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.BearingCharacteristics, TechnicalDataIdentifiers.InnerDiameter, value, unit, semanticId);
return this;
}

public BearingCharacteristicsGroupBuilder WithOuterDiameter(decimal value, string unit = "mm", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.BearingCharacteristics, TechnicalDataIdentifiers.OuterDiameter, value, unit, semanticId);
return this;
}

public BearingCharacteristicsGroupBuilder WithWidth(decimal value, string unit = "mm", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.BearingCharacteristics, TechnicalDataIdentifiers.Width, value, unit, semanticId);
return this;
}

public BearingCharacteristicsGroupBuilder WithLimitingSpeed(decimal value, string unit = "1/min", string? semanticId = null)
{
parent.UpsertDecimalProperty(TechnicalDataIdentifiers.BearingCharacteristics, TechnicalDataIdentifiers.LimitingSpeed, value, unit, semanticId);
return this;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using FluentAAS.Builder;

namespace FluentAAS.Templates.TechnicalData;

public static class TechnicalDataCompositionExample
{
public static IEnvironment BuildMotorAndBearingExampleEnvironment()
{
return AasBuilder.Create()
.AddShell("urn:aas:example:drivetrain:1000", "DriveTrainAAS")
.AddTechnicalData("urn:aas:submodel:technical-data:drivetrain:1000")
.WithMotorPerformance(motor => motor
.WithRatedVoltage(400m, "V", TechnicalDataSemantics.RatedVoltage)
.WithRatedCurrent(82.5m, "A", TechnicalDataSemantics.RatedCurrent)
.WithRatedPower(45m, "kW", TechnicalDataSemantics.RatedPower)
.WithRatedSpeed(1470m, "1/min", TechnicalDataSemantics.RatedSpeed))
.WithBearingCharacteristics(bearing => bearing
.WithInnerDiameter(50m, "mm", TechnicalDataSemantics.InnerDiameter)
.WithOuterDiameter(90m, "mm", TechnicalDataSemantics.OuterDiameter)
.WithWidth(20m, "mm", TechnicalDataSemantics.Width)
.WithLimitingSpeed(6500m, "1/min", TechnicalDataSemantics.LimitingSpeed))
.BuildTechnicalData()
.Build();

Check failure on line 23 in FluentAAS/FluentAAS.Templates/TechnicalData/TechnicalDataCompositionExample.cs

View workflow job for this annotation

GitHub Actions / PR Test Gate

'IShellBuilder' does not contain a definition for 'Build' and no accessible extension method 'Build' accepting a first argument of type 'IShellBuilder' could be found (are you missing a using directive or an assembly reference?)

Check failure on line 23 in FluentAAS/FluentAAS.Templates/TechnicalData/TechnicalDataCompositionExample.cs

View workflow job for this annotation

GitHub Actions / PR Test Gate

'IShellBuilder' does not contain a definition for 'Build' and no accessible extension method 'Build' accepting a first argument of type 'IShellBuilder' could be found (are you missing a using directive or an assembly reference?)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Complete shell chain before invoking Build()

BuildTechnicalData() returns IShellBuilder, but IShellBuilder has no Build() method (it only exposes CompleteShellConfiguration() and Parent). Chaining .Build() directly here makes this example uncompilable and breaks CompositionExample_ShouldBuildEnvironment, so callers cannot execute the sample as written unless they first return to AasBuilder.

Useful? React with 👍 / 👎.

}
}
Loading
Loading